diff --git a/.gitignore b/.gitignore index df8472b..e25f95b 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,5 @@ hosts.ini # agent .zcode/ -.omc/ \ No newline at end of file +.omc/ +.claude/ \ No newline at end of file diff --git a/.omc/project-memory.json b/.omc/project-memory.json deleted file mode 100644 index 8aca049..0000000 --- a/.omc/project-memory.json +++ /dev/null @@ -1,207 +0,0 @@ -{ - "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 deleted file mode 100644 index f20e103..0000000 --- a/.omc/sessions/865f1115-942b-4cc4-81f6-17a611773eb2.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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/crates/common/src/config.rs b/crates/common/src/config.rs index 644c67c..82053ee 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -1,4 +1,4 @@ -use crate::models::GridAxisValue; +use crate::models::{EngineStageConfig, GridAxisValue}; use anyhow::{Context, Result}; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -138,6 +138,43 @@ impl GridConfig { } Ok(cfg) } + + /// 解析 TLUSTY 阶段配置。 + /// + /// 优先级(见 docs/task_engine_decoupling_design.md §3): + /// 1. 新版顶层 `tlusty:` 块(EngineStageConfig)—— 显式覆盖; + /// 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 { + return cfg.clone(); + } + let mut cfg = EngineStageConfig::default_tlusty(); + if !self.seed_step_fallback { + cfg.strategies = vec!["cold_run".to_string()]; + } + cfg + } + + /// 解析 SYNSPEC 阶段配置。 + /// + /// 优先级: + /// 1. 新版顶层 `synspec_stage:` 块(EngineStageConfig)—— 显式覆盖(含 enabled 开关); + /// 2. 兜底 `default_synspec()`(enabled=true,保持旧行为:有大气就跑光谱)。 + /// + /// 注:旧版 `synspec: SynspecConfig`(数值参数)不影响阶段启用/策略——它只携带 + /// 波长范围等数值,由调度器透传到 TaskSpec.synspec_params。如需禁用 SYNSPEC, + /// 必须用新版 `synspec_stage: { enabled: false }`。 + pub fn resolve_synspec_config(&self) -> EngineStageConfig { + if let Some(cfg) = &self.synspec_stage { + return cfg.clone(); + } + EngineStageConfig::default_synspec() + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -213,7 +250,6 @@ fn default_abs_cutoff() -> f64 { } #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct GridConfig { pub grid: GridAxesConfig, #[serde(default)] @@ -228,8 +264,7 @@ pub struct GridConfig { #[serde(default = "default_true")] pub seed_step_fallback: bool, /// **已弃用的死字段**:旧版 Python 工具链遗留,无任何代码读取(实际目录以 - /// `ServerConfig.seeds_dir` / `DCTS_SEEDS_DIR` 为准)。仅因 `deny_unknown_fields` - /// 必须能解析而保留。workflow YAML 里仍可写(如 `results: data/seeds`)但被忽略。 + /// `ServerConfig.seeds_dir` / `DCTS_SEEDS_DIR` 为准)。保留以兼容旧 workflow YAML。 #[deprecated(note = "死字段,实际目录以 DCTS_SEEDS_DIR 为准")] #[serde(default)] pub results: Option, @@ -240,6 +275,14 @@ pub struct GridConfig { pub template: Option, pub fort55: Option, pub linelist: Option, + /// TLUSTY 阶段独立配置(见 docs/task_engine_decoupling_design.md §3)。 + /// 缺省 None → `resolve_tlusty_config()` 据旧 `seed_step_fallback` 推断默认链。 + #[serde(default)] + pub tlusty: Option, + /// SYNSPEC 阶段独立配置。命名为 `synspec_stage` 以与上方旧 `synspec: SynspecConfig` + ///(光谱合成数值参数)区分。缺省 None → `resolve_synspec_config()` 给默认 `[standard]`。 + #[serde(default)] + pub synspec_stage: Option, } fn default_grid_niter() -> Option { @@ -410,7 +453,7 @@ pub struct NodeConfig { /// 避免随沙盒删除而丢失。与 server 的 `seeds` 目录区分:此处存的是**完整产物** /// (光谱/连续谱/各阶段大气快照等),seeds 只存最小种子集(.7+conv.json)。 /// 默认 "data/result",可经 DCTS_RESULT_DIR 覆盖(回退读旧 DCTS_ARCHIVE_DIR)。 - /// 超过 MAX_RESULT_MODELS 个网格点子目录时按 LRU 删除最旧的。 + /// 归档**永久保留**,不做 LRU 淘汰(2026-08-02 撤销旧 MAX_RESULT_MODELS=200 上限)。 pub result_dir: String, pub heartbeat_sec: u64, } diff --git a/crates/common/src/conv_check.rs b/crates/common/src/conv_check.rs index 57ab764..e51f4ca 100644 --- a/crates/common/src/conv_check.rs +++ b/crates/common/src/conv_check.rs @@ -34,9 +34,7 @@ fn parse_fortran_float(s: &str) -> Option { return Some(v); } // 2. 归一化无-E 记数法:[前导符号?]<尾数>(含小数点或多位数字)[+/-]<指数> - let re = NO_E_EXP_RE.get_or_init(|| { - Regex::new(r"^([+-]?[\d.]+)([+-]\d+)$").unwrap() - }); + let re = NO_E_EXP_RE.get_or_init(|| Regex::new(r"^([+-]?[\d.]+)([+-]\d+)$").unwrap()); if let Some(caps) = re.captures(s) { let normalized = format!("{}E{}", &caps[1], &caps[2]); if let Ok(v) = normalized.parse::() { @@ -197,9 +195,8 @@ 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(r"(?i)(\bnan\b|\binf(?:inity)?\b|\*{3,})").unwrap()); for line in reader.lines().map_while(Result::ok) { total_lines += 1; @@ -274,7 +271,9 @@ mod tests { // Rust 的 f64::from_str 接受 "NaN"/"inf",返回 NaN/Inf(非 None)。 // 这些在 check_fort9 中会被 is_finite() 判为无效 → converged=false,行为正确。 - assert!(parse_fortran_float("NaN").map(|v| v.is_nan()).unwrap_or(false)); + assert!(parse_fortran_float("NaN") + .map(|v| v.is_nan()) + .unwrap_or(false)); assert_eq!(parse_fortran_float("inf"), Some(f64::INFINITY)); // 无法解析的垃圾 → None(调用方 continue 跳过) diff --git a/crates/common/src/embedded.rs b/crates/common/src/embedded.rs index 04a79f8..294dc9f 100644 --- a/crates/common/src/embedded.rs +++ b/crates/common/src/embedded.rs @@ -173,8 +173,13 @@ fn write_if_changed(target_path: &Path, content: &[u8], executable: bool) -> Res fs::set_permissions(&tmp_path, perms)?; } - fs::rename(&tmp_path, target_path) - .with_context(|| format!("原子重命名 {} -> {} 失败", tmp_path.display(), target_path.display()))?; + fs::rename(&tmp_path, target_path).with_context(|| { + format!( + "原子重命名 {} -> {} 失败", + tmp_path.display(), + target_path.display() + ) + })?; } Ok(()) diff --git a/crates/common/src/gen_input5.rs b/crates/common/src/gen_input5.rs index 821d1a9..74c912a 100644 --- a/crates/common/src/gen_input5.rs +++ b/crates/common/src/gen_input5.rs @@ -332,8 +332,7 @@ mod tests { .filter(|l| { // ions 数据行:含引号且首 token 是整数 l.contains('\'') - && l - .split_whitespace() + && l.split_whitespace() .next() .map(|t| t.parse::().is_ok()) .unwrap_or(false) diff --git a/crates/common/src/models.rs b/crates/common/src/models.rs index 6d61a7d..0c1358d 100644 --- a/crates/common/src/models.rs +++ b/crates/common/src/models.rs @@ -280,12 +280,121 @@ impl From<&str> for GridPointStatus { } } +/// 执行策略(决定如何处理历史记录)。 +/// +/// 见 docs/task_engine_decoupling_design.md §2.1:阶段独立配置三维之一。 +/// +/// **语义(2026-08-04 修正)**:策略只决定**启动工作流时**对历史终态点(converged/failed) +/// 的处理;失败后的策略链回退**只由启动时的策略链(回退优先级排序)驱动**,不受策略门控。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum StagePolicy { + /// 跳过已收敛、重试已失败:启动时把已失败点打回 pending 重试,收敛点保留(增量+重试失败)。 + /// 默认值。 + #[default] + SkipConverged, + /// 强制重算(无视历史状态与产物):启动时收敛 + 失败全部打回 pending。 + ForceRecompute, + /// 跳过收敛及失败:启动时收敛和失败点都保留,只算从未计算过的点(最保守增量)。 + SkipFailed, +} + +impl StagePolicy { + /// 序列化为 DB 文本列存储用的 snake_case 字符串。 + pub fn as_str(&self) -> &'static str { + match self { + StagePolicy::SkipConverged => "skip_converged", + StagePolicy::ForceRecompute => "force_recompute", + StagePolicy::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, + } + } +} + +/// 独立阶段配置(TLUSTY / SYNSPEC 各一份)。 +/// +/// 见 docs/task_engine_decoupling_design.md §3:嵌套式单阶段配置模型, +/// 包含三个正交维度:enabled / policy / strategies。 +/// +/// 为避免与 `common::config::StageConfig`(迭代步进参数)同名冲突,命名为 +/// `EngineStageConfig`。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EngineStageConfig { + /// 是否在当前计算流中启用该阶段。 + #[serde(default = "default_engine_stage_enabled")] + pub enabled: bool, + /// 决定如何处理历史记录。 + #[serde(default)] + pub policy: StagePolicy, + /// 策略链队列(按回退优先级排序),如 `["cold_run", "seed_step"]`。 + /// 节点总是执行 `strategies[0]`;失败后由服务端弹出首项,下一顺位顶上。 + #[serde(default)] + pub strategies: Vec, +} + +fn default_engine_stage_enabled() -> bool { + true +} + +impl EngineStageConfig { + /// TLUSTY 阶段默认配置:启用、增量、策略链 `[cold_run, seed_step]`。 + pub fn default_tlusty() -> Self { + Self { + enabled: true, + policy: StagePolicy::SkipConverged, + strategies: vec!["cold_run".to_string(), "seed_step".to_string()], + } + } + + /// SYNSPEC 阶段默认配置:启用、增量、策略链 `[standard]`。 + pub fn default_synspec() -> Self { + Self { + enabled: true, + policy: StagePolicy::SkipConverged, + strategies: vec!["standard".to_string()], + } + } + + /// 当前应执行的策略(队列首项)。空链兜底为传入的 fallback。 + pub fn current_strategy<'a>(&'a self, fallback: &'a str) -> &'a str { + self.strategies + .first() + .map(|s| s.as_str()) + .unwrap_or(fallback) + } + + /// 是否还含指定策略(任意位置)。用于回退去重等场景。 + /// + /// 注(审查 #6):生产回退路径现走 DB 侧策略链弹栈(`pop_stage_strategy_for_fallback`), + /// 本方法当前主要用于测试断言与诊断(判断某策略是否仍在链中),保留为公共工具。 + pub fn has_strategy(&self, name: &str) -> bool { + self.strategies.iter().any(|s| s == name) + } +} + /// Task execution specification sent to Node +/// +/// 注:`EngineStageConfig` 刻意**不实现 `Default`**——阶段默认值随阶段而异(TLUSTY +/// `[cold_run, seed_step]` vs SYNSPEC `[standard]`),无中立的默认语义。构造某阶段的配置请用 +/// `..EngineStageConfig::default_tlusty()` / `..EngineStageConfig::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, pub seed_point_name: Option, pub timeout_sec: u64, @@ -297,6 +406,72 @@ pub struct TaskSpec { /// 旧 payload 反序列化时缺省为 0。 #[serde(default)] pub wave: i32, + /// TLUSTY 阶段独立配置(见 docs/task_engine_decoupling_design.md §3)。 + /// 旧 payload 反序列化时缺省为 `default_tlusty()`。 + #[serde(default = "EngineStageConfig::default_tlusty")] + pub tlusty_config: EngineStageConfig, + /// SYNSPEC 阶段独立配置。旧 payload 反序列化时缺省为 `default_synspec()`。 + #[serde(default = "EngineStageConfig::default_synspec")] + pub synspec_config: EngineStageConfig, + /// SYNSPEC 数值参数(波长范围等,对应 `config::SynspecConfig`)。 + /// 以 `serde_json::Value` 携带避免 models ↔ config 循环依赖;executor 侧 + /// 反序列化为 `SynspecConfig` 后透传给 runner。None → runner 用硬编码默认。 + /// 旧 payload 反序列化时缺省为 None(旧节点本就用默认,无回归)。 + #[serde(default)] + pub synspec_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 { + task_id: Uuid::nil(), + point_name: String::new(), + params: GridPointParams { + teff: 0.0.into(), + logg: 0.0.into(), + loghe: 0.0.into(), + logc: 0.0.into(), + 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(), + synspec_params: None, + atmosphere_ref: None, + } + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -331,6 +506,10 @@ pub struct TaskReport { pub elapsed_sec: f64, pub error_message: Option, pub summary_json: String, + /// 失败阶段归因(见 docs/task_engine_decoupling_design.md §4.2):`"tlusty"` / `"synspec"`。 + /// 节点据 ModelSummary 推断;旧节点不携带该字段 → 服务端兜底按 TLUSTY 链回退(兼容)。 + #[serde(default)] + pub failed_stage: Option, } /// Node registration request @@ -349,6 +528,22 @@ pub struct NodeHeartbeatRequest { pub memory_usage: f32, } +/// Node heartbeat response. +/// +/// 设计依据见 docs/dynamic_cpu_slots_design.md:服务端在心跳响应里透传管理员设置的 +/// `admin_max_slots`(并发槽位配额上限),Worker 据此动态调整本地领用并发数, +/// 避免 Pull 模式下被服务端强行拒绝 claim 而陷入空轮询。 +/// +/// - `status`:固定 "ok"(401/403 由 HTTP 状态码承载,不会进入反序列化路径)。 +/// - `admin_max_slots`:管理员强制配额上限(`null` 表示无限制,恢复节点物理槽位上限)。 +/// `#[serde(default)]` 保证旧服务端(响应体不含此字段)反序列化兜底为 None。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeHeartbeatResponse { + pub status: String, + #[serde(default)] + pub admin_max_slots: Option, +} + /// Node state in database #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NodeInfo { @@ -359,6 +554,10 @@ pub struct NodeInfo { pub cpu_usage: f32, pub memory_usage: f32, pub last_heartbeat: DateTime, + /// 管理员强制并发槽位上限(动态调整 CPU 核数)。None 表示无限制,使用 max_slots。 + /// 透传给前端供 Dashboard 渲染配额状态,并在心跳响应里下发给 Worker。 + #[serde(default)] + pub admin_max_slots: Option, } /// Single iteration convergence result parsed from fort.9 @@ -665,4 +864,109 @@ mod tests { assert_eq!(GridPointStatus::from("failed"), GridPointStatus::Failed); assert_eq!(GridPointStatus::from("unknown"), GridPointStatus::Pending); } + + /// `StagePolicy` 的 snake_case serde 往返 + DB 文本兜底。 + #[test] + fn test_stage_policy_serde_roundtrip() { + for p in [ + StagePolicy::SkipConverged, + StagePolicy::ForceRecompute, + StagePolicy::SkipFailed, + ] { + let s = serde_json::to_string(&p).unwrap(); + let back: StagePolicy = serde_json::from_str(&s).unwrap(); + assert_eq!(p, back); + } + // snake_case 形态锁定(前端 payload 与 DB 列口径) + assert_eq!( + serde_json::to_string(&StagePolicy::SkipConverged).unwrap(), + "\"skip_converged\"" + ); + assert_eq!( + serde_json::to_string(&StagePolicy::ForceRecompute).unwrap(), + "\"force_recompute\"" + ); + assert_eq!( + serde_json::to_string(&StagePolicy::SkipFailed).unwrap(), + "\"skip_failed\"" + ); + // as_str/from_str_lossy 互逆(非法值兜底 SkipConverged) + assert_eq!( + StagePolicy::from_str_lossy("skip_converged"), + StagePolicy::SkipConverged + ); + assert_eq!( + StagePolicy::from_str_lossy("garbage"), + StagePolicy::SkipConverged + ); + assert_eq!( + StagePolicy::from_str_lossy(StagePolicy::ForceRecompute.as_str()), + StagePolicy::ForceRecompute + ); + } + + /// `EngineStageConfig` serde 往返 + 默认值(缺字段时 serde default 兜底)。 + #[test] + fn test_engine_stage_config_serde_and_defaults() { + let cfg = EngineStageConfig { + enabled: false, + policy: StagePolicy::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(); + assert_eq!(cfg, back); + + // 空 payload 应产出默认值(enabled=true, policy=skip_converged, strategies=[]) + let empty: EngineStageConfig = serde_json::from_str("{}").unwrap(); + assert!(empty.enabled); + assert_eq!(empty.policy, StagePolicy::SkipConverged); + assert!(empty.strategies.is_empty()); + + // current_strategy 空链兜底 + assert_eq!(empty.current_strategy("cold_run"), "cold_run"); + assert_eq!(cfg.current_strategy("x"), "cold_run"); + assert!(cfg.has_strategy("seed_step")); + assert!(!cfg.has_strategy("standard")); + } + + /// 旧版 MQ 在途消息(仅含 task_type,无 tlusty_config)经 `#[serde(default)]` + /// 反序列化后,`normalize_compat()` 应据 task_type 回填 strategies。 + #[test] + fn test_task_spec_normalize_compat_from_legacy_task_type() { + 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(); + 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 消息保持默认链" + ); + } } diff --git a/crates/common/src/result_filter.rs b/crates/common/src/result_filter.rs index c14f9ca..ed2d6c0 100644 --- a/crates/common/src/result_filter.rs +++ b/crates/common/src/result_filter.rs @@ -13,7 +13,8 @@ //! //! 1. **裸名保留**(有独立语义,不以 model_name 为前缀): //! `conv.json`、`fort.8`(synspec 输入大气)、`fort.55`(synspec 控制卡) -//! 2. **科学核心**:`.7`、`.spec`、`.cont`、`.iden`、`.log` +//! 2. **科学核心**:`.7`、`.spec`、`.cont`、`.iden`、`.log`、 +//! `.bfac`(TLUSTY 最终 b 因子/非 LTE 偏离因子)、`.emflux`(TLUSTY 最终出射谱 λ–Fλ) //! 3. **阶段快照**:`.