Files
DCTS/crates/common/src/runner.rs
T
fmq d16b3d3cdc feat(all): 数据库模块化拆分与版本化迁移、任务引擎命名体系收敛、物理输出校验加固与用户配置接通
- 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
2026-08-06 20:51:21 +08:00

900 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::config::{ChainStep, SynspecInput, TlustyInput};
use crate::conv_check::{atmosphere_has_nan, check_fort9, extract_failure_hint, spec_is_valid};
use crate::embedded::RuntimePaths;
use crate::fort55_writer::generate_fort55_content;
use crate::gen_input5::make_input5;
use crate::models::{GridPointParams, ModelSummary, StepSummary};
use crate::nst_writer::generate_nst_content;
use anyhow::Result;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Instant;
use tokio::fs::File;
use tokio::process::Command as AsyncCommand;
use tracing::{info, warn};
pub fn default_cold_chain() -> Vec<ChainStep> {
vec![
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: Some("cno".to_string()),
ichang: None,
idlte: None,
iacc: None,
orelax: None,
},
ChainStep {
label: "nc".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
ilvlin: 0,
require_converged: false,
niter: 10,
chmax: None,
itek: None,
metals: Some("cno".to_string()),
ichang: None,
idlte: None,
iacc: None,
orelax: None,
},
ChainStep {
label: "nl".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
ilvlin: 100,
require_converged: true,
niter: 100,
chmax: None,
itek: None,
metals: Some("cno".to_string()),
ichang: None,
idlte: None,
iacc: None,
orelax: None,
},
]
}
pub fn default_seed_chain() -> Vec<ChainStep> {
vec![
ChainStep {
label: "seed_nc".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
ilvlin: 0,
require_converged: false,
niter: 20,
chmax: None,
itek: None,
metals: Some("cno".to_string()),
ichang: Some(0),
idlte: None,
iacc: None,
orelax: None,
},
ChainStep {
label: "nl".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
ilvlin: 100,
require_converged: true,
niter: 100,
chmax: None,
itek: None,
metals: Some("cno".to_string()),
ichang: Some(0),
idlte: None,
iacc: None,
orelax: None,
},
]
}
/// 按当前策略选默认执行链(`custom_chain` 为 None/空时的兜底)。
///
/// Phase 6P8)起取代废弃的 task_type 匹配:`"seed_step"` → 种子热启动链,其余策略
/// `cold_run` 等)→ 冷启动链。executor 现优先使用 TaskSpec.tlusty_chain_params
/// (用户 YAML `tlusty_chain:` 配置),None/空才回退本函数的默认链。
pub fn default_chain_for_strategy(current_strategy: &str) -> Vec<ChainStep> {
if current_strategy == "seed_step" {
default_seed_chain()
} else {
default_cold_chain()
}
}
/// 运行子进程,带超时与优雅退出(shutdown)感知。
///
/// 三种终止路径:
/// 1. 子进程正常结束 → 返回 ExitStatus。
/// 2. 超时(timeout_sec)→ SIGKILL 子进程 + 二级 30s 等待 reap,超时则放弃 Childkill_on_drop 兜底)。
/// 3. shutdown 信号(节点收到 SIGTERM/SIGINT)→ 立即 SIGKILL 子进程并快速返回 Err
/// 让上层尽快退出(在途任务的结果会丢失,由服务端 stale 重投兜底)。
///
/// 历史 bug:超时 kill 后 `child.wait().await` 无二级超时,Fortran 进程若卡死
/// OpenMP hang / ptrace)会使 wait 永久阻塞,超时机制名存实亡、slot 永久泄漏。
async fn run_child_async_with_timeout(
mut child: tokio::process::Child,
timeout_sec: u64,
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Result<std::process::ExitStatus> {
let timeout_fut =
tokio::time::timeout(tokio::time::Duration::from_secs(timeout_sec), child.wait());
// 若提供了 shutdown 标志,则与超时/正常结束三路 select;否则只等超时/正常结束。
let outcome: Result<std::process::ExitStatus, ShutdownOrTimeout> = if let Some(flag) = shutdown
{
let shutdown_watcher = async move {
// 轮询 shutdown 标志(10ms 粒度足够灵敏,开销可忽略)。
loop {
if flag.load(std::sync::atomic::Ordering::Acquire) {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
};
tokio::select! {
biased; // 优先响应 shutdown
_ = shutdown_watcher => Err(ShutdownOrTimeout::Shutdown),
r = timeout_fut => match r {
Ok(res) => Ok(res?),
Err(_) => Err(ShutdownOrTimeout::Timeout),
},
}
} else {
match timeout_fut.await {
Ok(res) => Ok(res?),
Err(_) => Err(ShutdownOrTimeout::Timeout),
}
};
match outcome {
Ok(status) => Ok(status),
Err(ShutdownOrTimeout::Shutdown) => {
let _ = child.start_kill();
let _ = tokio::time::timeout(std::time::Duration::from_secs(30), child.wait()).await;
anyhow::bail!("节点收到退出信号,子进程已被终止");
}
Err(ShutdownOrTimeout::Timeout) => {
let _ = child.start_kill();
let _ = tokio::time::timeout(std::time::Duration::from_secs(30), child.wait()).await;
anyhow::bail!("进程计算超时 (上限: {} 秒)", timeout_sec);
}
}
}
#[derive(Debug)]
enum ShutdownOrTimeout {
Shutdown,
Timeout,
}
/// 快照 TLUSTY 最终模型的 b 因子与出射谱,防止被 SYNSPEC 覆盖丢失。
///
/// TLUSTY 在最终迭代(`LFIN=.TRUE.`)经 `OUTPRI` 写出(见 tlusty208.f):
/// - `fort.12`b 因子 / 非 LTE 偏离因子表(头 2I5 + 每深度 TEMP/ELEC/DENS/BFAC,格式 701/702/703)。
/// 随后 SYNSPEC 会复用 unit 12 写谱线证认表并覆盖它(runner 再将其存为 `<name>.iden`),
/// 故 TLUSTY 的 b 因子若不在此快照即静默丢失。
/// - `fort.14`:出射谱(波长 Å + Fλ,格式 614),同样会被 SYNSPEC 的谱线数据覆盖。
///
/// 在收敛链循环结束(链上最后一次 TLUSTY 运行即最终模型)、SYNSPEC 启动前调用,
/// 快照为 `<name>.bfac` / `<name>.emflux`,与科学核心产物一并进入归档白名单
/// (见 `result_filter::is_result_worthy` 的 `bfac`/`emflux` 后缀)。
/// 文件不存在时静默跳过(TLUSTY 未运行/未写出);IO 错误降级为 warn,不阻断主流程。
async fn snapshot_tlusty_outputs(model_dir: &Path, name: &str) {
for (src, suffix) in [("fort.12", "bfac"), ("fort.14", "emflux")] {
let src_path = model_dir.join(src);
if !src_path.is_file() {
continue;
}
let dst = model_dir.join(format!("{}.{}", name, suffix));
if let Err(e) = tokio::fs::copy(&src_path, &dst).await {
warn!("快照 TLUSTY {} 到 {} 失败: {}", src, dst.display(), e);
}
}
}
pub struct ExecutionRunner<'a> {
pub runtime: &'a RuntimePaths,
pub work_dir: PathBuf,
}
impl<'a> ExecutionRunner<'a> {
pub fn new(runtime: &'a RuntimePaths, work_dir: PathBuf) -> Self {
Self { runtime, work_dir }
}
#[allow(clippy::too_many_arguments)] // 透传全参给 run_model_with_timeout(后者同 allow
pub async fn run_model(
&self,
params: &GridPointParams,
name: &str,
current_strategy: &str,
custom_chain: Option<Vec<ChainStep>>,
seed_atmos: Option<&Path>,
synspec_cfg: Option<&SynspecInput>,
tlusty_input: Option<&TlustyInput>,
) -> Result<ModelSummary> {
self.run_model_with_timeout(
params,
name,
current_strategy,
custom_chain,
seed_atmos,
synspec_cfg,
true,
true,
7200,
None,
tlusty_input,
)
.await
}
/// 阶段独立配置执行入口(见 docs/task_engine_decoupling_design.md §5)。
///
/// `tlusty_enabled` / `synspec_enabled` 控制各阶段是否运行:
/// - TLUSTY 关闭:跳过 chain 循环,直接以 seed_atmos(或单独拉取的大气)作 final_7;
/// - SYNSPEC 关闭:跳过光谱合成块(即便 final_7 存在)。
///
/// Phase 6P8):`current_strategy` 取代废弃的 `task_type`——执行链由
/// `custom_chain`executor 按 `tlusty_config.strategies[0]` 显式推导)决定;
/// 该参数仅用于日志与 custom_chain=None 时的兜底("seed_step"→种子链,否则冷启动链)。
#[allow(clippy::too_many_arguments)]
pub async fn run_model_with_timeout(
&self,
params: &GridPointParams,
name: &str,
current_strategy: &str,
custom_chain: Option<Vec<ChainStep>>,
seed_atmos: Option<&Path>,
synspec_cfg: Option<&SynspecInput>,
tlusty_enabled: bool,
synspec_enabled: bool,
timeout_sec: u64,
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
tlusty_input: Option<&TlustyInput>,
) -> Result<ModelSummary> {
// `name` 取自权威的 TaskSpec.point_nameDB 的 grid_points.name 列,源精度正确),
// 而非 params.model_name()。原因:服务端把 GridPointParams 存成 6 个 REAL 数值列,
// 回读时用 from_value() 反推文本会丢精度("5.0"→"5"),导致 params.model_name()
// 产出错误名(g5 而非 g5.0)。point_name 走独立 TEXT 列,精度全程保留。
// 下游(沙盒子目录、各阶段快照、conv.json.name、归档目录)全部用此 name,
// 故只需在此处用权威 name 即可让整条链精度正确。
//
// 历史:此处曾把 params.model_name() 与 name 对比并 warn 不一致。但该不一致是
// DB REAL 列回读丢精度的已知现象(runner 端无法修复,根治需改 DB schema 存原文),
// 且 runner 已全程采用权威 name,对比结果不参与任何决策——故移除这段噪音 warn。
let model_dir = self.work_dir.join(name);
tokio::fs::create_dir_all(&model_dir).await?;
info!("开始物理计算网格模型 {} (策略: {})", name, current_strategy);
let t0 = Instant::now();
// 1. Data directory symlink setup
let link_data = model_dir.join("data");
if tokio::fs::symlink_metadata(&link_data).await.is_ok() || link_data.exists() {
let _ = tokio::fs::remove_file(&link_data).await;
}
#[cfg(unix)]
{
let abs_data_dir = tokio::fs::canonicalize(&self.runtime.data_dir)
.await
.unwrap_or_else(|_| self.runtime.data_dir.clone());
if let Err(e) = std::os::unix::fs::symlink(&abs_data_dir, &link_data) {
warn!("构建 data 数据集软链时发生提示性告警: {}", e);
}
}
// 2. Initial fort.8 seed setup
let fort8 = model_dir.join("fort.8");
if fort8.exists() {
let _ = tokio::fs::remove_file(&fort8).await;
}
if let Some(seed_path) = seed_atmos {
if seed_path.is_file() {
if let Err(e) = tokio::fs::copy(seed_path, &fort8).await {
warn!(
"向工作沙盒引导填载首期收敛模型种子 fort.8 发生复制错误: {}",
e
);
}
}
}
// Clean fort.84 residue to prevent NATOMS Fortran crash
let fort84 = model_dir.join("fort.84");
if fort84.exists() {
let _ = tokio::fs::remove_file(&fort84).await;
}
let chain = custom_chain.unwrap_or_else(|| default_chain_for_strategy(current_strategy));
let mut stage_summaries = Vec::new();
let mut current_seed: Option<PathBuf> = seed_atmos.map(|p| p.to_path_buf());
let mut final_converged = false;
let mut final_chmax: Option<f64> = None;
let mut final_max_relc: Option<f64> = None;
// 阶段独立配置(见 docs/task_engine_decoupling_design.md §5):
// TLUSTY 关闭时跳过整个 chain 循环——current_seed 直接作为 final_7 来源,
// 适配「仅 SYNSPEC」场景(用既有大气合成光谱,不重算大气结构)。
if tlusty_enabled {
info!("TLUSTY 阶段启用:执行 {} 步收敛链", chain.len());
} else {
info!("TLUSTY 阶段关闭:跳过大气结构计算,直接进入 SYNSPEC 阶段");
}
let tlusty_skipped = !tlusty_enabled;
for stage_def in &chain {
if tlusty_skipped {
break;
}
let stage_t0 = Instant::now();
let metals = stage_def.metals.as_deref().unwrap_or("cno");
let input5_text = make_input5(
params,
&stage_def.lte,
&stage_def.ltgray,
metals,
stage_def.ilvlin,
tlusty_input,
);
let input5_path = model_dir.join(format!("{}.5", name));
tokio::fs::write(&input5_path, &input5_text).await?;
// Write nst file
let nst_text = generate_nst_content(stage_def, tlusty_input);
tokio::fs::write(model_dir.join("nst"), &nst_text).await?;
// Prepare fort.8 for this stage
if stage_def.ltgray == "T" {
if fort8.exists() {
let _ = tokio::fs::remove_file(&fort8).await;
}
} else if let Some(ref s_path) = current_seed {
if s_path.is_file() {
if let Err(e) = tokio::fs::copy(s_path, &fort8).await {
warn!(
"阶段 {} 重载候选近邻推算种子模型期间发生文件复制异常: {}",
stage_def.label, e
);
}
}
}
// Run tlusty.exe
let fin = File::open(&input5_path).await?.into_std().await;
let fout = File::create(model_dir.join(format!("{}.6", name)))
.await?
.into_std()
.await;
let ferr = File::create(model_dir.join(format!("{}.err", name)))
.await?
.into_std()
.await;
let child = AsyncCommand::new(&self.runtime.tlusty_exe)
.current_dir(&model_dir)
.stdin(Stdio::from(fin))
.stdout(Stdio::from(fout))
.stderr(Stdio::from(ferr))
.kill_on_drop(true)
.spawn()?;
let status_res =
run_child_async_with_timeout(child, timeout_sec, shutdown.clone()).await;
let rc = match status_res {
Ok(st) => st.code().unwrap_or(-1),
Err(e) => {
warn!("tlusty 运行失败/超时: {}", e);
-1
}
};
let fort9 = model_dir.join("fort.9");
let fort7 = model_dir.join("fort.7");
let mut stage_summary = StepSummary {
label: stage_def.label.clone(),
chmax: stage_def.chmax,
lte: stage_def.lte.clone(),
converged: false,
best_max_relc: None,
elapsed_sec: stage_t0.elapsed().as_secs_f64(),
note: None,
last_iter: None,
worst_depth: None,
n_depths: None,
itek_history: Vec::new(),
};
if rc == 0 && fort7.is_file() {
let eff_chmax = stage_def.chmax.unwrap_or(0.001);
if fort9.is_file() {
let res = check_fort9(&fort9, eff_chmax);
stage_summary.converged = res.converged;
stage_summary.best_max_relc = Some(res.max_relc);
// 携带迭代诊断量进 conv.json(旧版在此处丢弃):
// 迭代数/最差深度点供详情页阶段链展示收敛难度。
stage_summary.last_iter = res.last_iter;
stage_summary.worst_depth = Some(res.worst_depth);
stage_summary.n_depths = Some(res.n_depths);
// itek 全量保真(Phase 5b):逐次迭代诊断随 summary_json/conv.json 落库。
stage_summary.itek_history = res.itek_history;
// 漏洞5修复:发散时从 fort.6 提取求解器 STOP 行(SOLVE/SOLVES/RYBSOL
// 作为 note,提升归因质量。仅未收敛且无既有 note 时补(避免覆盖错误信息)。
if !res.converged && stage_summary.note.is_none() {
let fort6 = model_dir.join(format!("{}.6", name));
if let Some(h) = extract_failure_hint(&fort6) {
stage_summary.note = Some(format!("未收敛 [{}]", h));
}
}
// Save fort.9 snapshot
let snap_name = format!("{}.{}_chmax{}.9", name, stage_def.label, eff_chmax);
let _ = tokio::fs::copy(&fort9, model_dir.join(snap_name)).await;
} else {
// fort.9 缺失:按 stage_def.niter 区分两种场景(漏洞2进阶修复)。
// - niter==0:合法 grey startlte 阶段不迭代,TLUSTY 不写 fort.9)。
// converged=true 保留 grey start 语义;best_max_relc=None 不虚构
// (避免污染 final_max_relc/种子选择)。
// - niter>0:异常——配了迭代却无 fort.9,通常是 TLUSTY 启动失败
// call quit,如 temp 越界)或 IO 异常。判 converged=false
// 避免把崩溃误判为收敛。此前两种场景共用无校验分支无法区分。
if stage_def.niter == 0 {
stage_summary.converged = true;
stage_summary.best_max_relc = None;
stage_summary.note = Some("NITER=0 grey start".to_string());
} else {
stage_summary.converged = false;
stage_summary.best_max_relc = None;
// 补 fort.6 失败诊断(call quit 留言),便于排查启动失败原因。
let fort6 = model_dir.join(format!("{}.6", name));
let hint = extract_failure_hint(&fort6);
stage_summary.note = Some(match hint {
Some(h) => format!(
"stage {} 配置 NITER={} 但 fort.9 缺失 [{}]",
stage_def.label, stage_def.niter, h
),
None => format!(
"stage {} 配置 NITER={} 但 fort.9 缺失(TLUSTY 未完成迭代)",
stage_def.label, stage_def.niter
),
});
}
}
// Copy fort.7 as stage seed
let stage_seed_path = model_dir.join(format!("{}.{}.7", name, stage_def.label));
let _ = tokio::fs::copy(&fort7, &stage_seed_path).await;
current_seed = Some(stage_seed_path);
} else {
// 漏洞5修复:fort.7 缺失分支(输入错误、temp 越界等 call quit 场景),
// 从 fort.6 尾部提取 call quit / stop 留言补进 note,便于排查。
let fort6 = model_dir.join(format!("{}.6", name));
let hint = extract_failure_hint(&fort6);
stage_summary.note = Some(match hint {
Some(h) => format!("tlusty rc={} or missing fort.7 [{}]", rc, h),
None => format!("tlusty rc={} or missing fort.7", rc),
});
}
// 快照本阶段的同名输入/输出文件,带阶段标签保留。
// 背景:.5/.6/.err/nst 在每阶段用同名文件覆盖,若不快照则只有最后阶段(nl)
// 的版本能存活到归档,nc 等中间阶段的日志/输入会丢失。失败阶段的日志对排错
// 尤其重要,因此此处无条件(不论 rc 是否为 0)快照。
// 命名风格与上方 .7/.9 快照一致:<name>.<label>.<后缀>(单 name,不重复)。
// 注意:stage_def.label 由配置保证唯一(lte/nc/nl/seed_nc),不会与 synspec 产物冲突。
//
// 不快照 fort.9:上方 L292 已把 fort.9 收敛诊断存为 `<name>.<label>_chmax*.9`
// (带 chmax 阈值语义),再快照成 `<name>.<label>.9` 会与它内容完全重复。
// 故 .9 收敛诊断只保留 `_chmax*.9` 一份,不留重复快照。
// (suffix, full_src_name) —— suffix 用于快照名后缀,full_src_name 用于定位源文件
for (suffix, full_name) in [
("5", format!("{}.5", name)),
("6", format!("{}.6", name)),
("err", format!("{}.err", name)),
("nst", "nst".to_string()),
] {
let src = model_dir.join(&full_name);
if src.is_file() {
let snap = model_dir.join(format!("{}.{}.{}", name, stage_def.label, suffix));
let _ = tokio::fs::copy(&src, &snap).await;
}
}
final_chmax = stage_def.chmax;
final_converged = stage_summary.converged;
if let Some(r) = stage_summary.best_max_relc {
final_max_relc = Some(r);
}
stage_summaries.push(stage_summary);
if !final_converged && stage_def.require_converged {
warn!(
"阶段 {} 要求收敛但未达标,中止后续收敛链阶段",
stage_def.label
);
break;
}
}
// Final atmosphere file .7
let final_7 = model_dir.join(format!("{}.7", name));
if let Some(ref s_path) = current_seed {
if s_path.is_file() {
let _ = tokio::fs::copy(s_path, &final_7).await;
}
} else if model_dir.join("fort.7").is_file() {
let _ = tokio::fs::copy(model_dir.join("fort.7"), &final_7).await;
}
// TLUSTY 最终 b 因子 + 出射谱快照。必须在此处(SYNSPEC 覆盖 fort.12/fort.14 之前)
// 完成:synspec 会复用 unit 12/14 写谱线数据,覆盖 TLUSTY 的最终产物(见上方
// snapshot_tlusty_outputs 的注释)。仅 SYNSPEC 场景(tlusty_enabled=false)下
// fort.12/14 不存在,函数内按文件是否存在静默跳过。
snapshot_tlusty_outputs(&model_dir, name).await;
// L2 修复:`atmosphere_has_nan` 对**缺失**文件返回 false(语义是"无 NaN"而非"有效"),
// 与**空文件返回 true** 语义不对称。缺失最终大气 = 无可判定收敛的干净大气 →
// 在此显式判定为无效(NaN),与空文件语义对齐。调用前提:final_7 应在收敛链产出;
// 若缺失(如种子拷贝失败、TLUSTY 崩溃未写 fort.7),本守卫强制最终不收敛。
let atmo_has_nan = if final_7.is_file() {
atmosphere_has_nan(&final_7)
} else {
true
};
if atmo_has_nan {
final_converged = false;
}
// Run synspec if enabled and final .7 atmosphere exists
let mut synspec_rc = None;
let mut synspec_err = None;
let mut synspec_sec = None;
if !synspec_enabled {
// SYNSPEC 关闭是合法配置(TLUSTY-only 大气计算),不写入 synspec_error
// 以免污染 conv.json 的错误归因——下游把非空 synspec_error 当「光谱有缺陷」。
info!("SYNSPEC 阶段关闭:跳过光谱合成(TLUSTY-only 模式)");
} else if final_7.is_file() {
let syn_t0 = Instant::now();
// H10synspec 输入文件(fort.8 大气 / fort.55 控制卡)写入失败不可静默吞掉。
// 历史上用 `let _ =` 忽略错误,磁盘满/inode 耗尽时 synspec 会读到旧/缺失的
// fort.8 产出垃圾光谱,却仍生成 .spec 并被归档为"成功"。现改为写入失败即记
// synspec_err 并跳过 synspec 阶段,避免产出物理上错误的谱。
if let Err(e) = tokio::fs::copy(&final_7, model_dir.join("fort.8")).await {
warn!("synspec 输入 fort.8 (大气) 复制失败,跳过 synspec: {}", e);
synspec_err = Some(format!("fort.8 copy failed: {}", e));
} else {
let _ = tokio::fs::remove_file(model_dir.join("fort.7")).await;
// Fort.55 parameter generation or symlink
let fort55_path = model_dir.join("fort.55");
let fort19_path = model_dir.join("fort.19");
let _ = tokio::fs::remove_file(&fort55_path).await;
let _ = tokio::fs::remove_file(&fort19_path).await;
let default_cfg = SynspecInput {
wstart: 1400.0,
wend: 1410.0,
imode: 0,
idrv: 50,
ifreq: 1,
rel_cutoff: 0.0001,
abs_cutoff: 0.01,
};
let fort55_text = generate_fort55_content(synspec_cfg.unwrap_or(&default_cfg));
if let Err(e) = tokio::fs::write(&fort55_path, &fort55_text).await {
warn!(
"synspec 输入 fort.55 (控制卡) 写入失败,跳过 synspec: {}",
e
);
synspec_err = Some(format!("fort.55 write failed: {}", e));
} else {
#[cfg(unix)]
{
let abs_linelist = tokio::fs::canonicalize(&self.runtime.linelist)
.await
.unwrap_or_else(|_| self.runtime.linelist.clone());
let _ = std::os::unix::fs::symlink(&abs_linelist, &fort19_path);
}
}
}
let input5_path = model_dir.join(format!("{}.5", name));
if synspec_err.is_none() && input5_path.is_file() {
let fin = File::open(&input5_path).await?.into_std().await;
let fout = File::create(model_dir.join(format!("{}.log", name)))
.await?
.into_std()
.await;
let child = AsyncCommand::new(&self.runtime.synspec_exe)
.current_dir(&model_dir)
.stdin(Stdio::from(fin))
.stdout(Stdio::from(fout))
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()?;
let synspec_timeout_sec = 600_u64.min(timeout_sec);
let status_res =
run_child_async_with_timeout(child, synspec_timeout_sec, shutdown.clone())
.await;
let rc = match status_res {
Ok(st) => st.code().unwrap_or(-1),
Err(e) => {
warn!("synspec 运行失败/超时: {}", e);
-1
}
};
synspec_rc = Some(rc);
synspec_sec = Some(syn_t0.elapsed().as_secs_f64());
// Copy/move outputs: fort.7 (Synspec spectrum) -> .spec, fort.17 -> .cont, fort.12 -> .iden
if model_dir.join("fort.7").is_file() {
let spec_path = model_dir.join(format!("{}.spec", name));
let _ = tokio::fs::rename(model_dir.join("fort.7"), &spec_path).await;
// 漏洞1修复(P0):SYNSPEC .spec 内容校验。
// gfortran 下 SYNSPEC 几乎所有错误路径 rc=0,旧代码只做 is_file() 存在性
// 检查,导致脏谱(NaN/Inf/行数不足/全零)被当作 Completed 归档——全链路
// 最大的科学正确性风险。命中无效则置 synspec_rc 非零 + synspec_error 描述,
// 让 reporter 判 Failed 并触发 synspec 策略链回退。
// 守卫 synspec_err.is_none():避免覆盖上游 fort.8/fort.55 复制失败的既有 err。
if synspec_err.is_none() {
if let Some(reason) = spec_is_valid(&spec_path) {
warn!("spec 校验失败: {}", reason);
synspec_rc = Some(1);
synspec_err = Some(reason);
}
}
}
if model_dir.join("fort.17").is_file() {
let _ = tokio::fs::copy(
model_dir.join("fort.17"),
model_dir.join(format!("{}.cont", name)),
)
.await;
}
if model_dir.join("fort.12").is_file() {
let _ = tokio::fs::copy(
model_dir.join("fort.12"),
model_dir.join(format!("{}.iden", name)),
)
.await;
}
}
} else {
synspec_err = Some("No atmosphere .7 produced".to_string());
}
// 收敛判定(见 docs/task_engine_decoupling_design.md §5):
// - TLUSTY 启用:final_converged 由 chain 循环内各阶段收敛状态累积得出(既有逻辑)。
// - TLUSTY 关闭(仅 SYNSPEC 场景):final_converged 不能恒为 false——否则成功的
// 光谱合成任务被误判失败并触发策略回退。此时收敛 = 大气加载干净(无 NaN)且
// SYNSPEC 成功(rc=0)或 SYNSPEC 也关闭(TLUSTY-only 等价的纯校验场景,虽罕见)。
// 仅 SYNSPEC 场景下大气来自既有产物(非本任务重算),NaN 检查仍必要(产物可能损坏)。
if !tlusty_enabled && !atmo_has_nan {
final_converged = match synspec_rc {
Some(rc) => rc == 0,
None => !synspec_enabled, // SYNSPEC 也关闭 → 仅校验大气,干净即收敛
};
}
// 清理冗余的裸文件:这些文件的内容已被带阶段标签的快照或重命名的科学产物覆盖,
// 保留它们只会与归档里的 <name>.<label>.* / <name>.iden / <name>.cont 等重复(尤其
// .spec/.cont 是大文件,双份存储浪费磁盘)。删除后归档目录干净无冗余。
// 注意:fort.8synspec 输入大气)和 fort.55synspec 控制卡)有独立语义,予以保留。
for redundant in [
format!("{}.5", name), // 同 <name>.<最后阶段label>.5
format!("{}.6", name), // 同 <name>.<最后阶段label>.6
format!("{}.err", name), // 同 <name>.<最后阶段label>.err
"nst".to_string(), // 同 <name>.<最后阶段label>.nst
"fort.9".to_string(), // 内容已被 <name>.<label>_chmax*.9 收敛诊断覆盖
"fort.12".to_string(), // 同 <name>.idensynspec 谱线证认)
"fort.17".to_string(), // 同 <name>.contsynspec 连续谱)
] {
let p = model_dir.join(&redundant);
if p.is_file() {
let _ = tokio::fs::remove_file(&p).await;
}
}
let elapsed_sec = t0.elapsed().as_secs_f64();
// 汇总 note(修复审查 #2 后续):半失败点(大气已收敛 + 光谱失败)须让
// synspec 的错误可见——此前 synspec rc≠0 时 note 恒为 None,上报的
// error_message 为空,attempts 表与详情面板无从排查失败原因。
let note = {
let mut notes: Vec<String> = Vec::new();
if atmo_has_nan {
notes.push("Invalidated: atmosphere contains NaN/Inf lines".to_string());
}
if let Some(ref err) = synspec_err {
notes.push(format!("synspec error: {}", err));
} else if let Some(rc) = synspec_rc {
if rc != 0 {
notes.push(format!("synspec rc={}", rc));
}
}
if notes.is_empty() {
None
} else {
Some(notes.join("; "))
}
};
let summary = ModelSummary {
name: name.to_string(),
params: params.clone(),
stages: stage_summaries,
result_valid: final_converged,
final_max_relc,
final_chmax,
seed: seed_atmos.map(|p| p.to_string_lossy().to_string()),
atmosphere_has_nan: atmo_has_nan,
synspec_rc,
synspec_error: synspec_err,
synspec_sec,
elapsed_sec,
note,
};
// Write conv.json
let json_text = serde_json::to_string_pretty(&summary)?;
tokio::fs::write(model_dir.join("conv.json"), json_text).await?;
Ok(summary)
}
}
#[cfg(test)]
mod tests {
use super::snapshot_tlusty_outputs;
use crate::models::{GridAxisValue, GridPointParams};
/// Phase 6(P8):执行链按当前策略派生——seed_step 走种子热启动链,其余走冷启动链。
#[test]
fn test_default_chain_for_strategy() {
let labels = |chain: Vec<super::ChainStep>| -> Vec<String> {
chain.into_iter().map(|s| s.label).collect()
};
// seed_step → 种子热启动链(seed_nc/nl)。
assert_eq!(
labels(super::default_chain_for_strategy("seed_step")),
vec!["seed_nc".to_string(), "nl".to_string()]
);
// 其余策略(cold_run / 未知如 standard)→ 冷启动链(lte/nc/nl)。
assert_eq!(
labels(super::default_chain_for_strategy("cold_run")),
vec!["lte".to_string(), "nc".to_string(), "nl".to_string()]
);
assert_eq!(
labels(super::default_chain_for_strategy("standard")),
vec!["lte".to_string(), "nc".to_string(), "nl".to_string()],
"未知策略兜底冷启动链(synspec-only strategies[0] 等)"
);
}
#[test]
fn test_synspec_timeout_calculation() {
let long_tlusty_timeout: u64 = 7200;
let synspec_timeout = 600_u64.min(long_tlusty_timeout);
assert_eq!(synspec_timeout, 600);
let short_tlusty_timeout: u64 = 300;
let synspec_timeout_short = 600_u64.min(short_tlusty_timeout);
assert_eq!(synspec_timeout_short, 300);
}
/// 回归测试:复现命名精度丢失场景,并锁定「runner 用 point_name 作权威名」的契约。
///
/// 背景:服务端 grid_points 表把 GridPointParams 存成 6 个 REAL 列,回读时用
/// `GridAxisValue::from_value()` 反推文本(`format_float_minimal`),整数-valued
/// 浮点数会丢小数(5.0 → "5")。于是 `params.model_name()` 产出 `g5` 而非 `g5.0`。
/// 而 `TaskSpec.point_name`DB 的 name TEXT 列,源精度)始终是 `g5.0`。
///
/// runner 的 `run_model_with_timeout` 现接收外部 `name: &str`(由 executor 传入
/// `task.point_name`),不再用降级的 `params.model_name()`。本测试构造降级后的
/// params,证明二者确实不同,从而确认「必须用 point_name」的修复是必要的。
#[test]
fn test_point_name_bypasses_degraded_params_model_name() {
// 模拟 DB REAL 列回读后的 paramslogg 经 from_value(5.0) 丢精度
let degraded = GridPointParams {
teff: GridAxisValue::from_value(20000.0),
logg: GridAxisValue::from_value(5.0), // text 退化为 "5"
loghe: GridAxisValue::from_value(-2.0),
logc: GridAxisValue::from_value(-4.0),
logn: GridAxisValue::from_value(-4.0),
logo: GridAxisValue::from_value(-4.0),
};
// 权威 point_nameDB name 列,保留源精度)
let point_name = "t20000_g5.0_he-2_c-4_n-4_o-4";
// 降级的 params 重推出的名字丢了 ".0"
assert_ne!(
degraded.model_name(),
point_name,
"降级 params.model_name() 应与权威 point_name 不同(这是 bug 的可观测证据)"
);
assert_eq!(degraded.model_name(), "t20000_g5_he-2_c-4_n-4_o-4");
// runner 现在直接采用 point_name(不再调 params.model_name()),故归档/产物名正确
let authoritative_name = point_name; // 即 executor 传入的 task.point_name
assert_eq!(authoritative_name, "t20000_g5.0_he-2_c-4_n-4_o-4");
}
/// 快照 TLUSTY b 因子与出射谱:fort.12→`<name>.bfac`、fort.14→`<name>.emflux`
/// 缺失的源文件静默跳过,未列入快照的 fort.13 不受影响。
#[tokio::test]
async fn test_snapshot_tlusty_outputs() {
let dir = tempfile::tempdir().unwrap();
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
let model_dir = dir.path().join(name);
tokio::fs::create_dir_all(&model_dir).await.unwrap();
// TLUSTY 最终迭代产物:b 因子(fort.12)与出射谱(fort.14
tokio::fs::write(model_dir.join("fort.12"), "bfac payload")
.await
.unwrap();
tokio::fs::write(model_dir.join("fort.14"), "emflux payload")
.await
.unwrap();
// 不参与快照的文件(出射辐射场 fort.13、大气 fort.7
tokio::fs::write(model_dir.join("fort.13"), "emrad payload")
.await
.unwrap();
tokio::fs::write(model_dir.join("fort.7"), "atmo payload")
.await
.unwrap();
snapshot_tlusty_outputs(&model_dir, name).await;
assert_eq!(
tokio::fs::read_to_string(model_dir.join(format!("{}.bfac", name)))
.await
.unwrap(),
"bfac payload"
);
assert_eq!(
tokio::fs::read_to_string(model_dir.join(format!("{}.emflux", name)))
.await
.unwrap(),
"emflux payload"
);
// fort.13 未列入快照,不应生成 <name>.emrad
assert!(!model_dir.join(format!("{}.emrad", name)).exists());
// 原 fort.12/fort.14 保留(后续 synspec 覆盖前仍作为单元文件存在)
assert!(model_dir.join("fort.12").is_file());
assert!(model_dir.join("fort.14").is_file());
}
/// 缺失源文件(仅 SYNSPEC 场景,tlusty 未运行)时快照应是无害 no-op
#[tokio::test]
async fn test_snapshot_tlusty_outputs_noop_when_missing() {
let dir = tempfile::tempdir().unwrap();
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
let model_dir = dir.path().join(name);
tokio::fs::create_dir_all(&model_dir).await.unwrap();
snapshot_tlusty_outputs(&model_dir, name).await;
assert!(!model_dir.join(format!("{}.bfac", name)).exists());
assert!(!model_dir.join(format!("{}.emflux", name)).exists());
}
}