From d16b3d3cdc43e8351026cddaea8d59b1e8e02bbb Mon Sep 17 00:00:00 2001 From: Asfmq <2696428814@qq.com> Date: Thu, 6 Aug 2026 20:51:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(all):=20=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=8C=96=E6=8B=86=E5=88=86=E4=B8=8E=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8C=96=E8=BF=81=E7=A7=BB=E3=80=81=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=BC=95=E6=93=8E=E5=91=BD=E5=90=8D=E4=BD=93=E7=B3=BB=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E3=80=81=E7=89=A9=E7=90=86=E8=BE=93=E5=87=BA=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E5=8A=A0=E5=9B=BA=E4=B8=8E=E7=94=A8=E6=88=B7=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=8E=A5=E9=80=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server/db: 拆 4929 行 db.rs 单体为 db/ 目录,migrations.rs 引入 PRAGMA user_version 版本化迁移运行器(M1~M13) - 任务引擎 Phase 6/7b/7c 改名收敛:EngineStageConfig→PhaseConfig、StagePolicy→ResumePolicy、 Converged→Completed、删除 task_type 列、success_method 拆 tlusty_/synspec_ 双列、 新增 tlusty_status/synspec_status 半失败阶段守卫 - 科学正确性加固:conv_check 任意行 NaN/Inf/溢出判无效(0 行容忍)、新增 spec_is_valid 校验 SYNSPEC 脏谱、itek_history 逐次迭代全量保真、fmt_abn powf 溢出饱和 - 用户配置真正接通:tlusty_chain/tlusty_input 由死字段经 调度器→TaskSpec→executor→runner 透传生效;config 加载期 validate + deny_unknown_fields + 解析失败记 warn - 调度修复:H1 活锁(pending_strategies 跳过已失败策略)、种子查找错误不再静默降级冷启动 - dashboard: 阶段配置面板 tlusty_stage/synspec_stage、"已完成"标签、迭代诊断展示 - docs: 新增 database_refactor_design.md,同步 database/api/PIPELINE/workflow_detail --- CLAUDE.md | 86 + crates/common/src/config.rs | 200 +- crates/common/src/conv_check.rs | 420 +- crates/common/src/fort55_writer.rs | 6 +- crates/common/src/gen_input5.rs | 327 +- crates/common/src/models.rs | 274 +- crates/common/src/nst_writer.rs | 119 +- crates/common/src/result_filter.rs | 65 +- crates/common/src/runner.rs | 180 +- crates/common/src/seed_finder.rs | 2 +- crates/mq/src/sqlite_queue.rs | 57 +- crates/node/src/executor.rs | 152 +- crates/node/src/reporter.rs | 45 +- crates/node/src/worker.rs | 126 +- crates/server/src/api/status.rs | 15 +- crates/server/src/api/task.rs | 23 +- crates/server/src/api/workflow.rs | 49 +- crates/server/src/db.rs | 4929 ----------------- crates/server/src/db/grid.rs | 937 ++++ crates/server/src/db/mod.rs | 3556 ++++++++++++ crates/server/src/db/nodes.rs | 577 ++ crates/server/src/db/seeds.rs | 199 + crates/server/src/db/snapshots.rs | 98 + crates/server/src/db/tasks.rs | 603 ++ crates/server/src/db/workflows.rs | 303 + crates/server/src/lib.rs | 1 + crates/server/src/main.rs | 21 +- crates/server/src/migrations.rs | 565 ++ crates/server/src/scheduler.rs | 518 +- crates/server/tests/api_tests.rs | 164 +- crates/server/tests/wf_migration_isolation.rs | 8 +- dashboard/index.html | 13 +- dashboard/public/theme-init.js | 11 + dashboard/src/api.js | 12 +- dashboard/src/components/wfEnginePanel.js | 65 +- dashboard/src/components/workflows.js | 4 +- dashboard/src/main.js | 6 +- dashboard/src/state.js | 20 +- dashboard/src/style.css | 17 + dashboard/src/utils/format.js | 19 +- dashboard/src/utils/polling.js | 11 +- dashboard/src/utils/yamlStage.js | 37 +- dashboard/src/views/detail/overview.js | 45 +- dashboard/src/views/detail/parSets.js | 29 +- dashboard/src/views/detail/pointPanel.js | 65 +- dashboard/src/views/detail/pointsTable.js | 20 +- dashboard/src/views/workflowDetail.js | 3 +- dashboard/test/format.test.js | 23 +- dashboard/test/polling.test.js | 35 + dashboard/test/psCache.test.js | 2 +- dashboard/test/yamlStage.test.js | 87 +- docs/PIPELINE.md | 22 +- docs/api.md | 81 +- docs/architecture.md | 2 +- docs/database.md | 33 +- docs/database_refactor_design.md | 546 ++ docs/workflow_detail_design.md | 39 +- scripts/deploy.sh | 39 +- scripts/fetch_results.sh | 150 +- tools/import_results/src/main.rs | 37 +- workflows/sdB_cno.yaml | 51 +- 61 files changed, 10268 insertions(+), 5881 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 crates/server/src/db.rs create mode 100644 crates/server/src/db/grid.rs create mode 100644 crates/server/src/db/mod.rs create mode 100644 crates/server/src/db/nodes.rs create mode 100644 crates/server/src/db/seeds.rs create mode 100644 crates/server/src/db/snapshots.rs create mode 100644 crates/server/src/db/tasks.rs create mode 100644 crates/server/src/db/workflows.rs create mode 100644 crates/server/src/migrations.rs create mode 100644 dashboard/public/theme-init.js create mode 100644 docs/database_refactor_design.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..51b968e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +DCTS is a Rust-based distributed computing scheduler for stellar atmosphere modeling. It discretizes a multi-dimensional parameter grid (Teff, log g, log He, log C, log N, log O) into independent compute points, runs TLUSTY (atmosphere) + SYNSPEC (synthetic spectrum) physics binaries on distributed worker nodes, and orchestrates convergence via seed passing and divergence fallback. Fully documented (in Chinese) under `docs/`. + +## Commands + +```bash +# Build / check +cargo build --release # binaries: server, node, import_results +cargo check --workspace --all-targets + +# Tests (per crate) +cargo test --workspace +cargo test -p common # physics/input/conv-check unit tests +cargo test -p mq # SQLite queue concurrency + requeue tests +cargo test -p server # axum integration tests (api_tests.rs, wf_migration_isolation.rs) +cargo test -p server --test api_tests # single integration test file +cargo test -p server --test api_tests test_name # single test by name filter + +# Lint / format (must be clean before commit — see contributing.md) +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings + +# Dashboard (Vite + native ESM, Node built-in test runner) +cd dashboard && npm install +npm run dev # dev server +npm run build # outputs dashboard/dist (served by server on /) +npm test # node --test "test/*.test.js" +``` + +## Runtime + +```bash +./target/release/server --workflow workflows/sdB_cno.yaml --port 8090 +./target/release/node # worker; reads .env, DCTS_SERVER_URL +``` + +All config is via environment variables prefixed `DCTS_` (see `.env.example`): `DCTS_ADMIN_TOKEN`, `DCTS_NODE_ID`, `DCTS_SERVER_URL`, `DCTS_MAX_SLOTS`, `DCTS_HEARTBEAT_SEC`, `DCTS_DB_PATH`, `DCTS_QUEUE_DB_PATH`, `DCTS_SEEDS_DIR`, etc. `dotenvy` loads `.env`. With `DCTS_AUTH_DISABLE=1` auth is skipped (local debug only). + +## Architecture + +Workspace crates (`Cargo.toml`): `common` (lib), `server` (bin), `node` (bin), `mq` (lib), `tools/import_results` (bin), plus `dashboard/` (web UI). + +### Master-Worker topology (pull-based) + +- **`server`** — Axum HTTP service. `main.rs` builds the router + background loops (offline detection, stale-task requeue). `db/` module (SQLite + r2d2): `db/mod.rs` holds the `Database` struct, connection/migration infra, shared types & the consolidated unit tests; `db/grid.rs`/`db/nodes.rs`/`db/tasks.rs`/`db/seeds.rs`/`db/workflows.rs`/`db/snapshots.rs` hold the per-domain `impl Database` methods. `scheduler.rs` expands the grid and pushes pending points into the MQ queue. `api/` submodules: `node.rs` (register/heartbeat), `task.rs` (claim/report), `seed.rs` (.7 download), `data.rs` (binary/runtime deps), `workflow.rs` (CRUD + start/stop), `status.rs`, `admin.rs`, `auth.rs`, `rate_limit.rs`. +- **`node`** — stateless worker daemon. `worker.rs` is the poll loop (heartbeat + claim + concurrent slot pool). `executor.rs` runs the physics chain in a per-task sandbox. `reporter.rs` uploads results + `.7` seed via multipart. Bootstrap pulls missing runtime deps from master. +- **`mq`** — `SqliteTaskQueue` (WAL mode): transactional Push/Claim/Report/Stale-Requeue. Claim is atomic so one task goes to one worker. +- **`common`** — physics engine: `config.rs` (YAML parsing), `gen_input5.rs`/`fort55_writer.rs`/`nst_writer.rs` (TLUSTY input streams), `conv_check.rs` (parses fort.6 log, judges convergence), `runner.rs` (async subprocess with timeout), `seed_finder.rs` (nearest-neighbor seed matching), `models.rs` (shared types/enums), `embedded.rs` (bootstrap), `logging.rs`. + +### Task scheduling — deliberate pull model + +The scheduler only pushes pending grid points into the MQ queue; it does NOT decide which node gets a task. Workers actively claim work whenever `active_slots < max_slots` and stop claiming when full — this achieves natural load balancing in steady state. Dequeue order is `wave ASC, created_at ASC` (low CNO hard-sum first, then FIFO). Do not "improve" this to server-side load distribution unless the sparse-state tradeoffs are explicitly accepted (see `docs/architecture.md §5`). + +### Task lifecycle + +`pending → queued → running → completed / failed`. Statuses map to `GridPointStatus` in `common/src/models.rs` (`completed` supersedes the old `converged` alias, still parsed). Running tasks that exceed the stale timeout (~1800s) are requeued back to `pending`. + +## Physics pipeline (TLUSTY/SYNSPEC) + +Each grid point runs a **strategy chain** (`tlusty_strategies` in the workflow YAML, e.g. `["cold_run", "seed_step"]`): + +- **Cold run** (`default_cold_chain` in `common/src/runner.rs`): `lte` (grey LTE guess) → `nc` (NLTE continuum, `ilvlin=0, niter=10`) → `nl` (NLTE full lines, `ilvlin=100`, must converge) → `synspec`. +- **Seed step** (`default_seed_chain`): skip the grey LTE stage, hot-start from a nearby converged atmosphere `.7` as `fort.8`: `seed_nc` → `nl` → `synspec`. + +On `nl` non-convergence the server pops the chain head and retries with the next strategy (`trigger_strategy_fallback`). `seed_step` requires a resolvable neighboring seed before dispatch; if none exists the grid point stays pending and self-heals once a seed appears. Only `failed` tasks with an actual state transition trigger fallback (a 2026-08-02 fix prevents duplicate failure reports from re-triggering). Nodes are strategy-unaware — they only execute `strategies[0]` plus the injected `seed_point_name`. + +### Seed finding (`common/src/seed_finder.rs`) + +The seed pool is a global shared resource (seeds table + in-memory cache, cross-workflow). `exact_family` buckets by quantized `(teff/5000, logg, loghe)`; within a family, candidates are ranked by **directed CNO distance** — increasing metal abundance (target richer) is penalized 4×, decreasing (target poorer) 1×, because adding metals destabilizes NLTE radiation equilibrium (historically 3–11% success vs 42–54% for metal-poor direction). Global fallback uses `d = Δteff/5000 + Δlogg×2 + Δloghe×0.5 + Δcno×0.1` with `d ≤ 3.0`. + +## Web dashboard (`dashboard/`) + +Native ESM modules (no framework), Vite build. `main.js`/`router.js`/`state.js`/`api.js` are the shell; `utils/` (format, polling, yamlStage, errors) and `components/`, `views/` (home, workflowDetail, detail/ for per-point panels). Tests are pure Node scripts in `dashboard/test/` run via `node --test`. State is centralized in `state.js` with polling from `utils/polling.js`. + +## Conventions & gotchas + +- **Convergence tuning is empirical** — parameters like `niter=10` for `nc` are the result of documented measurements (see `workflows/sdB_cno.yaml` comments and `docs/spectrum_correctness_analysis.md`). Don't change them casually; validate against the guide before altering. +- **Update docs when you change contracts**: API changes → `docs/api.md`; DB schema → `docs/database.md`; physics/scheduling → `docs/design.md`, `docs/task_engine_decoupling_design.md`. The repo treats these as living specs. +- **Keep `serde_yaml` pinned to 0.9.34** (the upstream crate is archived; the workspace pins the final stable version deliberately). +- **DB migrations** live in `crates/server/src/migrations.rs`; old SQLite DBs are migrated seamlessly at startup (see `docs/database_refactor_design.md`). +- Commit messages follow Conventional Commits with scopes `common`/`server`/`node`/`mq`/`dashboard`/`all` (see `docs/contributing.md`). \ No newline at end of file diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index 82053ee..371ada2 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -1,11 +1,13 @@ -use crate::models::{EngineStageConfig, GridAxisValue}; +use crate::models::{GridAxisValue, PhaseConfig}; use anyhow::{Context, Result}; use regex::Regex; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::Path; use std::sync::OnceLock; #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct GridAxesConfig { pub teff: Vec, pub logg: Vec, @@ -136,24 +138,77 @@ impl GridConfig { if let Some(raw_axes) = parse_grid_axes_raw(yaml) { cfg.grid = raw_axes; } + cfg.validate()?; Ok(cfg) } + /// 配置合法性校验(审查修复 #M2/#N3):把配置错误从运行时逐点失败提前到加载时, + /// 避免笔误(如 chmax: 0、logc: 400)静默产出错误输入文件、浪费算力。 + /// + /// 校验项: + /// - `tlusty_chain` 中每个 ChainStep 的 `chmax` 若为 Some,必须 > 0(conv_check.rs + /// 的守卫会把 chmax <= 0 判为非法、整 stage 判发散;runner.rs 的 unwrap_or(0.001) + /// 只在字段缺失时兜底,显式写 0 不会触发)。 + /// - grid 六轴数值范围合理性(teff > 0;丰度 logc/logn/logo 物理上 ∈ [-20, 10], + /// 超出几乎必为笔误,如 logc: 400 会让 gen_input5 的 10^logx 溢出为 Inf 污染输入文件)。 + fn validate(&self) -> Result<()> { + for (i, step) in self.tlusty_chain.iter().enumerate() { + if let Some(chmax) = step.chmax { + // 用 partial_cmp 显式判断:`!(chmax > 0.0)` 对 NaN 为 true(NaN 比较恒 false), + // 应拒绝 NaN;改写为 `chmax <= 0.0` 会漏掉 NaN(NaN<=0 也是 false),故不用。 + if !matches!(chmax.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) { + anyhow::bail!( + "tlusty_chain[{}] (label={}) 的 chmax={} 非法:必须 > 0(<=0 会被 conv_check 判为整 stage 发散)", + i, step.label, chmax + ); + } + } + // 注:niter 不校验 > 0——LTE grey start 步骤 niter=0 是合法设计 + //(runner.rs:455 显式处理 niter==0 为「不迭代,直接用 grey atmosphere 作初值」)。 + } + // teff 必须为正(物理温度)。 + for (i, t) in self.grid.teff.iter().enumerate() { + let v = t.value(); + // 同 chmax:`!(v > 0.0)` 保持对 NaN 的拒绝语义,用 partial_cmp 显式表达。 + if !matches!(v.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) { + anyhow::bail!("grid.teff[{}] = {} 非法:温度必须 > 0", i, v); + } + } + // 丰度对数轴范围校验(超出 [-20, 10] 几乎必为笔误,且会令 10^logx 溢出为 Inf)。 + for (axis_name, vals) in [ + ("logc", &self.grid.logc), + ("logn", &self.grid.logn), + ("logo", &self.grid.logo), + ("loghe", &self.grid.loghe), + ] { + for (i, av) in vals.iter().enumerate() { + let v = av.value(); + if !(-20.0..=10.0).contains(&v) { + anyhow::bail!( + "grid.{}[{}] = {} 超出物理合理范围 [-20, 10]:请检查是否笔误(超出会让 10^logx 溢出为 Inf 污染输入文件)", + axis_name, i, v + ); + } + } + } + Ok(()) + } + /// 解析 TLUSTY 阶段配置。 /// /// 优先级(见 docs/task_engine_decoupling_design.md §3): - /// 1. 新版顶层 `tlusty:` 块(EngineStageConfig)—— 显式覆盖; + /// 1. 新版顶层 `tlusty_stage:` 块(PhaseConfig)—— 显式覆盖; /// 2. 旧版 `seed_step_fallback: bool` —— true → `[cold_run, seed_step]`, /// false → `[cold_run]`(不回退种子步进); /// 3. 兜底 `default_tlusty()`。 /// /// 注:旧版只控制是否回退种子步进,无 enabled/policy 维度,故回退路径固定 /// enabled=true / policy=SkipConverged(与新默认一致)。 - pub fn resolve_tlusty_config(&self) -> EngineStageConfig { - if let Some(cfg) = &self.tlusty { + pub fn resolve_tlusty_config(&self) -> PhaseConfig { + if let Some(cfg) = &self.tlusty_stage { return cfg.clone(); } - let mut cfg = EngineStageConfig::default_tlusty(); + let mut cfg = PhaseConfig::default_tlusty(); if !self.seed_step_fallback { cfg.strategies = vec!["cold_run".to_string()]; } @@ -163,22 +218,90 @@ impl GridConfig { /// 解析 SYNSPEC 阶段配置。 /// /// 优先级: - /// 1. 新版顶层 `synspec_stage:` 块(EngineStageConfig)—— 显式覆盖(含 enabled 开关); + /// 1. 新版顶层 `synspec_stage:` 块(PhaseConfig)—— 显式覆盖(含 enabled 开关); /// 2. 兜底 `default_synspec()`(enabled=true,保持旧行为:有大气就跑光谱)。 /// - /// 注:旧版 `synspec: SynspecConfig`(数值参数)不影响阶段启用/策略——它只携带 + /// 注:旧版 `synspec: SynspecInput`(数值参数)不影响阶段启用/策略——它只携带 /// 波长范围等数值,由调度器透传到 TaskSpec.synspec_params。如需禁用 SYNSPEC, /// 必须用新版 `synspec_stage: { enabled: false }`。 - pub fn resolve_synspec_config(&self) -> EngineStageConfig { + pub fn resolve_synspec_config(&self) -> PhaseConfig { if let Some(cfg) = &self.synspec_stage { return cfg.clone(); } - EngineStageConfig::default_synspec() + PhaseConfig::default_synspec() } } +// ===== TLUSTY 输入文件(.5 + nst)全局物理参数 ===== +// 这些参数不随收敛阶段(lte/nc/nl)变化——NFREAD 频率网格、ions 能级数据表等是 +// 物理建模选择,一旦确定对整个大气计算全局生效。阶段差异参数(lte/ltgray/niter/ +// chmax/metals 等)保留在 ChainStep 里。 +// 缺省 None → 走代码内硬编码默认(gen_input5.rs/nst_writer.rs 的常量),与改动前行为一致。 + +/// 单个元素的 atoms 块配置(.5 文件的 `mode abn modpf` 行)。 +/// abn(丰度)不在此配——每网格点不同,由 GridPointParams.loghe/logc/logn/logo 计算。 #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StageConfig { +pub struct AtomConfig { + /// 0=不计算, 1=隐式(仅 LTE Saha 算电荷,不出现在 ions 块), 2=显式(统计平衡)。 + /// None → 走代码默认(H/He/CNO=2,Li/Be/B=0)。 + pub mode: Option, + /// partition function 模式(.5 atoms 行第 3 列 modpf)。None → 默认 0。 + #[serde(default)] + pub modpf: Option, +} + +/// 单个离子的能级数据配置(.5 文件 ions 块的一行)。 +/// ilast 由 nlevs 推导(nlevs==1 → ilast=1 裸核终止标志,否则 0),不暴露。 +/// ilvlin 由 ChainStep.ilvlin 提供(nlevs==1 时强制 0),不在此配。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IonConfig { + /// 原子序数(1=H, 2=He, 6=C, 7=N, 8=O...)。 + pub iat: i32, + /// 电离级(0=中性, 1=一次电离, 2=二次电离...)。 + pub iz: i32, + /// 能级数(该离子的能级模型复杂度)。 + pub nlevs: i32, + /// 4 字符离子标识(如 `" H 1"`、`"He 2"`),用于 tlusty 日志与诊断。 + pub typion: String, + /// 能级数据文件路径(如 `"data/h1.dat"`),裸核(nlevs==1)用 `" "`。 + pub filei: String, +} + +/// TLUSTY 输入文件的全局物理参数(.5 + nst 的非阶段差异部分)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TlustyInput { + /// .5 frequencies 块的 NFREAD:连续频率网格点数。 + /// 取值 >0 → 从预设频率表读取(高精度、慢);≤0 → 由 frmin/frmax 自动生成对数网格(快)。 + /// 默认 2000(gen_input5.rs 原硬编码值)。 + #[serde(default = "default_nfread")] + pub nfread: i32, + + /// atoms 块特定元素的 mode/modpf 覆盖。 + /// key = 元素符号(`"H"`/`"He"`/`"C"`/`"N"`/`"O"`/`"Li"`/`"Be"`/`"B"`)。 + /// 缺省的元素走代码默认 mode(H/He/CNO=2,Li/Be/B=0)。 + #[serde(default)] + pub atoms: HashMap, + + /// ions 能级数据表(完全替换默认的 H/He/C/N/O 23 行表)。 + /// 空Vec → 用 gen_input5.rs 的默认常量表。 + /// 非空时由 metals 参数按 iat 筛选参与元素。 + #[serde(default)] + pub ions: Vec, + + /// nst 文件的额外关键字(逃逸口):自由传入任意 `KEY=VALUE` 对。 + /// 生成 nst 时追加到末尾(每行一个),用于暴露未结构化的 220+ nst 关键字 + /// (如 FRCMAX/CUTBAL/TAU/NDGREY 等)。 + #[serde(default)] + pub nst_extra_keys: Vec<(String, String)>, +} + +fn default_nfread() -> i32 { + 2000 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ChainStep { pub label: String, #[serde(default = "default_false_str")] pub lte: String, @@ -207,8 +330,10 @@ fn default_niter() -> i32 { 50 } +/// SYNSPEC 输入文件(fort.55)的数值参数。与 `TlustyInput` 语义对称—— +/// 分别承载各阶段输入文件的物理参数(tlusty 的 .5/nst vs synspec 的 fort.55)。 #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SynspecConfig { +pub struct SynspecInput { #[serde(default = "default_wstart")] pub wstart: f64, #[serde(default = "default_wend")] @@ -249,12 +374,28 @@ fn default_abs_cutoff() -> f64 { 0.01 } +/// 审查修复:`#[serde(deny_unknown_fields)]` 让旧字段名(如已重命名的 `chain`/`synspec`/ +/// `tlusty`/`results`/`itek_fallback`)在反序列化时**立即报错**,而非静默丢弃导致配置失效。 +/// 用户明确不需要向后兼容,故用严格模式让配置错误尽早暴露。 #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct GridConfig { pub grid: GridAxesConfig, + /// TLUSTY 物理迭代步进链(lte/nc/nl 多阶段 ChainStep 数组)。 + /// 每阶段独立配置 niter/chmax/metals/ilvlin 等参数,由 scheduler 序列化进 + /// TaskSpec.tlusty_chain_params,executor 反序列化后透传给 runner.custom_chain。 + /// 空(缺省)→ executor 用 `default_chain_for_strategy` 兜底(按策略名选默认链)。 #[serde(default)] - pub chain: Vec, - pub synspec: Option, + pub tlusty_chain: Vec, + /// TLUSTY 输入文件(.5 + nst)的全局物理参数。缺省 None → 走代码内硬编码默认。 + /// 由 scheduler 序列化进 TaskSpec.tlusty_input_params,executor 反序列化后 + /// 透传给 runner → make_input5 / generate_nst_content。 + #[serde(default)] + pub tlusty_input: Option, + /// SYNSPEC 输入文件(fort.55)的数值参数。与 `tlusty_input` 语义对称。 + /// 由 scheduler 序列化进 TaskSpec.synspec_params,executor 反序列化后透传给 runner。 + #[serde(default)] + pub synspec_input: Option, #[serde(default = "default_nworkers")] pub nworkers: usize, #[serde(default = "default_timeout")] @@ -263,13 +404,6 @@ pub struct GridConfig { pub resume: bool, #[serde(default = "default_true")] pub seed_step_fallback: bool, - /// **已弃用的死字段**:旧版 Python 工具链遗留,无任何代码读取(实际目录以 - /// `ServerConfig.seeds_dir` / `DCTS_SEEDS_DIR` 为准)。保留以兼容旧 workflow YAML。 - #[deprecated(note = "死字段,实际目录以 DCTS_SEEDS_DIR 为准")] - #[serde(default)] - pub results: Option, - #[serde(default)] - pub itek_fallback: Vec, #[serde(default = "default_grid_niter")] pub niter: Option, pub template: Option, @@ -277,12 +411,14 @@ pub struct GridConfig { pub linelist: Option, /// TLUSTY 阶段独立配置(见 docs/task_engine_decoupling_design.md §3)。 /// 缺省 None → `resolve_tlusty_config()` 据旧 `seed_step_fallback` 推断默认链。 + /// 命名为 `tlusty_stage` 以与同级 `synspec_stage` 对称(均带 `_stage` 后缀, + /// 表示阶段启用/策略配置,区别于 `synspec: SynspecInput` 数值参数)。 #[serde(default)] - pub tlusty: Option, - /// SYNSPEC 阶段独立配置。命名为 `synspec_stage` 以与上方旧 `synspec: SynspecConfig` + pub tlusty_stage: Option, + /// SYNSPEC 阶段独立配置。命名为 `synspec_stage` 以与上方旧 `synspec: SynspecInput` ///(光谱合成数值参数)区分。缺省 None → `resolve_synspec_config()` 给默认 `[standard]`。 #[serde(default)] - pub synspec_stage: Option, + pub synspec_stage: Option, } fn default_grid_niter() -> Option { @@ -356,17 +492,16 @@ fn default_node_stale_sec() -> u64 { impl Default for ServerConfig { fn default() -> Self { + // Phase 7b:清理旧 CNO_* / DCTS_RESULTS_DIR 回退(TLUSTY-first 遗留命名)。 let port = std::env::var("DCTS_PORT") - .or_else(|_| std::env::var("CNO_PORT")) .or_else(|_| std::env::var("PORT")) .unwrap_or_else(|_| "8090".to_string()); 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()); - // 种子库目录:优先 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()); + // 种子库目录:DCTS_SEEDS_DIR,缺省 data/seeds。 + let seeds_dir = + std::env::var("DCTS_SEEDS_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") @@ -474,9 +609,9 @@ impl std::fmt::Debug for NodeConfig { impl Default for NodeConfig { fn default() -> Self { + // Phase 7b:清理旧 CNO_SERVER_URL 回退。 let server_url = std::env::var("DCTS_SERVER_URL") .or_else(|_| std::env::var("SERVER_URL")) - .or_else(|_| std::env::var("CNO_SERVER_URL")) .unwrap_or_else(|_| "http://127.0.0.1:8090".to_string()); let node_id = std::env::var("DCTS_NODE_ID") .or_else(|_| std::env::var("NODE_ID")) @@ -496,10 +631,9 @@ 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()); + // 结果归档目录:DCTS_RESULT_DIR,缺省 data/result(Phase 7b 清理旧 DCTS_ARCHIVE_DIR 回退)。 + let result_dir = + std::env::var("DCTS_RESULT_DIR").unwrap_or_else(|_| "data/result".to_string()); let heartbeat_sec = std::env::var("DCTS_HEARTBEAT_SEC") .ok() .and_then(|v| v.parse::().ok()) diff --git a/crates/common/src/conv_check.rs b/crates/common/src/conv_check.rs index e51f4ca..97aeff5 100644 --- a/crates/common/src/conv_check.rs +++ b/crates/common/src/conv_check.rs @@ -1,4 +1,4 @@ -use crate::models::ConvCheckResult; +use crate::models::{ConvCheckResult, IterCheck}; use regex::Regex; use std::fs::File; use std::io::{BufRead, BufReader}; @@ -6,9 +6,20 @@ use std::path::Path; use std::sync::OnceLock; static FORT9_RE: OnceLock = OnceLock::new(); +/// 无效数值正则模式(NaN / Inf / Infinity / Fortran 字段溢出 `***` / 超高正指数 E+300+)。 +/// +/// 抽为常量是因为 `NAN_RE` 这个 `OnceLock` 被 `atmosphere_has_nan` 与 `spec_is_valid` +/// 共用——若两处 `get_or_init` 闭包传不同的字符串,`OnceLock` 全局只采用首次初始化的 +/// 版本,第二处闭包被静默丢弃,导致两函数行为不一致且依赖调用顺序(并发测试间歇性失败)。 +/// 常量化保证两处字面一致。超高正指数分支物理论据见 `atmosphere_has_nan` 文档注释。 +const NAN_RE_PATTERN: &str = r"(?i)(\bnan\b|\binf(?:inity)?\b|\*{3,}|[eE]\+(?:3\d{2}|[4-9]\d{2,}))"; static NAN_RE: OnceLock = OnceLock::new(); /// 匹配 Fortran 无-E 科学记数法的尾数+指数部分(归一化用,见 parse_fortran_float)。 static NO_E_EXP_RE: OnceLock = OnceLock::new(); +/// 匹配 fort.6 中求解器发散 STOP 行(如 `**** STOP in SOLVE after ITER 8`)。 +static SOLVER_STOP_RE: OnceLock = OnceLock::new(); +/// 匹配 call quit / stop 留言关键字。 +static QUIT_RE: OnceLock = OnceLock::new(); /// 解析 fort.9 / fort.7 中的数值字符串为 f64,兼容 Fortran 的**无-E 科学记数法**。 /// @@ -63,6 +74,7 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult { last_iter: None, n_depths: 0, chmax, + itek_history: Vec::new(), error: Some(format!( "非法 chmax={}(须为正有限数):请检查工作流 YAML 中该 stage 的 chmax 配置", chmax @@ -80,6 +92,7 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult { last_iter: None, n_depths: 0, chmax, + itek_history: Vec::new(), error: Some(format!("Failed to open fort.9: {}", e)), } } @@ -95,6 +108,9 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult { let mut last_iter: Option = None; let mut cur_iter: Option = None; let mut cur_rows: Vec = Vec::new(); + // 逐次迭代诊断(Phase 5b itek 全量保真):每次迭代记录该次最大 |maximum|。 + let mut itek_history: Vec = Vec::new(); + let mut iter_max_relc: f64 = 0.0; for line in reader.lines().map_while(Result::ok) { if let Some(caps) = re.captures(&line) { @@ -112,14 +128,35 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult { }; if cur_iter != Some(iter) { + // 迭代切换:结算上一拍(若有),并开启新拍。 + if let Some(prev_iter) = cur_iter { + itek_history.push(IterCheck { + iter: prev_iter, + max_relc: iter_max_relc, + n_depths: cur_rows.len(), + }); + } cur_iter = Some(iter); cur_rows.clear(); + iter_max_relc = 0.0; } cur_rows.push(Fort9Row { depth, maximum }); + let abs_max = maximum.abs(); + if abs_max > iter_max_relc { + iter_max_relc = abs_max; + } last_iter = Some(iter); } } + // 收尾:结算最后一拍。 + if let Some(prev_iter) = cur_iter { + itek_history.push(IterCheck { + iter: prev_iter, + max_relc: iter_max_relc, + n_depths: cur_rows.len(), + }); + } if cur_rows.is_empty() || last_iter.is_none() { return ConvCheckResult { @@ -129,6 +166,7 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult { last_iter: None, n_depths: 0, chmax, + itek_history: Vec::new(), error: Some("No valid iteration data found in fort.9".to_string()), }; } @@ -149,6 +187,7 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult { last_iter, n_depths: 0, chmax, + itek_history: Vec::new(), error: Some( "No valid iteration rows found when calculating maximum change".to_string(), ), @@ -166,6 +205,7 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult { last_iter, n_depths: cur_rows.len(), chmax, + itek_history, error: if is_valid_num { None } else { @@ -182,11 +222,24 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult { /// - Fortran 字段宽度溢出标记 `***`(如 `********`):Tlusty 数值溢出发散时常以星号填满 /// 字段而非写 NaN。历史上只检 `\bnan\b`,全溢出发散的大气会被判"无 NaN"→converged, /// 产出物理上完全错误的大气。 +/// - 超高指数科学记数法 `[Ee]\+(3\d{2}|[4-9]\d{2,})`(漏洞4补充盲区):匹配 E+300 以上的 +/// 正指数值(如 `1.0E+308`、`9.99E+307`)。这是数值发散的产物——恒星大气物理量天花板 +/// 在 E+07 量级(温度/密度/布居数),E+300 在物理上无意义。gfortran 实际溢出时多写 `***` +/// (已被上一条覆盖),但"未溢出但接近 f64 上限"的窄窗口需此分支兜底。锁定正号 `\+` +/// 避免误伤合法的极小值(如 E-300)。真实数据集最高仅 E+07,零误报。 /// -/// 超过 10% 的行命中任一标记即判定无效。 +/// **任意一行**命中任一标记即判定无效(0 行容忍)。物理论据:大气模型每个深度点的 +/// 物理量都是耦合求解的,任意一个深度点 NaN/Inf/溢出意味着该层解已破坏,整个大气不可用。 /// -/// 文件缺失时返回 `false`(语义:不存在 NaN 内容)。这与“含 NaN 导致无效”是不同语义; -/// 调用方需先自行确认文件存在性,不应将“缺失”与“含 NaN”混为一谈。 +/// 历史:曾用 10% 阈值(`bad_lines > total*0.1`),但单行 NaN(如表层发散)会被放过, +/// 导致部分坏大气被误判为可用。见 `docs/tlusty&synspec收敛性判断.md` §4 漏洞 4。 +/// +/// 文件缺失时返回 `false`(语义:不存在 NaN 内容)。这与"含 NaN 导致无效"是不同语义; +/// 调用方需先自行确认文件存在性,不应将"缺失"与"含 NaN"混为一谈。 +/// +/// **空文件(存在但 0 行数据)返回 `true`**(语义:无效大气)。0 行意味着大气数据 +/// 缺失/损坏(如 TLUSTY 启动后立即崩溃、写出的文件截断),不应被当作"无 NaN 的可用 +/// 大气"。runner 凭此判定 final_converged=false,行为安全。 pub fn atmosphere_has_nan(path: &Path) -> bool { let file = match File::open(path) { Ok(f) => f, @@ -195,8 +248,7 @@ pub fn atmosphere_has_nan(path: &Path) -> bool { let reader = BufReader::new(file); let mut total_lines = 0; let mut bad_lines = 0; - let nan_re = - NAN_RE.get_or_init(|| Regex::new(r"(?i)(\bnan\b|\binf(?:inity)?\b|\*{3,})").unwrap()); + let nan_re = NAN_RE.get_or_init(|| Regex::new(NAN_RE_PATTERN).unwrap()); for line in reader.lines().map_while(Result::ok) { total_lines += 1; @@ -209,7 +261,120 @@ pub fn atmosphere_has_nan(path: &Path) -> bool { return true; } - (bad_lines as f64) > (total_lines as f64 * 0.1) + // 漏洞4修复:0 行容忍——任意一行 NaN/Inf/溢出/超高指数即判定大气不可用。 + bad_lines > 0 +} + +/// 校验 SYNSPEC 产出的 `.spec` 光谱文件内容是否有效。 +/// +/// # 背景(漏洞 1,P0) +/// gfortran 下 SYNSPEC 几乎所有错误路径都用裸 `STOP`(rc=0),且即便发散也会写出 +/// 一个"看起来存在"的 `.spec`。旧代码只做 `is_file()` 存在性检查,导致脏谱(含 +/// NaN/Inf、行数极少、流量全零)被当作 `Completed` 归档——这是全链路最大的科学 +/// 正确性风险。见 `docs/tlusty&synspec收敛性判断.md` §4 漏洞 1。 +/// +/// # 校验规则(按序短路) +/// 1. 文件缺失/无法读取 → `Some("...")` +/// 2. 逐行扫描累计:含 NaN/Inf/`***` 的 bad_lines、含 ≥2 个可解析数值 token 的有效行、 +/// 流量列(第 2 列)非零的有效行 +/// 3. 空文件 → `Some("spec 为空")` +/// 4. bad_lines > 0 → `Some("spec 含 NaN/Inf/溢出行 (共 N 行)")` +/// 5. 有效行数 < 10 → `Some("spec 有效行数不足 (N<10)")` +/// 6. 非零流量行数 == 0 → `Some("spec 流量全为零")` +/// 7. 全通过 → `None` +/// +/// 返回 `None` 表示有效,`Some(原因)` 表示无效(调用方据此置 `synspec_rc`/`synspec_error`)。 +pub fn spec_is_valid(path: &Path) -> Option { + let file = match File::open(path) { + Ok(f) => f, + Err(_) => return Some("spec 文件缺失或无法读取".to_string()), + }; + let reader = BufReader::new(file); + let nan_re = NAN_RE.get_or_init(|| Regex::new(NAN_RE_PATTERN).unwrap()); + + let mut total_lines = 0; + let mut bad_lines = 0; + let mut valid_lines = 0; // 含 ≥2 个可解析数值 token 的行 + let mut nonzero_flux_lines = 0; // 流量列(第 2 个 token)非零的有效行 + + for line in reader.lines().map_while(Result::ok) { + total_lines += 1; + if nan_re.is_match(&line) { + bad_lines += 1; + continue; + } + // 拆 token,尝试解析为数值。.spec 每行 2 列:波长、流量(FLAM)。 + let tokens: Vec<&str> = line.split_whitespace().collect(); + let parsed: Vec = tokens + .iter() + .filter_map(|t| parse_fortran_float(t)) + .filter(|v| v.is_finite()) + .collect(); + if parsed.len() >= 2 { + valid_lines += 1; + // 第 2 个数值列是流量;流量非零才算有效行(避免全零谱)。 + if parsed[1].abs() > 0.0 { + nonzero_flux_lines += 1; + } + } + } + + if total_lines == 0 { + return Some("spec 为空".to_string()); + } + if bad_lines > 0 { + return Some(format!("spec 含 NaN/Inf/溢出行 (共 {} 行)", bad_lines)); + } + if valid_lines < 10 { + return Some(format!("spec 有效行数不足 ({}<10)", valid_lines)); + } + if nonzero_flux_lines == 0 { + return Some("spec 流量全为零".to_string()); + } + None +} + +/// 从 fort.6(TLUSTY stdout 日志)提取失败诊断提示。 +/// +/// # 背景(漏洞 5,P2) +/// TLUSTY 失败时旧 note 统一记 `"tlusty rc=N or missing fort.7"`,丢失"为何未收敛"线索。 +/// 求解器发散时 fort.6 会印 `**** STOP in SOLVE/SOLVES/RYBSOL after ITER N`(来自 +/// `tlusty208.f:14731/15077/47598`,FORMAT 610);fort.7 缺失(输入错误、temp 越界等 +/// `call quit`)时 fort.6 尾部会有 `stop 'msg'` 留言。两类信息都能显著提升归因质量。 +/// 见 `docs/tlusty&synspec收敛性判断.md` §4 漏洞 5。 +/// +/// # 规则 +/// 1. 优先全文匹配 `STOP in (SOLVE|SOLVES|RYBSOL) after ITER N`(发散求解器名),返回该行。 +/// 2. 否则取最后 5 行,找含 `stop|quit|error`(忽略大小写)的行返回。 +/// 3. 都没有 → `None`。 +pub fn extract_failure_hint(fort6_path: &Path) -> Option { + let file = File::open(fort6_path).ok()?; + let reader = BufReader::new(file); + let solver_re = SOLVER_STOP_RE.get_or_init(|| { + Regex::new(r"(?i)STOP\s+in\s+(SOLVES?|RYBSOL)\s+after\s+ITER\s+\d+").unwrap() + }); + // 单词边界 \b 避免子串误报(如 "stopping criterion"/"no error detected")。 + // 仅在已确认失败的语境下提取 hint,误报后果仅是 note 多一行提示,不影响 converged 判定。 + let quit_re = QUIT_RE.get_or_init(|| Regex::new(r"(?i)\b(stop|quit|error)\b").unwrap()); + + let mut all_lines: Vec = Vec::new(); + for line in reader.lines().map_while(Result::ok) { + all_lines.push(line); + } + + // 1. 全文找发散求解器 STOP 行(取最后一次出现)。 + for line in all_lines.iter().rev() { + if solver_re.is_match(line) { + return Some(line.trim().to_string()); + } + } + // 2. 尾部 5 行找 call quit / stop / error 留言(取最后一条)。 + for line in all_lines.iter().rev().take(5) { + if quit_re.is_match(line) { + return Some(line.trim().to_string()); + } + } + None } #[cfg(test)] @@ -245,6 +410,215 @@ mod tests { let inf_file_path = dir.path().join("inf.7"); std::fs::write(&inf_file_path, "Inf 2 3\nInfinity 5 6\n7 8 9\n").unwrap(); assert!(atmosphere_has_nan(&inf_file_path)); + + // 漏洞4修复:0 行容忍——单行 NaN(1/3 行)也应判定无效。 + let single_nan_path = dir.path().join("single_nan.7"); + std::fs::write(&single_nan_path, "NaN 2 3\n4 5 6\n7 8 9\n").unwrap(); + assert!( + atmosphere_has_nan(&single_nan_path), + "单行 NaN(表层发散的典型形态)应被判为无效" + ); + + // 漏洞4补充盲区:超高指数科学记数法(E+300 以上)是数值发散产物,物理上无意义。 + // 正常大气物理量天花板在 E+07,应被判无效。 + let huge_exp_path = dir.path().join("huge_exp.7"); + std::fs::write( + &huge_exp_path, + "1.0E+07 2.0 3.0\n9.99E+308 5.0 6.0\n7.0 8.0 9.0\n", + ) + .unwrap(); + assert!( + atmosphere_has_nan(&huge_exp_path), + "超高指数 E+308 应被判为无效(数值发散产物)" + ); + + // 边界:合法的极高正指数(E+50,仍远超物理上限但未被超高指数分支命中) + // —— 此测试确认正则只匹配 E+300+,不误伤。 + // 注:E+50 在物理上无意义但 regex 不拦,由物理论据留给将来收紧。 + let high_but_ok_path = dir.path().join("high_ok.7"); + std::fs::write(&high_but_ok_path, "1.0E+50 2.0 3.0\n4.0 5.0 6.0\n").unwrap(); + assert!( + !atmosphere_has_nan(&high_but_ok_path), + "E+50 不在 E+300+ 检测范围,不应被超高指数分支误判" + ); + + // 边界:合法的极小值(E-300)不得被误伤(正则锁定正号 \\+)。 + let tiny_path = dir.path().join("tiny.7"); + std::fs::write(&tiny_path, "1.0E-300 2.0 3.0\n4.0 5.0 6.0\n").unwrap(); + assert!( + !atmosphere_has_nan(&tiny_path), + "极小值 E-300(合法)不得被超高指数分支误伤" + ); + } + + /// 漏洞1修复:SYNSPEC `.spec` 内容校验。 + #[test] + fn test_spec_validation() { + let dir = tempfile::tempdir().unwrap(); + + // 1. 正常谱:≥10 行、每行 2 数值列、流量非零 → None(有效) + let good_spec = dir.path().join("good.spec"); + let mut content = String::new(); + for i in 0..15 { + content.push_str(&format!( + " {:.5} {:.6E}\n", + 1400.0 + i as f64 * 0.5, + 1e-12 + )); + } + std::fs::write(&good_spec, &content).unwrap(); + assert!( + spec_is_valid(&good_spec).is_none(), + "正常多行数值谱应判有效" + ); + + // 2. 脏谱(仓库内真实失败样本形态):仅 2 行 + Infinity/NaN → Some + let dirty_spec = dir.path().join("dirty.spec"); + std::fs::write( + &dirty_spec, + " Infinity NaN\n 1410.00005 NaN\n", + ) + .unwrap(); + let reason = spec_is_valid(&dirty_spec).expect("脏谱应判无效"); + assert!( + reason.contains("NaN") || reason.contains("Inf"), + "脏谱原因应提及 NaN/Inf,实际:{}", + reason + ); + + // 3. 行数不足(< 10)→ Some + let short_spec = dir.path().join("short.spec"); + std::fs::write( + &short_spec, + " 1400.0 1.0E-12\n 1401.0 1.0E-12\n 1402.0 1.0E-12\n", + ) + .unwrap(); + let reason = spec_is_valid(&short_spec).expect("行数不足应判无效"); + assert!( + reason.contains("行数不足"), + "行数不足原因,实际:{}", + reason + ); + + // 4. 全零流量 → Some + let zero_spec = dir.path().join("zero.spec"); + let mut content = String::new(); + for i in 0..15 { + content.push_str(&format!(" {:.5} {:.6E}\n", 1400.0 + i as f64 * 0.5, 0.0)); + } + std::fs::write(&zero_spec, &content).unwrap(); + let reason = spec_is_valid(&zero_spec).expect("全零谱应判无效"); + assert!(reason.contains("全为零"), "全零谱原因,实际:{}", reason); + + // 5. 文件缺失 → Some + let missing_spec = dir.path().join("missing.spec"); + let reason = spec_is_valid(&missing_spec).expect("文件缺失应判无效"); + assert!(reason.contains("缺失"), "文件缺失原因,实际:{}", reason); + + // 6. Fortran 字段溢出 *** → Some + let overflow_spec = dir.path().join("overflow.spec"); + let mut content = String::new(); + for i in 0..15 { + content.push_str(&format!( + " {:.5} {:.6E}\n", + 1400.0 + i as f64 * 0.5, + 1e-12 + )); + } + // 第 3 行混入溢出行 + let mut lines: Vec<&str> = content.lines().collect(); + if lines.len() > 2 { + lines[2] = " 1401.0 ********"; + } + std::fs::write(&overflow_spec, lines.join("\n") + "\n").unwrap(); + let reason = spec_is_valid(&overflow_spec).expect("含溢出标记的谱应判无效"); + assert!(reason.contains("溢出"), "溢出标记原因,实际:{}", reason); + + // 7. 超高指数(E+308,数值发散产物)→ Some。 + // 双重作用:(a) 验证 spec_is_valid 拦截超高指数; + // (b) 守护 NAN_RE OnceLock 一致性——spec_is_valid 与 atmosphere_has_nan + // 共用 NAN_RE,若两处正则字符串不一致(OnceLock 竞态),此断言会 + // 在多线程测试时间歇性失败。 + let huge_exp_spec = dir.path().join("huge_exp.spec"); + let mut content = String::new(); + for i in 0..15 { + content.push_str(&format!( + " {:.5} {:.6E}\n", + 1400.0 + i as f64 * 0.5, + 1e-12 + )); + } + let mut lines: Vec = content.lines().map(|s| s.to_string()).collect(); + if lines.len() > 2 { + lines[2] = " 1401.0 9.99E+308".to_string(); + } + std::fs::write(&huge_exp_spec, lines.join("\n") + "\n").unwrap(); + let reason = spec_is_valid(&huge_exp_spec).expect("含超高指数的谱应判无效"); + assert!( + reason.contains("NaN") || reason.contains("Inf") || reason.contains("溢出"), + "超高指数应被识别为无效数值,实际原因:{}", + reason + ); + } + + /// 漏洞5修复:fort.6 失败诊断提示提取。 + #[test] + fn test_extract_failure_hint() { + let dir = tempfile::tempdir().unwrap(); + + // 1. 发散求解器 STOP 行 → 提取该行 + let solve_log = dir.path().join("solve.6"); + std::fs::write( + &solve_log, + " KANTOROVICH acceleration: ITER 7\n\ + **** STOP in SOLVE after ITER 8\n\ + Max change: 3.59E+20\n", + ) + .unwrap(); + let hint = extract_failure_hint(&solve_log).expect("发散日志应返回 hint"); + assert!( + hint.contains("STOP in SOLVE"), + "应提取求解器 STOP 行,实际:{}", + hint + ); + + // RYBSOL 路径 + let rybsol_log = dir.path().join("rybsol.6"); + std::fs::write(&rybsol_log, "**** STOP in RYBSOL after ITER 3\n").unwrap(); + let hint = extract_failure_hint(&rybsol_log).expect("RYBSOL 日志应返回 hint"); + assert!(hint.contains("RYBSOL")); + + // 2. call quit 留言(fort.7 缺失场景,如 temp 越界)→ 提取尾部 + let quit_log = dir.path().join("quit.6"); + std::fs::write( + &quit_log, + " some normal output\n\ + partf; temp<1000 K\n\ + stop 'partf; temp<1000 K'\n", + ) + .unwrap(); + let hint = extract_failure_hint(&quit_log).expect("call quit 日志应返回 hint"); + assert!( + hint.contains("partf") || hint.to_lowercase().contains("stop"), + "应提取 quit 留言,实际:{}", + hint + ); + + // 3. 无关日志(正常收敛,无 STOP/quit)→ None + let clean_log = dir.path().join("clean.6"); + std::fs::write( + &clean_log, + " KANTOROVICH acceleration: ITER 10\n Converged.\n", + ) + .unwrap(); + assert!( + extract_failure_hint(&clean_log).is_none(), + "正常日志不应返回 hint" + ); + + // 4. 文件缺失 → None + let missing = dir.path().join("missing.6"); + assert!(extract_failure_hint(&missing).is_none()); } /// 验证 parse_fortran_float 对各种数值格式(含 Fortran 无-E 记数法)的解析。 @@ -253,7 +627,7 @@ mod tests { // 标准 parse 能覆盖的 assert_eq!(parse_fortran_float("100"), Some(100.0)); assert_eq!(parse_fortran_float("-0.001"), Some(-0.001)); - assert_eq!(parse_fortran_float("3.14"), Some(3.14)); + assert_eq!(parse_fortran_float("2.5"), Some(2.5)); assert_eq!(parse_fortran_float("-5.42E+72"), Some(-5.42e72)); assert_eq!(parse_fortran_float("1.5e-99"), Some(1.5e-99)); assert_eq!(parse_fortran_float("0"), Some(0.0)); @@ -336,5 +710,35 @@ mod tests { let res = check_fort9(&fort9, 0.001); assert!(res.converged, "正常收敛行应判为收敛"); assert!((res.max_relc - 1.0e-5).abs() < 1e-15); + assert_eq!(res.itek_history.len(), 1, "单迭代应只有一拍"); + assert_eq!(res.itek_history[0].iter, 10); + } + + /// Phase 5b itek 全量保真:多迭代 fort.9 → itek_history 逐拍记录(iter → 该次最大 |maximum|)。 + #[test] + fn test_fort9_itek_history_per_iteration() { + let dir = tempfile::tempdir().unwrap(); + let fort9 = dir.path().join("multi_iter.9"); + // 两拍:iter1 最深 max_relc=2.0E-2(depth2)、iter2 最深 max_relc=5.0E-4(depth1)。 + std::fs::write( + &fort9, + " 1 1 0 1.0E-3 1.0E-3 1.0E-3 1.0E-2 5 10\n\ + 1 2 0 1.0E-3 1.0E-3 1.0E-3 2.0E-2 5 10\n\ + 2 1 0 1.0E-3 1.0E-3 1.0E-3 5.0E-4 5 10\n\ + 2 2 0 1.0E-3 1.0E-3 1.0E-3 3.0E-4 5 10\n", + ) + .unwrap(); + let res = check_fort9(&fort9, 0.001); + // 末拍(iter2)收敛。 + assert!(res.converged); + assert!((res.max_relc - 5.0e-4).abs() < 1e-15); + // itek_history 逐拍完整。 + assert_eq!(res.itek_history.len(), 2); + assert_eq!(res.itek_history[0].iter, 1); + assert!((res.itek_history[0].max_relc - 2.0e-2).abs() < 1e-15); + assert_eq!(res.itek_history[0].n_depths, 2); + assert_eq!(res.itek_history[1].iter, 2); + assert!((res.itek_history[1].max_relc - 5.0e-4).abs() < 1e-15); + assert_eq!(res.itek_history[1].n_depths, 2); } } diff --git a/crates/common/src/fort55_writer.rs b/crates/common/src/fort55_writer.rs index e5e6289..1bd0beb 100644 --- a/crates/common/src/fort55_writer.rs +++ b/crates/common/src/fort55_writer.rs @@ -1,7 +1,7 @@ -use crate::config::SynspecConfig; +use crate::config::SynspecInput; /// Dynamic generator for SYNSPEC fort.55 parameter control file -pub fn generate_fort55_content(cfg: &SynspecConfig) -> String { +pub fn generate_fort55_content(cfg: &SynspecInput) -> String { let line1 = format!(" {} {} {}", cfg.imode, cfg.idrv, cfg.ifreq); let line2 = " 1 0 0 0"; let line3 = " 0 0 0 0 0"; @@ -25,7 +25,7 @@ mod tests { #[test] fn test_fort55_generation() { - let cfg = SynspecConfig { + let cfg = SynspecInput { wstart: 3000.0, wend: 7000.0, imode: 0, diff --git a/crates/common/src/gen_input5.rs b/crates/common/src/gen_input5.rs index 74c912a..cde1b09 100644 --- a/crates/common/src/gen_input5.rs +++ b/crates/common/src/gen_input5.rs @@ -1,4 +1,6 @@ +use crate::config::{AtomConfig, IonConfig, TlustyInput}; use crate::models::GridPointParams; +use std::collections::HashMap; struct IonDef { iat: i32, @@ -177,63 +179,193 @@ const IONS_O: &[IonDef] = &[ }, ]; +/// 把对数丰度 logx 格式化为 10^logx 的科学计数法字符串。 +/// +/// 审查修复 #N3:深度防御 powf 溢出——config.validate 已把丰度轴限制在 [-20, 10], +/// 但 fmt_abn 也可能被 tlusty_chain 里的元素丰度等其它路径调用。若 logx 超出约 [-308, 308], +/// `10f64.powf(logx)` 会溢出为 Inf/0,写出 `inf` 污染 .5 输入文件导致 TLUSTY 行为未定义。 +/// 配置层校验在前,此处仅作最终防线:溢出时 saturate 到 f64 可表示范围并按原格式输出。 fn fmt_abn(logx: f64) -> String { - format!("{:.4E}", 10.0f64.powf(logx)) + let val = if logx.abs() < 300.0 { + 10.0f64.powf(logx) + } else { + // 超出安全范围:clamp 到 0(丰度对数极小)或 f64::MAX(丰度对数极大), + // 避免写出 inf。配置层应已拦截,这里不会成为正常路径。 + if logx < 0.0 { + 0.0 + } else { + f64::MAX + } + }; + format!("{:.4E}", val) +} + +/// 元素符号首字母大写归一("h"→"H", "he"→"He", "HE"→"He")。 +/// 容忍用户在 YAML atoms 块用小写/全大写写元素符号。 +fn capitalize_first(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + Some(f) => f.to_uppercase().collect::() + c.as_str().to_lowercase().as_str(), + None => String::new(), + } +} + +/// 默认 ions 能级数据表(与历史 IONS_* 常量等价)。 +/// 用户未在 `tlusty_input.ions` 配置时使用此表,按 metals 筛选参与元素。 +fn default_ions(metals: &str) -> Vec { + let mt = metals.to_lowercase(); + let has_c = mt.contains('c'); + let has_n = mt.contains('n'); + let has_o = mt.contains('o'); + let mut ions: Vec = Vec::new(); + // H(恒在) + for d in IONS_H { + ions.push(IonConfig { + iat: d.iat, + iz: d.iz, + nlevs: d.nlevs, + typion: d.typion.to_string(), + filei: d.filei.to_string(), + }); + } + // He(恒在) + for d in IONS_HE { + ions.push(IonConfig { + iat: d.iat, + iz: d.iz, + nlevs: d.nlevs, + typion: d.typion.to_string(), + filei: d.filei.to_string(), + }); + } + let extend = |ions: &mut Vec, table: &[IonDef]| { + for d in table { + ions.push(IonConfig { + iat: d.iat, + iz: d.iz, + nlevs: d.nlevs, + typion: d.typion.to_string(), + filei: d.filei.to_string(), + }); + } + }; + if has_c { + extend(&mut ions, IONS_C); + } + if has_n { + extend(&mut ions, IONS_N); + } + if has_o { + extend(&mut ions, IONS_O); + } + ions } /// Constructs the complete text of a `.5` input file for TLUSTY +/// +/// `input_cfg`:全局输入参数(NFREAD / atoms.mode / ions 表)。 +/// None → 走代码内硬编码默认(与改动前行为完全一致,向后兼容)。 pub fn make_input5( params: &GridPointParams, lte: &str, ltgray: &str, metals: &str, ilvlin: i32, + input_cfg: Option<&TlustyInput>, ) -> String { let mt = metals.to_lowercase(); let has_c = mt.contains('c'); let has_n = mt.contains('n'); let has_o = mt.contains('o'); - // 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 - ]; + // 元素符号 → (mode, abn)。mode 优先取 input_cfg.atoms 覆写,否则走默认。 + // abn(丰度)永远由 GridPointParams 计算——每网格点不同,不可全局配置。 + // 查找时对 key 做首字母大写归一(H/He/Li/Be/B/C/N/O),容忍用户写小写 "h"/"he"。 + // + // M1 修复:config.rs 的 `atoms` 是 Serde 反序列化的 `HashMap`, + // 键按 YAML 原样存储(无规范化)。若用户写 `he:`/`HE:`,原实现只归一探针、不归一存储键, + // 查不到规范键 "He" → mode 覆写静默回落默认(错误物理:想要的 LTE-only 变统计平衡)。 + // 这里把用户键也按同一 capitalize_first 规则归一一次,保证任意大小写都能命中。 + let atoms_normalized: Option> = input_cfg.map(|c| { + let mut m = HashMap::with_capacity(c.atoms.len()); + for (k, v) in &c.atoms { + let canon = capitalize_first(k); + // 归一撞键检测:用户同时写 "C" 与 "c"(或 "He"/"HE")等大小写变体时,两键归一为 + // 同一规范键。原 collect() 静默覆盖(迭代序非确定 → 取哪个任选)。现显式提示 + // 让歧义可见,避免 mode 覆写悄悄取到任意一个变体。 + if let Some(_prev) = m.insert(canon.clone(), v) { + tracing::warn!( + "atoms 键 {:?} 与已有键(归一后同为 {:?})冲突:取最后读取项,请改用单一规范键", + k, + canon + ); + } + } + m + }); + let atom_mode = |sym: &str, default_mode: i32| -> i32 { + atoms_normalized + .as_ref() + .and_then(|m| m.get(&capitalize_first(sym))) + .and_then(|a| a.mode) + .unwrap_or(default_mode) + }; + let atom_modpf = |sym: &str| -> i32 { + atoms_normalized + .as_ref() + .and_then(|m| m.get(&capitalize_first(sym))) + .and_then(|a| a.modpf) + .unwrap_or(0) + }; + // Atoms block:H/He 恒在;Li/Be/B 恒在(mode=0 不参与);C/N/O 按 metals。 + let mut atom_rows: Vec<(i32, String, i32)> = vec![ + (atom_mode("H", 2), "0.".to_string(), atom_modpf("H")), // 1 H + (atom_mode("He", 2), fmt_abn(*params.loghe), atom_modpf("He")), // 2 He + (atom_mode("Li", 0), "0.".to_string(), atom_modpf("Li")), // 3 Li + (atom_mode("Be", 0), "0.".to_string(), atom_modpf("Be")), // 4 Be + (atom_mode("B", 0), "0.".to_string(), atom_modpf("B")), // 5 B + ]; if has_c { - atom_rows.push((2, fmt_abn(*params.logc))); // 6 C + atom_rows.push((atom_mode("C", 2), fmt_abn(*params.logc), atom_modpf("C"))); + // 6 C } if has_n { - atom_rows.push((2, fmt_abn(*params.logn))); // 7 N + atom_rows.push((atom_mode("N", 2), fmt_abn(*params.logn), atom_modpf("N"))); + // 7 N } if has_o { - atom_rows.push((2, fmt_abn(*params.logo))); // 8 O + atom_rows.push((atom_mode("O", 2), fmt_abn(*params.logo), atom_modpf("O"))); + // 8 O } - - let natoms = - 5 + (if has_c { 1 } else { 0 }) + (if has_n { 1 } else { 0 }) + (if has_o { 1 } else { 0 }); + let natoms = atom_rows.len() as i32; let mut atoms_block = format!(" {}\n* mode abn modpf\n", natoms); - for (mode, abn) in &atom_rows { - atoms_block.push_str(&format!(" {} {} 0\n", mode, abn)); + for (mode, abn, modpf) in &atom_rows { + atoms_block.push_str(&format!(" {} {} {}\n", mode, abn, modpf)); } - // Ions block - let mut ions: Vec<&IonDef> = Vec::new(); - ions.extend(IONS_H.iter()); - ions.extend(IONS_HE.iter()); - if has_c { - ions.extend(IONS_C.iter()); - } - if has_n { - ions.extend(IONS_N.iter()); - } - if has_o { - ions.extend(IONS_O.iter()); - } + // Ions block:用户配置非空时完全替换默认表,仍按 metals 筛选 iat。 + let ions: Vec = if let Some(cfg) = input_cfg { + if cfg.ions.is_empty() { + default_ions(metals) + } else { + // 用户自定义表——按 metals 筛选参与元素(H/He 恒在,C/N/O 按 metals)。 + cfg.ions + .iter() + .filter(|ion| match ion.iat { + 1 | 2 => true, // H, He 恒在 + 6 => has_c, + 7 => has_n, + 8 => has_o, + _ => true, // 用户自定义元素不筛 + }) + .cloned() + .collect() + } + } else { + default_ions(metals) + }; let mut ions_block = "*iat iz nlevs ilast ilvlin nonstd typion filei\n*\n".to_string(); for ion in &ions { @@ -243,9 +375,6 @@ pub fn make_input5( // (tests/tlusty/hhe/fort.5) 逐字节一致: // iat 结束于 col4(|iat|=4), iz col10(+6), nlevs col16(+6), // ilast col23(+7), ilvl col30(+7), nonstd col37(+7)。 - // 实测:此宽列宽与窄列宽对 tlusty 输出(fort.7/9 等)完全相同(list-directed I/O - // 列宽无关),但对齐真实文件便于与历史参考 diff、符合 tlusty 官方输入惯例。 - // 历史上曾用 `" {} {:2} {:5}..."`(窄列宽,与 Python 旧实现一致但偏离真实 fort.5)。 ions_block.push_str(&format!( "{:>4}{:>6}{:>6}{:>7}{:>7}{:>7} '{}' '{}'\n", ion.iat, ion.iz, ion.nlevs, ilast, ilvl, 0, ion.typion, ion.filei @@ -257,13 +386,16 @@ pub fn make_input5( 0, 0, 0, -1, 0, 0, " ", " " )); + // NFREAD:从配置读,None → 默认 2000。 + let nfread = input_cfg.map(|c| c.nfread).unwrap_or(2000); + format!( "{:.1} {:.1} ! TEFF, GRAV\n \ {} {} ! LTE, LTGRAY\n \ 'nst' ! name of file containing non-standard flags\n\ *-----------------------------------------------------------------\n\ * frequencies\n \ - 2000 ! NFREAD\n\ + {} ! NFREAD\n\ *-----------------------------------------------------------------\n\ * data for atoms\n\ {}\ @@ -271,7 +403,7 @@ pub fn make_input5( * data for ions\n*\n\ {}\ *\n* end\n", - params.teff, params.logg, lte, ltgray, atoms_block, ions_block + params.teff, params.logg, lte, ltgray, nfread, atoms_block, ions_block ) } @@ -289,7 +421,7 @@ mod tests { logn: (-2.0).into(), logo: (-2.0).into(), }; - let input5 = make_input5(¶ms, "F", "F", "cno", 100); + let input5 = make_input5(¶ms, "F", "F", "cno", 100, None); assert!(input5.contains("35000.0 5.5")); assert!(input5.contains("data/h1.dat")); assert!(input5.contains("data/c1.dat")); @@ -312,7 +444,7 @@ mod tests { logn: (-1.0).into(), logo: (-1.0).into(), }; - let input5 = make_input5(¶ms, "T", "T", "", 100); + let input5 = make_input5(¶ms, "T", "T", "", 100, None); let lines: Vec<&str> = input5.lines().collect(); // 真实 fort.5 的 ions 数据行(数值部分 + typion/filei)。 @@ -346,4 +478,125 @@ mod tests { "ions 行数值部分必须与真实 fort.5 逐字节一致(宽列宽)" ); } + + /// 回归守护:None 配置时 NFREAD=2000、atoms.mode 走默认(H/He=2,Li/Be/B=0)。 + #[test] + fn test_make_input5_none_cfg_defaults() { + let params = GridPointParams { + 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, None); + // NFREAD 默认 2000 + assert!(input5.contains("2000"), "None 配置时 NFREAD 应为默认 2000"); + // H 的 mode=2(显式) + assert!(input5.contains(" 2 0. 0"), "H 的 mode 应为默认 2"); + } + + /// 用户配置生效:nfread 覆写、atoms.mode 覆写、ions 自定义表。 + #[test] + fn test_make_input5_user_cfg_override() { + let params = GridPointParams { + 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 mut atoms = std::collections::HashMap::new(); + atoms.insert( + "C".to_string(), + crate::config::AtomConfig { + mode: Some(1), + modpf: None, + }, + ); + let cfg = TlustyInput { + nfread: 500, + atoms, + ions: vec![], + nst_extra_keys: vec![], + }; + let input5 = make_input5(¶ms, "F", "F", "cno", 100, Some(&cfg)); + // NFREAD 被覆写为 500 + assert!( + input5.contains("500") && !input5.contains("2000"), + "NFREAD 应被用户配置覆写为 500" + ); + // C 的 mode 被覆写为 1(隐式),而非默认 2 + // atoms 行格式 " {mode} {abn} {modpf}",C 的 abn 是 fmt_abn(logc) + let c_line = input5 + .lines() + .find(|l| l.contains(&format!("{:.4E}", 10.0f64.powf(-2.0)))) + .expect("应找到 C 的 atoms 行"); + assert!( + c_line.trim_start().starts_with("1"), + "C 的 mode 应被覆写为 1(隐式),实际: {}", + c_line + ); + } + + /// M1 回归:`atoms` 键大小写不敏感查找。用户写小写/全大写元素键(`c:`/`HE:`)时, + /// 与规范键(`C`/`He`)必须同样命中覆写,否则 mode 覆写会静默回落默认(错误物理)。 + #[test] + fn test_atoms_key_case_insensitive() { + let params = GridPointParams { + teff: 35000.0.into(), + logg: 5.5.into(), + loghe: (-1.0).into(), + logc: (-2.0).into(), + logn: (-2.0).into(), + logo: (-2.0).into(), + }; + // 用户用小写 "c" 与全大写 "HE" 覆写 mode。 + let mut atoms = std::collections::HashMap::new(); + atoms.insert( + "c".to_string(), + crate::config::AtomConfig { + mode: Some(1), + modpf: None, + }, + ); + atoms.insert( + "HE".to_string(), + crate::config::AtomConfig { + mode: Some(0), + modpf: None, + }, + ); + let cfg = TlustyInput { + nfread: 2000, + atoms, + ions: vec![], + nst_extra_keys: vec![], + }; + let input5 = make_input5(¶ms, "F", "F", "cno", 100, Some(&cfg)); + // C(键 "c" 小写)应命中得到 mode=1,而非默认 2。 + let c_abn = format!("{:.4E}", 10.0f64.powf(-2.0)); + let c_line = input5 + .lines() + .find(|l| l.contains(&c_abn)) + .expect("应找到 C 的 atoms 行"); + assert!( + c_line.trim_start().starts_with("1"), + "小写键 c 应覆写 C 的 mode 为 1,实际: {}", + c_line + ); + // He(键 "HE" 全大写)应命中得到 mode=0,而非默认 2。 + let he_abn = format!("{:.4E}", 10.0f64.powf(-1.0)); + let he_line = input5 + .lines() + .find(|l| l.contains(&he_abn)) + .expect("应找到 He 的 atoms 行"); + assert!( + he_line.trim_start().starts_with("0"), + "全大写键 HE 应覆写 He 的 mode 为 0,实际: {}", + he_line + ); + } } diff --git a/crates/common/src/models.rs b/crates/common/src/models.rs index 0c1358d..255bb2f 100644 --- a/crates/common/src/models.rs +++ b/crates/common/src/models.rs @@ -242,7 +242,10 @@ pub struct GridPoint { pub wave: i32, pub status: GridPointStatus, pub attempt_count: i32, - pub success_method: Option, + /// TLUSTY 阶段收敛策略(TLUSTY 禁用为 NULL)。 + pub tlusty_success_method: Option, + /// 光谱阶段收敛策略(TLUSTY-only 为 NULL)。 + pub synspec_success_method: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -251,7 +254,9 @@ pub enum GridPointStatus { Pending, Queued, Running, - Converged, + /// Phase 7c:由 `Converged` 改名——点级"管线完成"(大气收敛 + 光谱合成), + /// 消除 TLUSTY-first 的"大气收敛"误读。DB 值 'completed' 经 M9 迁为 'completed'。 + Completed, Failed, } @@ -261,7 +266,7 @@ impl std::fmt::Display for GridPointStatus { GridPointStatus::Pending => "pending", GridPointStatus::Queued => "queued", GridPointStatus::Running => "running", - GridPointStatus::Converged => "converged", + GridPointStatus::Completed => "completed", GridPointStatus::Failed => "failed", }; write!(f, "{}", s) @@ -273,7 +278,8 @@ impl From<&str> for GridPointStatus { match s { "queued" => GridPointStatus::Queued, "running" => GridPointStatus::Running, - "converged" | "done" => GridPointStatus::Converged, + // 7c:'completed' 是权威值;'converged'/'done' 为 legacy 别名(M9 迁移前旧数据/旧代码)。 + "completed" | "converged" | "done" => GridPointStatus::Completed, "failed" => GridPointStatus::Failed, _ => GridPointStatus::Pending, } @@ -288,7 +294,7 @@ impl From<&str> for GridPointStatus { /// 的处理;失败后的策略链回退**只由启动时的策略链(回退优先级排序)驱动**,不受策略门控。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] -pub enum StagePolicy { +pub enum ResumePolicy { /// 跳过已收敛、重试已失败:启动时把已失败点打回 pending 重试,收敛点保留(增量+重试失败)。 /// 默认值。 #[default] @@ -299,22 +305,22 @@ pub enum StagePolicy { SkipFailed, } -impl StagePolicy { +impl ResumePolicy { /// 序列化为 DB 文本列存储用的 snake_case 字符串。 pub fn as_str(&self) -> &'static str { match self { - StagePolicy::SkipConverged => "skip_converged", - StagePolicy::ForceRecompute => "force_recompute", - StagePolicy::SkipFailed => "skip_failed", + ResumePolicy::SkipConverged => "skip_converged", + ResumePolicy::ForceRecompute => "force_recompute", + ResumePolicy::SkipFailed => "skip_failed", } } /// 从 DB 文本列回读;非法值兜底为默认 SkipConverged(防注入与脏数据)。 pub fn from_str_lossy(s: &str) -> Self { match s { - "force_recompute" => StagePolicy::ForceRecompute, - "skip_failed" => StagePolicy::SkipFailed, - _ => StagePolicy::SkipConverged, + "force_recompute" => ResumePolicy::ForceRecompute, + "skip_failed" => ResumePolicy::SkipFailed, + _ => ResumePolicy::SkipConverged, } } } @@ -324,16 +330,16 @@ impl StagePolicy { /// 见 docs/task_engine_decoupling_design.md §3:嵌套式单阶段配置模型, /// 包含三个正交维度:enabled / policy / strategies。 /// -/// 为避免与 `common::config::StageConfig`(迭代步进参数)同名冲突,命名为 -/// `EngineStageConfig`。 +/// 为避免与 `common::config::ChainStep`(迭代步进参数)同名冲突,命名为 +/// `PhaseConfig`。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct EngineStageConfig { +pub struct PhaseConfig { /// 是否在当前计算流中启用该阶段。 #[serde(default = "default_engine_stage_enabled")] pub enabled: bool, /// 决定如何处理历史记录。 #[serde(default)] - pub policy: StagePolicy, + pub policy: ResumePolicy, /// 策略链队列(按回退优先级排序),如 `["cold_run", "seed_step"]`。 /// 节点总是执行 `strategies[0]`;失败后由服务端弹出首项,下一顺位顶上。 #[serde(default)] @@ -344,12 +350,12 @@ fn default_engine_stage_enabled() -> bool { true } -impl EngineStageConfig { +impl PhaseConfig { /// TLUSTY 阶段默认配置:启用、增量、策略链 `[cold_run, seed_step]`。 pub fn default_tlusty() -> Self { Self { enabled: true, - policy: StagePolicy::SkipConverged, + policy: ResumePolicy::SkipConverged, strategies: vec!["cold_run".to_string(), "seed_step".to_string()], } } @@ -358,7 +364,7 @@ impl EngineStageConfig { pub fn default_synspec() -> Self { Self { enabled: true, - policy: StagePolicy::SkipConverged, + policy: ResumePolicy::SkipConverged, strategies: vec!["standard".to_string()], } } @@ -382,20 +388,18 @@ impl EngineStageConfig { /// Task execution specification sent to Node /// -/// 注:`EngineStageConfig` 刻意**不实现 `Default`**——阶段默认值随阶段而异(TLUSTY +/// 注:`PhaseConfig` 刻意**不实现 `Default`**——阶段默认值随阶段而异(TLUSTY /// `[cold_run, seed_step]` vs SYNSPEC `[standard]`),无中立的默认语义。构造某阶段的配置请用 -/// `..EngineStageConfig::default_tlusty()` / `..EngineStageConfig::default_synspec()`, +/// `..PhaseConfig::default_tlusty()` / `..PhaseConfig::default_synspec()`, /// 避免把 TLUSTY 默认链误用到 synspec。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TaskSpec { pub task_id: Uuid, pub point_name: String, pub params: GridPointParams, - /// **已废弃**:保留以兼容历史 MQ 在途消息与旧节点。新代码应读 - /// `tlusty_config.strategies[0]` 判定当前 TLUSTY 策略。 - /// 该字段仍是必填(serde 反序列化要求),调度器在派发时会据 - /// `tlusty_config.strategies[0]` 同步设置它,保证旧节点能正常工作。 - pub task_type: TaskType, + /// **双义**(Phase 7a 标注):TLUSTY 启用时 = seed_step 热启动的近邻种子点; + /// SYNSPEC-only(TLUSTY 关闭)时 = 光谱输入大气来源点(由 atmosphere_ref/point_name 决定, + /// 本字段此时恒 None)。executor 以 `tlusty_config.enabled` 区分两种语义。 pub seed_point_name: Option, pub timeout_sec: u64, /// 所属工作流名称,用于按工作流隔离队列清理(stop_workflow 只清当前工作流的任务)。 @@ -408,46 +412,37 @@ pub struct TaskSpec { pub wave: i32, /// TLUSTY 阶段独立配置(见 docs/task_engine_decoupling_design.md §3)。 /// 旧 payload 反序列化时缺省为 `default_tlusty()`。 - #[serde(default = "EngineStageConfig::default_tlusty")] - pub tlusty_config: EngineStageConfig, + #[serde(default = "PhaseConfig::default_tlusty")] + pub tlusty_config: PhaseConfig, /// SYNSPEC 阶段独立配置。旧 payload 反序列化时缺省为 `default_synspec()`。 - #[serde(default = "EngineStageConfig::default_synspec")] - pub synspec_config: EngineStageConfig, - /// SYNSPEC 数值参数(波长范围等,对应 `config::SynspecConfig`)。 + #[serde(default = "PhaseConfig::default_synspec")] + pub synspec_config: PhaseConfig, + /// SYNSPEC 数值参数(波长范围等,对应 `config::SynspecInput`)。 /// 以 `serde_json::Value` 携带避免 models ↔ config 循环依赖;executor 侧 - /// 反序列化为 `SynspecConfig` 后透传给 runner。None → runner 用硬编码默认。 + /// 反序列化为 `SynspecInput` 后透传给 runner。None → runner 用硬编码默认。 /// 旧 payload 反序列化时缺省为 None(旧节点本就用默认,无回归)。 #[serde(default)] pub synspec_params: Option, + /// TLUSTY 物理迭代步进链(lte/nc/nl 多阶段 `config::ChainStep` 数组)。 + /// 同样以 `serde_json::Value` 携带避免循环依赖;executor 反序列化为 + /// `Vec` 后透传给 runner 的 custom_chain 参数。 + /// None/空 → executor 用 `default_chain_for_strategy` 兜底(按策略名选默认链)。 + /// 旧 payload 反序列化时缺省为 None(旧节点本就用 default 链,无回归)。 + #[serde(default)] + pub tlusty_chain_params: Option, + /// TLUSTY 输入文件(.5 + nst)的全局物理参数(`config::TlustyInput`)。 + /// NFREAD 频率网格、ions 能级表、nst extra_keys 等不随阶段变化的参数。 + /// None → runner 用代码内硬编码默认(gen_input5.rs/nst_writer.rs 的常量)。 + /// 旧 payload 反序列化时缺省为 None(旧节点本就用默认,无回归)。 + #[serde(default)] + pub tlusty_input_params: Option, /// 仅 SYNSPEC-only 场景(TLUSTY 关闭)拉取大气用:显式关联大气网格点名。 #[serde(default)] pub atmosphere_ref: Option, } -impl TaskSpec { - /// 旧版兼容归一化:据废弃的 `task_type` 回填 `tlusty_config.strategies` 首项。 - /// - /// 修复(审查 #8):serde default 已把 strategies 填为完整默认链 `[cold_run, seed_step]`, - /// 故仅判 `is_empty` 无法覆盖「旧 seed_step 消息被误判为 cold_run」的场景。 - /// 现据 task_type 把首项校正为对应的单策略链(旧消息的 task_type 是权威来源): - /// - task_type=SeedStep → `[seed_step]`(旧热启动消息不应被当冷启动重跑); - /// - task_type=ColdRun → 保持默认链(cold_run 本就是默认首项)。 - pub fn normalize_compat(&mut self) { - let legacy_first = match self.task_type { - TaskType::ColdRun => "cold_run", - TaskType::SeedStep => "seed_step", - }; - // 仅当当前 strategies 首项与 task_type 不一致时校正(避免覆盖显式配置)。 - let needs_fix = - self.tlusty_config.strategies.first().map(|s| s.as_str()) != Some(legacy_first); - if needs_fix { - self.tlusty_config.strategies = vec![legacy_first.to_string()]; - } - } -} - /// 仅供测试夹具构造便利:`TaskSpec::default()` 给出合法占位(task_id 零值、 -/// 空点/参数、ColdRun、默认阶段配置)。生产代码应显式构造所有字段,避免依赖占位。 +/// 空点/参数、默认阶段配置)。生产代码应显式构造所有字段,避免依赖占位。 impl Default for TaskSpec { fn default() -> Self { TaskSpec { @@ -461,26 +456,20 @@ impl Default for TaskSpec { logn: 0.0.into(), logo: 0.0.into(), }, - task_type: TaskType::ColdRun, seed_point_name: None, timeout_sec: 3600, workflow_name: None, wave: 0, - tlusty_config: EngineStageConfig::default_tlusty(), - synspec_config: EngineStageConfig::default_synspec(), + tlusty_config: PhaseConfig::default_tlusty(), + synspec_config: PhaseConfig::default_synspec(), synspec_params: None, + tlusty_chain_params: None, + tlusty_input_params: None, atmosphere_ref: None, } } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum TaskType { - ColdRun, - SeedStep, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum TaskStatus { @@ -499,10 +488,20 @@ pub struct TaskReport { #[serde(default)] pub params: Option, pub node_id: String, + /// 任务整体成败(`derive_report_status`:converged && 无 synspec 错误 → Completed, + /// 半失败 = Failed)。整体语义,非阶段成败。 pub status: TaskStatus, - pub converged: bool, + /// **双义**(Phase 7a/7b 标注):TLUSTY 启用时 = 大气收敛标志("本次大气产物是否可用"); + /// SYNSPEC-only(TLUSTY 关闭)被重写为管线成功。7b 改名 `result_valid` 消除字段名误读—— + /// 阶段成败请用 `failed_stage` / `summary_json.synspec_*`。 + /// + /// 旧节点仍以字段名 `converged` 上报,serde alias 兼容(支持滚动升级)。 + #[serde(alias = "converged")] + pub result_valid: bool, + /// 仅 TLUSTY 大气迭代有效(SYNSPEC-only 任务此量为 None 或大气来源值)。 pub max_relc: Option, pub atmosphere_has_nan: bool, + /// 单点总墙钟耗时(秒,含 TLUSTY + SYNSPEC;`synspec_sec` 是其子集)。 pub elapsed_sec: f64, pub error_message: Option, pub summary_json: String, @@ -570,11 +569,25 @@ pub struct ConvCheckResult { pub n_depths: usize, pub chmax: f64, pub error: Option, + /// 逐次迭代诊断(iter → 该次最大相对变化;Phase 5b 起由 fort.9 全量解析)。 + /// 完整收敛轨迹:17 次迭代缓降 vs 顶着 NITER 上限勉强的发散轨迹一眼可辨。 + #[serde(default)] + pub itek_history: Vec, +} + +/// 单次迭代的收敛诊断(fort.9 每迭代一拍)。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct IterCheck { + pub iter: i32, + /// 该次迭代的最大相对变化(所有深度点 |maximum| 的最大值)。 + pub max_relc: f64, + /// 该次迭代参与解析的深度点行数。 + pub n_depths: usize, } /// Convergence stage summary recorded in conv.json #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StageSummary { +pub struct StepSummary { pub label: String, pub chmax: Option, pub lte: String, @@ -592,6 +605,10 @@ pub struct StageSummary { /// 深度点总数,诊断用。 #[serde(default)] pub n_depths: Option, + /// 逐次迭代诊断(iter → 该次最大相对变化)。**全量保真**:summary_json 与 conv.json + /// 同源于 runner 的 StepSummary,故完整收敛轨迹随 summary_json 落库,不再仅存磁盘。 + #[serde(default)] + pub itek_history: Vec, } /// Full execution summary for a grid point @@ -599,8 +616,15 @@ pub struct StageSummary { pub struct ModelSummary { pub name: String, pub params: GridPointParams, - pub stages: Vec, - pub converged: bool, + /// TLUSTY 收敛链子步骤摘要(lte/nc/nl 或 seed_nc/nl),**不是** TLUSTY/SYNSPEC 管线大阶段。 + pub stages: Vec, + /// 本次结果是否可用(P9 拆分,与 `TaskReport.result_valid` 对齐): + /// TLUSTY 启用时 = 大气收敛;SYNSPEC-only(TLUSTY 关闭)被 reporter 重写为管线成功。 + /// 读方不能仅凭字段名判断是哪个阶段——整体成败请用 `TaskReport.status`。 + /// 旧 `conv.json`/`summary_json` 序列化的键名是 `converged`,`#[serde(alias)]` 兼容读取。 + #[serde(alias = "converged")] + pub result_valid: bool, + /// 仅 TLUSTY 大气迭代有效(最大相对修正;SYNSPEC 阶段无此量)。 pub final_max_relc: Option, pub final_chmax: Option, pub seed: Option, @@ -608,8 +632,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 总是写入。 + /// 单点总墙钟耗时(秒,含 TLUSTY + SYNSPEC)。**包含** `synspec_sec`(子集): + /// `elapsed_sec ≥ synspec_sec` 恒成立(synspec 为空时 synspec_sec=None)。 + /// 极旧版 conv.json 可能缺此字段,default 0.0 兜底(展示层把 ≤0 视为"无数据")。 #[serde(default)] pub elapsed_sec: f64, pub note: Option, @@ -619,7 +644,7 @@ pub struct ModelSummary { mod tests { use super::*; - /// StageSummary 新增迭代诊断字段的向后兼容: + /// StepSummary 新增迭代诊断字段的向后兼容: /// 旧 conv.json(无 last_iter/worst_depth/n_depths)必须能正常反序列化为 null, /// 新写入的 conv.json 往返保真。 #[test] @@ -628,12 +653,12 @@ mod tests { "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(); + let st: StepSummary = 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 { + let full = StepSummary { label: "nl".to_string(), chmax: Some(0.001), lte: "F".to_string(), @@ -644,8 +669,13 @@ mod tests { last_iter: Some(17), worst_depth: Some(1), n_depths: Some(50), + itek_history: vec![IterCheck { + iter: 1, + max_relc: 0.5, + n_depths: 50, + }], }; - let round: StageSummary = + let round: StepSummary = 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)); @@ -657,7 +687,7 @@ mod tests { /// /// 载荷严格复刻 run_one.py 的真实输出形态:stage 含 `itek_attempts`/`final` 嵌套 /// dict、`note`、可选 `best_max_relc`,顶层含 `final_chmax`/`synspec_*`/`seed` 等。 - /// Rust StageSummary 未声明的字段(itek_attempts/final)应被 serde 静默忽略。 + /// Rust StepSummary 未声明的字段(itek_attempts/final)应被 serde 静默忽略。 #[test] fn test_model_summary_parses_python_legacy_conv_json() { let legacy = r#"{ @@ -699,7 +729,8 @@ mod tests { 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); + // 旧版键名 "converged" 经 #[serde(alias)] 兼容读入 result_valid。 + assert!(s.result_valid); assert!(!s.atmosphere_has_nan); assert_eq!(s.final_max_relc, Some(0.0069)); assert_eq!(s.elapsed_sec, 715.0); @@ -852,75 +883,80 @@ mod tests { assert_eq!(GridPointStatus::Pending.to_string(), "pending"); assert_eq!(GridPointStatus::Queued.to_string(), "queued"); assert_eq!(GridPointStatus::Running.to_string(), "running"); - assert_eq!(GridPointStatus::Converged.to_string(), "converged"); + assert_eq!(GridPointStatus::Completed.to_string(), "completed"); assert_eq!(GridPointStatus::Failed.to_string(), "failed"); assert_eq!(GridPointStatus::from("queued"), GridPointStatus::Queued); + // 7c:'completed' 权威值;'converged'/'done' 为 legacy 别名。 + assert_eq!( + GridPointStatus::from("completed"), + GridPointStatus::Completed + ); assert_eq!( GridPointStatus::from("converged"), - GridPointStatus::Converged + GridPointStatus::Completed ); - assert_eq!(GridPointStatus::from("done"), GridPointStatus::Converged); + assert_eq!(GridPointStatus::from("done"), GridPointStatus::Completed); assert_eq!(GridPointStatus::from("failed"), GridPointStatus::Failed); assert_eq!(GridPointStatus::from("unknown"), GridPointStatus::Pending); } - /// `StagePolicy` 的 snake_case serde 往返 + DB 文本兜底。 + /// `ResumePolicy` 的 snake_case serde 往返 + DB 文本兜底。 #[test] fn test_stage_policy_serde_roundtrip() { for p in [ - StagePolicy::SkipConverged, - StagePolicy::ForceRecompute, - StagePolicy::SkipFailed, + ResumePolicy::SkipConverged, + ResumePolicy::ForceRecompute, + ResumePolicy::SkipFailed, ] { let s = serde_json::to_string(&p).unwrap(); - let back: StagePolicy = serde_json::from_str(&s).unwrap(); + let back: ResumePolicy = serde_json::from_str(&s).unwrap(); assert_eq!(p, back); } // snake_case 形态锁定(前端 payload 与 DB 列口径) assert_eq!( - serde_json::to_string(&StagePolicy::SkipConverged).unwrap(), + serde_json::to_string(&ResumePolicy::SkipConverged).unwrap(), "\"skip_converged\"" ); assert_eq!( - serde_json::to_string(&StagePolicy::ForceRecompute).unwrap(), + serde_json::to_string(&ResumePolicy::ForceRecompute).unwrap(), "\"force_recompute\"" ); assert_eq!( - serde_json::to_string(&StagePolicy::SkipFailed).unwrap(), + serde_json::to_string(&ResumePolicy::SkipFailed).unwrap(), "\"skip_failed\"" ); // as_str/from_str_lossy 互逆(非法值兜底 SkipConverged) assert_eq!( - StagePolicy::from_str_lossy("skip_converged"), - StagePolicy::SkipConverged + ResumePolicy::from_str_lossy("skip_converged"), + ResumePolicy::SkipConverged ); assert_eq!( - StagePolicy::from_str_lossy("garbage"), - StagePolicy::SkipConverged + ResumePolicy::from_str_lossy("garbage"), + ResumePolicy::SkipConverged ); assert_eq!( - StagePolicy::from_str_lossy(StagePolicy::ForceRecompute.as_str()), - StagePolicy::ForceRecompute + ResumePolicy::from_str_lossy(ResumePolicy::ForceRecompute.as_str()), + ResumePolicy::ForceRecompute ); } - /// `EngineStageConfig` serde 往返 + 默认值(缺字段时 serde default 兜底)。 + /// `PhaseConfig` serde 往返 + 默认值(缺字段时 serde default 兜底)。 #[test] fn test_engine_stage_config_serde_and_defaults() { - let cfg = EngineStageConfig { + let cfg = PhaseConfig { enabled: false, - policy: StagePolicy::ForceRecompute, + policy: ResumePolicy::ForceRecompute, strategies: vec!["cold_run".to_string(), "seed_step".to_string()], }; let json = serde_json::to_string(&cfg).unwrap(); - let back: EngineStageConfig = serde_json::from_str(&json).unwrap(); + let back: PhaseConfig = serde_json::from_str(&json).unwrap(); assert_eq!(cfg, back); // 空 payload 应产出默认值(enabled=true, policy=skip_converged, strategies=[]) - let empty: EngineStageConfig = serde_json::from_str("{}").unwrap(); + let empty: PhaseConfig = serde_json::from_str("{}").unwrap(); assert!(empty.enabled); - assert_eq!(empty.policy, StagePolicy::SkipConverged); + assert_eq!(empty.policy, ResumePolicy::SkipConverged); assert!(empty.strategies.is_empty()); // current_strategy 空链兜底 @@ -930,43 +966,31 @@ mod tests { assert!(!cfg.has_strategy("standard")); } - /// 旧版 MQ 在途消息(仅含 task_type,无 tlusty_config)经 `#[serde(default)]` - /// 反序列化后,`normalize_compat()` 应据 task_type 回填 strategies。 + /// Phase 6(P8)删除 task_type 后,`normalize_compat` 与旧版单策略链校正机制整体移除。 + /// 升级前需确认队列为空(docs/database_refactor_design.md §8.8):残留旧 payload 的 + /// strategies 会按 serde default 填成默认链 [cold_run, seed_step],无 task_type 可校正。 #[test] - fn test_task_spec_normalize_compat_from_legacy_task_type() { + fn test_legacy_payload_without_task_type_uses_default_strategy_chain() { let legacy_json = r#"{ "task_id": "00000000-0000-0000-0000-000000000001", "point_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}, - "task_type": "seed_step", "seed_point_name": "neighbor", "timeout_sec": 7200, "workflow_name": "wf_a", "wave": 0 }"#; - let mut spec: TaskSpec = serde_json::from_str(legacy_json).unwrap(); - // 修复后 normalize_compat 据 task_type 校正首项:旧 seed_step 消息的 strategies - // 首项应被校正为 seed_step(而非保留默认链的 cold_run 首项,否则会被误当冷启动)。 - spec.normalize_compat(); + // task_type 字段已被移除;旧 payload 即使仍携带该键也会被 serde 忽略(未知字段)。 + // tlusty_config 缺省回落到默认链,首项 cold_run。 + let spec: TaskSpec = serde_json::from_str(legacy_json).unwrap(); + assert_eq!( + spec.tlusty_config.current_strategy("cold_run"), + "cold_run", + "无显式 strategies 的旧 payload 回落到默认链首项" + ); assert_eq!( spec.tlusty_config.strategies, - vec!["seed_step".to_string()], - "旧 seed_step 消息应校正为 [seed_step] 单策略链" - ); - assert_eq!(spec.tlusty_config.current_strategy("cold_run"), "seed_step"); - - // 对照:旧 cold_run 消息 → 首项已是 cold_run(默认链首项),无需校正。 - let mut cold_spec = TaskSpec::default(); - cold_spec.task_type = TaskType::ColdRun; - cold_spec.normalize_compat(); - assert_eq!( - cold_spec - .tlusty_config - .strategies - .first() - .map(|s| s.as_str()), - Some("cold_run"), - "旧 cold_run 消息保持默认链" + vec!["cold_run".to_string(), "seed_step".to_string()] ); } } diff --git a/crates/common/src/nst_writer.rs b/crates/common/src/nst_writer.rs index 9379bd1..e1e15c3 100644 --- a/crates/common/src/nst_writer.rs +++ b/crates/common/src/nst_writer.rs @@ -1,6 +1,17 @@ -use crate::config::StageConfig; +use crate::config::{ChainStep, TlustyInput}; -pub fn generate_nst_content(stage: &StageConfig) -> String { +/// 生成 TLUSTY 的 nst(非标准标志)文件内容。 +/// +/// nst 文件由 `KEY=VALUE` 对组成,TLUSTY 的 NSTPAR 子程序逐行解析(无行数限制)。 +/// 内容来源(优先级从高到低): +/// 1. `input_cfg.nst_extra_keys`:用户自由传入的任意 KEY=VALUE(逃逸口),追加到末尾。 +/// 2. `stage`(ChainStep):CHMAX/ITEK/NITER/ORELAX/IDLTE/IACC/ICHANG 等阶段差异参数。 +/// 3. 硬编码默认:ND/NLAMBD/VTB/ISPODF/DDNU/CNU1/IELCOR(input_cfg 无对应字段时)。 +/// +/// `input_cfg` 为 None 时走全默认(与改动前行为完全一致,向后兼容)。 +pub fn generate_nst_content(stage: &ChainStep, input_cfg: Option<&TlustyInput>) -> String { + // 第 1 行:深度点数/角度数/湍速/ODF 等物理网格参数 + 收敛控制。 + // ND/NLAMBD/VTB/ISPODF/DDNU/CNU1 当前无结构化字段,保留硬编码(如需覆写用 extra_keys)。 let mut line1_parts = vec![ "ND=50".to_string(), "NLAMBD=3".to_string(), @@ -18,6 +29,7 @@ pub fn generate_nst_content(stage: &StageConfig) -> String { } line1_parts.push(format!("NITER={}", stage.niter)); + // 第 2 行:加速/收敛控制开关。 let mut line2_parts = Vec::new(); if let Some(orelax) = stage.orelax { line2_parts.push(format!("ORELAX={}", orelax)); @@ -33,7 +45,38 @@ pub fn generate_nst_content(stage: &StageConfig) -> String { } line2_parts.push("IELCOR=-1".to_string()); - format!("{}\n{}\n", line1_parts.join(","), line2_parts.join(",")) + let mut out = format!("{}\n{}\n", line1_parts.join(","), line2_parts.join(",")); + + // 第 3 行起:用户自由传入的额外 nst 关键字(逃逸口)。 + // 每行一个 KEY=VALUE,追加到末尾。用于暴露未结构化的 220+ nst 关键字 + // (如 FRCMAX/CUTBAL/TAU/NDGREY 等)。 + // 多行格式经 TLUSTY 源码验证(tlusty208.f:1819 NSTPAR 用 `READ(INPFI,500,END=70)` + // + `GO TO 10` 逐行循环读至 EOF,每行用 GETWRD 解析 KEY=VALUE),追加行可被正确解析。 + // 安全校验:key/value 含逗号/换行/等号会破坏 nst 的逗号分隔或 KEY=VALUE 解析, + // 跳过非法项并记 warn(避免生成损坏的 nst 导致 tlusty 行为异常)。 + // L1 修复:**key 与 value 都校验**——key 含 `=`/`,`/空白/换行会产出畸形 `KEY=VALUE` + // 行,TLUSTY 的 GETWRD 解析器可能误读。key 必须是合法标识符(非空、无上述分隔符)。 + if let Some(cfg) = input_cfg { + for (key, value) in &cfg.nst_extra_keys { + let bad_key = key.is_empty() + || key.contains(',') + || key.contains('\n') + || key.contains('=') + || key.chars().any(char::is_whitespace); + let bad_value = value.contains(',') || value.contains('\n') || value.contains('='); + if bad_key || bad_value { + tracing::warn!( + "跳过非法 nst_extra_keys 项 {:?}={:?}: 含逗号/换行/等号/空白会破坏 nst 解析", + key, + value + ); + continue; + } + out.push_str(&format!("{}={}\n", key, value)); + } + } + + out } #[cfg(test)] @@ -42,7 +85,7 @@ mod tests { #[test] fn test_nst_generation() { - let stage = StageConfig { + let stage = ChainStep { label: "nc".to_string(), lte: "F".to_string(), ltgray: "F".to_string(), @@ -57,9 +100,75 @@ mod tests { iacc: None, orelax: None, }; - let content = generate_nst_content(&stage); + let content = generate_nst_content(&stage, None); assert!(content.contains("ND=50")); assert!(content.contains("NITER=10")); assert!(content.contains("IELCOR=-1")); } + + /// extra_keys 追加到 nst 末尾(每行一个 KEY=VALUE)。 + #[test] + fn test_nst_extra_keys() { + let stage = ChainStep { + label: "nl".to_string(), + lte: "F".to_string(), + ltgray: "F".to_string(), + ilvlin: 100, + require_converged: true, + niter: 100, + chmax: Some(0.001), + itek: None, + metals: None, + ichang: None, + idlte: None, + iacc: None, + orelax: None, + }; + let cfg = TlustyInput { + nfread: 2000, + atoms: Default::default(), + ions: vec![], + nst_extra_keys: vec![ + ("FRCMAX".to_string(), "0.01".to_string()), + ("CUTBAL".to_string(), "0.3".to_string()), + ], + }; + let content = generate_nst_content(&stage, Some(&cfg)); + assert!(content.contains("FRCMAX=0.01"), "extra_keys 应追加到 nst"); + assert!(content.contains("CUTBAL=0.3")); + // 原有内容仍存在 + assert!(content.contains("NITER=100")); + assert!(content.contains("CHMAX=0.001")); + } + + /// None 配置时与改动前行为一致(无 extra_keys 行)。 + #[test] + fn test_nst_none_cfg_backward_compat() { + let stage = ChainStep { + label: "lte".to_string(), + lte: "T".to_string(), + ltgray: "T".to_string(), + ilvlin: 0, + require_converged: false, + niter: 0, + chmax: None, + itek: None, + metals: None, + ichang: None, + idlte: None, + iacc: None, + orelax: None, + }; + let content = generate_nst_content(&stage, None); + // None 配置时只有 2 行(第1行 ND/NITER 等 + 第2行 IELCOR 等),无 extra_keys 追加行。 + let non_empty_lines: Vec<&str> = content.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!( + non_empty_lines.len(), + 2, + "None 配置时 nst 应只有 2 行,实际 {} 行: {:?}", + non_empty_lines.len(), + non_empty_lines + ); + assert!(!content.contains("FRCMAX")); + } } diff --git a/crates/common/src/result_filter.rs b/crates/common/src/result_filter.rs index ed2d6c0..c6b4b37 100644 --- a/crates/common/src/result_filter.rs +++ b/crates/common/src/result_filter.rs @@ -15,7 +15,8 @@ //! `conv.json`、`fort.8`(synspec 输入大气)、`fort.55`(synspec 控制卡) //! 2. **科学核心**:`.7`、`.spec`、`.cont`、`.iden`、`.log`、 //! `.bfac`(TLUSTY 最终 b 因子/非 LTE 偏离因子)、`.emflux`(TLUSTY 最终出射谱 λ–Fλ) -//! 3. **阶段快照**:`.