Files
DCTS/crates/node/src/executor.rs
T
fmq b058e66722 feat(all): NaN 伪收敛否决与 nl_tight 能量回退、emflux 检验 4π 修正、nst 行宽与种子边界修复、gfATO 谱线表接通与网格加密 9216 点、阶段分项统计与跳板机部署
物理修复(400 失败点归因,见 docs/failed400_nan_pseudo_convergence_2026_08_17.md):
- runner: 阶段 converged 后复查 fort.7,含 NaN/Inf 即否决(fort.9 全零伪收敛,
  曾致 261 点误跳过 nl_direct 回退);否决阶段不产出种子,阻断污染传播
- runner: nl_tight 回退——仅能量边际失败时以 CHMAX 收紧 10× 从自身模型续迭代,
  残差降幅 ~10×;execute_tlusty_stage 抽取供主链与回退共用
- conv_check: fort.14 为 Eddington 通量 Hλ,积分需乘 4π 再比 σTeff⁴
  (旧版 ratio 稳定 0.0796=1/4π,全点系统性假阳性)+ 回归测试
- nst_writer: 单行超 80 字符被 TLUSTY 静默截断,IFALI/JALI/TRAD 等从未生效;
  按 75 字符自动换行
- seed_finder: Teff 容忍度改含边界 <=,相邻 5000K 档恢复互为种子 + 回归测试

谱线表与网格:
- 默认线表 gfVIS99 → gfATO(全波段 18-23000Å),TaskSpec.linelist 支持工作流
  级覆盖,节点按需下载(进程互斥锁防并发重复下载 238MB)
- sdB_cno Teff 加密至 5000K 步长,432 → 9216 点;tlusty/synspec 静态二进制更新

统计与部署:
- grid 汇总改按 tlusty_status/synspec_status 分项计数,新增 tlusty_failed/
  synspec_failed/synspec_pending,前端详情页双视图适配
- deploy/fetch_results 支持跳板机 ProxyJump 与 SSH 主连接复用,fetch 新增 --force;
- Docker 构建支持 CARGO_MIRROR/USE_MIRRORS 国内镜像参数;移除 tools/ 拷贝
- 新增 tlusty-synspec-test skill 与 6 篇根因分析/验证文档
2026-08-17 23:55:26 +08:00

758 lines
32 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 anyhow::Result;
use common::config::{ChainStep, SynspecInput, TlustyInput};
use common::embedded::{ensure_specific_data_files, RuntimePaths};
use common::models::{ModelSummary, TaskSpec};
use common::result_filter::is_result_worthy;
use common::runner::ExecutionRunner;
use reqwest::Client;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
/// 进程级互斥锁:序列化谱线表按需下载,防止多 Slot 并发首次派发时对同一 238MB 文件
/// 重复发起下载请求(POSIX rename 保证写安全,但冗余下载浪费带宽与 IO)。
static LINELIST_DOWNLOAD_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
pub async fn execute_task(
client: &Client,
server_url: &str,
runtime: &RuntimePaths,
work_dir: &Path,
result_dir: &Path,
task: &TaskSpec,
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Result<(ModelSummary, Option<Vec<u8>>)> {
info!(
"开始执行计算任务 {} (网格点: {})",
task.task_id, task.point_name
);
// 1. Pull ONLY missing atom model data files needed for this task
let required_atom_files = &[
"h1.dat",
"he1.dat",
"he2.dat",
"c1.dat",
"c2.dat",
"c3_34+12lev.dat",
"c4.dat",
"n1.dat",
"n2_32+10lev.dat",
"n3.dat",
"n4_34+14lev.dat",
"n5.dat",
"o1_23+10lev.dat",
"o2_36+12lev.dat",
"o3_28+13lev.dat",
"o4.dat",
"o5.dat",
];
if let Err(e) =
ensure_specific_data_files(&runtime.data_dir, server_url, client, required_atom_files).await
{
warn!("拉取缺失原子数据文件失败: {}", e);
}
let mut seed_atmos_path: Option<PathBuf> = None;
// 提前创建 per-slot 隔离沙盒目录:种子下载后需复制一份私有副本进沙盒(见下方),
// 故沙盒必须先于种子下载就绪。
let slot_work_dir = work_dir.join(format!("task_{}", task.task_id));
tokio::fs::create_dir_all(&slot_work_dir).await?;
// 2. 种子大气获取(见 docs/task_engine_decoupling_design.md §5):
// - TLUSTY 启用 + 策略为 seed_step:下载近邻种子 .7 作热启动种子(既有逻辑)。
// - TLUSTY 关闭(仅 SYNSPEC 场景):需拉取目标点 .7 大气作光谱合成输入。
// 顺序:本地 result 归档 → server 拉取。
// 注:不查沙盒本地——TLUSTY 与 SYNSPEC 在同一任务内串行,种子获取先于 runner,
// 全新 slot 内不可能已有目标大气;重试任务的 slot 亦全新(task_id 唯一)。
let tlusty_enabled = task.tlusty_config.enabled;
let current_strategy = task.tlusty_config.current_strategy("cold_run");
let needs_seed_download = (tlusty_enabled && current_strategy == "seed_step")
|| (!tlusty_enabled && task.synspec_config.enabled);
if needs_seed_download {
let seed_name = if !tlusty_enabled {
// 仅 SYNSPEC 场景:大气来自目标点本身(atmosphere_ref 或 point_name)。
task.atmosphere_ref
.clone()
.unwrap_or_else(|| task.point_name.clone())
} else {
// SeedStep 热启动:大气来自近邻种子点。
task.seed_point_name.clone().ok_or_else(|| {
anyhow::anyhow!(
"SeedStep 任务 {} 缺少 seed_point_name,无法热启动",
task.point_name
)
})?
};
// 仅 SYNSPEC 场景先查本地 result 归档目录(节点此前算过同点大气,避免 server 拉取)。
if !tlusty_enabled {
let archived = result_dir
.join(&task.point_name)
.join(format!("{}.7", task.point_name));
if archived.is_file() {
info!(
"SYNSPEC-only:在本地 result 归档找到大气 {},复用避免 server 拉取",
archived.display()
);
seed_atmos_path = Some(archived);
}
}
// 归档无 → 向 server 拉取。
if seed_atmos_path.is_none() {
let seed_url = format!("{}/api/seed/{}", server_url, seed_name);
info!(
"正在从服务端下载大气文件 ({}, 用途: {}): {}",
seed_name,
if tlusty_enabled {
"TLUSTY 热启动种子"
} else {
"SYNSPEC 输入大气"
},
seed_url
);
let resp = client.get(&seed_url).send().await.map_err(|e| {
anyhow::anyhow!(
"任务 {} 下载大气文件 {} 失败: {}",
task.point_name,
seed_url,
e
)
})?;
if !resp.status().is_success() {
anyhow::bail!(
"任务 {} 下载大气文件 {} 失败: HTTP {}",
task.point_name,
seed_url,
resp.status()
);
}
let bytes = resp.bytes().await.map_err(|e| {
anyhow::anyhow!(
"任务 {} 读取大气文件 {} 响应体失败: {}",
task.point_name,
seed_url,
e
)
})?;
let temp_seed_dir = work_dir.join(".seed_cache");
tokio::fs::create_dir_all(&temp_seed_dir).await?;
cleanup_seed_cache(&temp_seed_dir).await;
let tmp_path = temp_seed_dir.join(format!(
"{}.{}.tmp",
seed_name,
uuid::Uuid::new_v4().simple()
));
let final_seed_path = temp_seed_dir.join(format!("{}.seed.7", seed_name));
tokio::fs::write(&tmp_path, bytes).await?;
tokio::fs::rename(&tmp_path, &final_seed_path).await?;
let private_seed = slot_work_dir.join("seed_atmos.seed.7");
tokio::fs::copy(&final_seed_path, &private_seed).await?;
seed_atmos_path = Some(private_seed);
}
}
// 3. slot_work_dir 已在种子下载前提前创建,种子私有副本亦已落盘于沙盒内。)
// 反序列化工作流携带的 SYNSPEC 数值参数(波长范围等)。None → runner 用硬编码默认。
let synspec_cfg: Option<SynspecInput> = task
.synspec_params
.as_ref()
.and_then(|v| serde_json::from_value::<SynspecInput>(v.clone()).ok());
// 谱线表覆盖:TaskSpec.linelist 指定时(如 YAML 配 linelist: gfATO.dat),
// 按需从服务端下载到 runtime_dir 根目录,并构造覆盖了 linelist 路径的 RuntimePaths。
// None → 用 node 启动时下载的默认线表(ensure_runtime 的 default_linelist 参数)。
let runtime_override: Option<RuntimePaths> = if let Some(ref ll_name) = task.linelist {
// 线表放在 runtime_dir 根目录(与默认线表同级,runner symlink 为 fort.19)。
let ll_path = runtime
.data_dir
.parent()
.unwrap_or(&runtime.data_dir)
.join(ll_name);
// 互斥保护:多 Slot 并发首次派发时,仅第一个进入临界区的 Slot 执行下载,
// 后续 Slot 在获得锁后 double-check 发现文件已存在直接跳过,避免冗余下载。
{
let _guard = LINELIST_DOWNLOAD_MUTEX.lock().await;
if !ll_path.exists() {
info!("任务指定谱线表 {} 本地缺失,从服务端按需下载...", ll_name);
let url = format!("{}/api/data/file/{}", server_url, ll_name);
let resp = client.get(&url).send().await?;
if resp.status().is_success() {
let bytes = resp.bytes().await?;
let ll_dir = ll_path.parent().unwrap_or(std::path::Path::new("."));
let tmp = ll_dir.join(format!("{}.{}.tmp", ll_name, uuid::Uuid::new_v4().simple()));
tokio::fs::write(&tmp, &bytes).await?;
tokio::fs::rename(&tmp, &ll_path).await?;
info!("成功下载谱线表 {} ({} bytes)", ll_name, bytes.len());
} else {
anyhow::bail!(
"下载任务指定谱线表 {} 失败,HTTP {}",
ll_name,
resp.status()
);
}
}
}
let mut rt = runtime.clone();
rt.linelist = ll_path;
Some(rt)
} else {
None
};
let effective_runtime = runtime_override.as_ref().unwrap_or(runtime);
let runner = ExecutionRunner::new(effective_runtime, slot_work_dir.clone());
// 执行链来源(优先级):
// 1. TaskSpec.tlusty_chain_params(用户在 YAML `tlusty_chain:` 配置的多阶段 ChainStep
// 数组,由 scheduler 序列化注入)——非空时优先使用,使用户能细粒度控制 niter/chmax/
// ilvlin 等阶段参数。
// 2. default_chain_for_strategy(current_strategy) 兜底——按策略名(cold_run/seed_step
// 选预设默认链(runner.rs 的 default_cold_chain / default_seed_chain)。
// 历史:Phase 6 起仅用 default 链(用户 config.chain 被忽略,是死字段);本次接通后
// 用户配置真正生效,default 链降级为兜底。旧 MQ payload(无 tlusty_chain_params 字段)
// 反序列化为 None → 回退 default 链,行为与旧版完全一致(向后兼容)。
let chain = resolve_execution_chain(
current_strategy,
&task.tlusty_chain_params,
&task.seed_chain_params,
&task.task_id.to_string(),
);
// TLUSTY 输入文件全局参数(NFREAD/ions 表/nst extra_keys 等)。
// None → runner 用代码内硬编码默认(向后兼容)。
let tlusty_input =
task.tlusty_input_params.as_ref().and_then(|v| {
match serde_json::from_value::<TlustyInput>(v.clone()) {
Ok(t) => Some(t),
Err(e) => {
warn!(
"任务 {} 的 tlusty_input_params 反序列化失败,回退默认输入: {}",
task.task_id, e
);
None
}
}
});
let summary = runner
.run_model_with_timeout(
&task.params,
// 用权威的 point_nameDB grid_points.name 列,源精度正确)作为模型名,
// 而非 task.params.model_name()(后者经 DB REAL 列回读已丢精度 "5.0"→"5")。
&task.point_name,
current_strategy,
Some(chain),
seed_atmos_path.as_deref(),
synspec_cfg.as_ref(),
// 阶段独立配置开关(见 docs/task_engine_decoupling_design.md §5)。
task.tlusty_config.enabled,
task.synspec_config.enabled,
task.timeout_sec,
shutdown,
tlusty_input.as_ref(),
task.energy_tolerance,
task.temp_max_factor,
task.temp_floor,
task.temp_ceiling,
task.emflux_tolerance,
task.convergence_min_ratio,
task.bfac_max,
task.bfac_min,
)
.await?;
info!(
"完成计算任务 {} (网格点: {}, 结果可用: {})",
task.task_id, task.point_name, summary.result_valid
);
// Read seed bytes if result usable and clean
let mut seed_bytes: Option<Vec<u8>> = None;
if summary.result_valid && !summary.atmosphere_has_nan {
let model_sub_dir = slot_work_dir.join(&summary.name);
let candidates = [
model_sub_dir.join(format!("{}.7", summary.name)),
model_sub_dir.join(format!("{}.nl.7", summary.name)),
model_sub_dir.join(format!("{}.nc.7", summary.name)),
model_sub_dir.join("fort.7"),
slot_work_dir.join(format!("{}.7", summary.name)),
];
for cand in &candidates {
if cand.is_file() {
if let Ok(bytes) = tokio::fs::read(cand).await {
info!(
"找到网格点 {} 的种子二进制文件: {}",
summary.name,
cand.display()
);
seed_bytes = Some(bytes);
break;
}
}
}
}
info!(
"任务 {} 计算完成,沙盒目录: {}",
task.task_id,
slot_work_dir.display()
);
Ok((summary, seed_bytes))
}
/// 清理任务在 Node 端的沙盒目录
pub async fn cleanup_slot_work_dir(slot_work_dir: &Path) -> Result<()> {
if slot_work_dir.exists() {
tokio::fs::remove_dir_all(slot_work_dir).await?;
info!("已清理 Node 端沙盒目录: {}", slot_work_dir.display());
}
Ok(())
}
/// 把单个任务沙盒内的产物拷贝到持久归档目录。
///
/// 采用**白名单**策略([`is_result_worthy`])而非「拷贝所有普通文件」的 catch-all:
/// 只保留有语义价值的产物,丢弃 Tlusty/Synspec 运行时产生的中间工作单元
/// `fort.1/2/3/13/14/18/22/42/44/50/57/69/82/95` 等,旧版 catch-all 会把它们
/// 一并搬进归档,每个模型浪费约 2MB / 4.8MB)。
///
/// 保留内容(详见 [`is_result_worthy`]):
/// - 裸名:`conv.json`、`fort.8`synspec 输入大气)、`fort.55`synspec 控制卡)
/// - 科学核心:`<name>.7/.spec/.cont/.iden/.log`,以及 runner 在 SYNSPEC 覆盖前快照的
/// TLUSTY 最终产物:`<name>.bfac`b 因子/非 LTE 偏离因子)、`<name>.emflux`(出射谱 λ–Fλ)
/// - 阶段快照:`<name>.<label>.5/.6/.err/.nst/.7`
/// - 收敛诊断:`<name>.<label>_chmax*.9`**唯一保留的 .9**
///
/// 跳过内容:符号链接(`data`/`fort.19` 等共享 runtime 资源)、子目录、`.tmp`、`fort.84`、
/// 所有 Tlusty 中间单元、以及不以 `<name>.` 为前缀的无语义裸文件。
///
/// 任何 IO 错误均降级为 warn,不阻断上报/清理主流程(归档是尽力而为)。
///
/// `name` 为网格点权威名:取自 summary.namerunner 现用 task.point_name 作权威名),
/// 严重失败(runner 抛 Err、无 summary)时回退到 task.point_name,确保失败任务的
/// 排错日志也能落盘。
pub async fn save_result_artifacts(result_dir: &Path, slot_work_dir: &Path, name: &str) {
let src_dir = slot_work_dir.join(name);
if !src_dir.is_dir() {
// 模型子目录不存在(极早期失败),无可归档内容
return;
}
let dest_dir = result_dir.join(name);
if let Err(e) = tokio::fs::create_dir_all(&dest_dir).await {
warn!(
"归档网格点 {} 失败:创建归档目录 {} 失败: {}",
name,
dest_dir.display(),
e
);
return;
}
let mut kept = 0usize;
let mut skipped = 0usize;
let mut skipped_link = 0usize;
let mut rd = match tokio::fs::read_dir(&src_dir).await {
Ok(rd) => rd,
Err(e) => {
warn!(
"归档网格点 {} 失败:读取源目录 {} 失败: {}",
name,
src_dir.display(),
e
);
return;
}
};
while let Ok(Some(entry)) = rd.next_entry().await {
let path = entry.path();
let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
// 跳过符号链接(指向共享 runtime 资源,不归档)
if tokio::fs::symlink_metadata(&path)
.await
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
skipped_link += 1;
continue;
}
// 只归档普通文件(跳过意外的子目录)
if !path.is_file() {
continue;
}
// 白名单判别:只保留有语义价值的产物,丢弃 Tlusty 中间单元
if !is_result_worthy(&file_name, name) {
skipped += 1;
continue;
}
let dest_path = dest_dir.join(&file_name);
// 原子写入:先拷到 .result.tmp.<uuid> 再 rename,防止中途崩溃产生半截文件
let tmp_path = dest_dir.join(format!(
"{}.result.tmp.{}",
file_name,
uuid::Uuid::new_v4().simple()
));
match tokio::fs::copy(&path, &tmp_path).await {
Ok(_) => {
if let Err(e) = tokio::fs::rename(&tmp_path, &dest_path).await {
// rename 失败则清理 tmp,避免残留
let _ = tokio::fs::remove_file(&tmp_path).await;
warn!(
"归档网格点 {} 的文件 {} rename 失败: {}",
name, file_name, e
);
continue;
}
kept += 1;
}
Err(e) => {
let _ = tokio::fs::remove_file(&tmp_path).await;
warn!("归档网格点 {} 的文件 {} 拷贝失败: {}", name, file_name, e);
}
}
}
info!(
"已归档网格点 {} 的产物:保留 {} 个文件到 {}(跳过 {} 个非白名单文件、{} 个符号链接)",
name,
kept,
dest_dir.display(),
skipped,
skipped_link
);
}
// 归档目录不做数量上限治理:所有已算网格点的完整产物(.spec/.cont/.iden/各阶段
// 快照/日志/种子二进制等)一律永久保留,避免 LRU 淘汰导致科学产物丢失
// 2026-08-02 修正:撤销 1bfa240 引入的 MAX_RESULT_MODELS=200 LRU 上限)。
/// `.seed_cache/` 内保留的 `.seed.7` 文件上限。超过则按 mtime 删除最旧的。
///
/// 审查修复 #N5:上限可经环境变量 `DCTS_SEED_CACHE_MAX` 覆盖(默认 8)。多工作流或密集
/// 网格下常用邻域种子可能超过 8 个,硬编码上限会导致反复从 server 下载,增加负载。
fn seed_cache_max_files() -> usize {
std::env::var("DCTS_SEED_CACHE_MAX")
.ok()
.and_then(|v| v.parse().ok())
.filter(|n: &usize| *n > 0)
.unwrap_or(8)
}
/// LRU 清理种子缓存目录:当 `.seed.7` 文件数超过上限(`DCTS_SEED_CACHE_MAX`,默认 8)时,
/// 按 mtime 升序删除最旧的若干个,直到不超过上限。仅统计 `.seed.7`,忽略 `.tmp` 中间文件。
/// 任何 IO 错误均降级为 warn,不阻断主流程。
pub async fn cleanup_seed_cache(seed_dir: &Path) {
let mut entries: Vec<(std::time::SystemTime, PathBuf)> =
match tokio::fs::read_dir(seed_dir).await {
Ok(mut rd) => {
let mut v = Vec::new();
while let Ok(Some(entry)) = rd.next_entry().await {
let path = entry.path();
// 仅纳入 .seed.7 文件(最终产物),跳过 .tmp 中间文件
if path.extension().and_then(|e| e.to_str()) != Some("7") {
continue;
}
let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if !file_name.ends_with(".seed.7") {
continue;
}
let meta = match entry.metadata().await {
Ok(m) => m,
Err(_) => continue,
};
let mtime = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
v.push((mtime, path));
}
v
}
Err(_) => return,
};
let max_files = seed_cache_max_files();
if entries.len() <= max_files {
return;
}
// 按 mtime 升序(最旧在前),删除超出上限的最旧文件
entries.sort_by_key(|(mtime, _)| *mtime);
let to_remove = entries.len().saturating_sub(max_files);
for (_, path) in entries.into_iter().take(to_remove) {
match tokio::fs::remove_file(&path).await {
Ok(()) => info!("LRU 清理种子缓存文件: {}", path.display()),
// 文件已被并发删除(多 slot 同时清理同一批最旧文件):清理目标已达成,
// 视为成功,不再误报 warn。其他真实 IO 错误才需要告警。
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
debug!("种子缓存文件已被并发删除: {}", path.display())
}
Err(e) => warn!("清理种子缓存文件 {} 失败: {}", path.display(), e),
}
}
}
/// 解析任务要执行的大气链(冷启动链 / 种子热启动链)。
///
/// 优先级:
/// - `current_strategy == "seed_step"` → 优先 TaskSpec.seed_chain_params(用户 YAML
/// `seed_chain:` 配置),非空即用;为空/反序列化失败 → `default_seed_chain()` 兜底。
/// 不复用 `tlusty_chain`:冷启动链首步 lte 的 `ltgray=T` 会删除 fort.8、丢弃已下载的
/// 热启动种子(runner.rs 阶段 fort.8 准备逻辑)。seed_chain 专用于种子热启动,
/// 首步 seed_nc 的 `ltgray=F` 保留 fort.8 种子。
/// - 其余策略 → 优先 TaskSpec.tlusty_chain_params(用户 YAML `tlusty_chain:` 配置),非空即用;
/// 为空/反序列化失败 → `default_chain_for_strategy(current_strategy)` 兜底。
fn resolve_execution_chain(
current_strategy: &str,
tlusty_chain_params: &Option<serde_json::Value>,
seed_chain_params: &Option<serde_json::Value>,
task_id: &str,
) -> Vec<ChainStep> {
if current_strategy == "seed_step" {
seed_chain_params
.as_ref()
.and_then(|v| match serde_json::from_value::<Vec<ChainStep>>(v.clone()) {
Ok(c) => Some(c),
Err(e) => {
warn!(
"任务 {} 的 seed_chain_params 反序列化失败,回退 default_seed_chain: {}",
task_id, e
);
None
}
})
.filter(|c| !c.is_empty())
.unwrap_or_else(common::runner::default_seed_chain)
} else {
tlusty_chain_params
.as_ref()
.and_then(
|v| match serde_json::from_value::<Vec<ChainStep>>(v.clone()) {
Ok(c) => Some(c),
Err(e) => {
warn!(
"任务 {} 的 tlusty_chain_params 反序列化失败,回退 default 链: {}",
task_id, e
);
None
}
},
)
.filter(|c| !c.is_empty())
.unwrap_or_else(|| common::runner::default_chain_for_strategy(current_strategy))
}
}
#[cfg(test)]
mod tests {
use super::*;
use common::models::{GridPointParams, ModelSummary};
fn make_summary() -> ModelSummary {
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(),
};
ModelSummary {
name: params.model_name(),
params,
stages: vec![],
result_valid: true,
final_max_relc: Some(0.0005),
final_chmax: None,
seed: None,
atmosphere_has_nan: false,
synspec_rc: Some(0),
synspec_error: None,
synspec_sec: Some(10.0),
elapsed_sec: 120.0,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
}
}
fn labels(chain: &[ChainStep]) -> Vec<String> {
chain.iter().map(|s| s.label.clone()).collect()
}
/// P1 回归防护:seed_step 忽略 tlusty_chain_params(冷启动链),只认 seed_chain_params。
/// 否则首步 lte(ltgray=T) 会删 fort.8、丢弃已下载种子,回退退化成本地冷启动。
#[test]
fn seed_step_ignores_custom_cold_chain() {
let custom_cold = serde_json::json!([
{"label": "lte", "lte": "T", "ltgray": "T", "ilvlin": 0, "niter": 0},
{"label": "nc", "lte": "F", "ltgray": "F", "ilvlin": 0, "niter": 10},
{"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100},
]);
// seed_chain_params=None → 走 default_seed_chaintlusty_chain_params 被忽略。
let chain = resolve_execution_chain("seed_step", &Some(custom_cold), &None, "t1");
assert_eq!(labels(&chain), vec!["seed_nc", "nl"]);
// 首步必须是非灰 LTEltgray=F),否则会删 fort.8 丢弃种子。
assert_eq!(chain[0].ltgray, "F");
}
/// seed_step 使用 seed_chain_params(用户 YAML seed_chain 配置)时生效。
#[test]
fn seed_step_uses_custom_seed_chain_when_provided() {
let custom_seed = serde_json::json!([
{"label": "seed_nc", "lte": "F", "ltgray": "F", "ilvlin": 0, "niter": 20, "orelax": 0.3},
{"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100, "orelax": 0.5},
]);
let chain = resolve_execution_chain("seed_step", &None, &Some(custom_seed), "t1b");
assert_eq!(labels(&chain), vec!["seed_nc", "nl"]);
// 用户配置的 orelax 生效。
assert_eq!(chain[0].orelax, Some(0.3));
assert_eq!(chain[1].orelax, Some(0.5));
}
#[test]
fn cold_run_uses_custom_chain_when_provided() {
let custom = serde_json::json!([
{"label": "lte", "lte": "T", "ltgray": "T", "ilvlin": 0, "niter": 0},
{"label": "nc", "lte": "F", "ltgray": "F", "ilvlin": 0, "niter": 10},
{"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100},
]);
let chain = resolve_execution_chain("cold_run", &Some(custom), &None, "t2");
assert_eq!(labels(&chain), vec!["lte", "nc", "nl"]);
}
#[test]
fn cold_run_falls_back_to_default_when_no_custom_chain() {
let chain = resolve_execution_chain("cold_run", &None, &None, "t3");
assert_eq!(labels(&chain), vec!["lte", "nc", "nl"]);
}
#[tokio::test]
async fn test_cleanup_slot_work_dir() {
let temp_dir =
std::env::temp_dir().join(format!("test_slot_work_dir_{}", uuid::Uuid::new_v4()));
tokio::fs::create_dir_all(&temp_dir).await.unwrap();
tokio::fs::write(temp_dir.join("dummy.txt"), "content")
.await
.unwrap();
assert!(temp_dir.exists());
cleanup_slot_work_dir(&temp_dir).await.unwrap();
assert!(!temp_dir.exists());
}
/// 验证归档:完整产物被拷贝、符号链接/fort.84/.tmp 被跳过
#[tokio::test]
async fn test_save_result_artifacts() {
let root = std::env::temp_dir().join(format!("test_result_{}", uuid::Uuid::new_v4()));
let result_dir = root.join("result");
let summary = make_summary();
let slot_work_dir = root.join("work");
let model_dir = slot_work_dir.join(&summary.name);
tokio::fs::create_dir_all(&model_dir).await.unwrap();
// 应被归档的白名单产物(科学核心 + 阶段快照 + 收敛诊断 + 裸名保留)
let kept_files = [
format!("{}.7", summary.name), // 最终大气
format!("{}.spec", summary.name), // 合成光谱
format!("{}.cont", summary.name), // 连续谱
format!("{}.iden", summary.name), // 谱线证认
format!("{}.log", summary.name), // synspec 日志
format!("{}.bfac", summary.name), // TLUSTY 最终 b 因子(快照 fort.12
format!("{}.emflux", summary.name), // TLUSTY 最终出射谱(快照 fort.14
"conv.json".to_string(), // 摘要
"fort.8".to_string(), // synspec 输入大气(裸名保留)
"fort.55".to_string(), // synspec 控制卡(裸名保留)
format!("{}.nl.7", summary.name), // nl 阶段大气快照
format!("{}.nc.7", summary.name), // nc 阶段大气快照
format!("{}.nl.5", summary.name), // nl 阶段输入卡快照
format!("{}.nl.6", summary.name), // nl 阶段输出日志快照
format!("{}.nl.err", summary.name), // nl 阶段错误日志快照
format!("{}.nl.nst", summary.name), // nl 阶段控制卡快照
format!("{}.nc.nst", summary.name), // nc 阶段控制卡快照
format!("{}.nl_chmax0.001.9", summary.name), // nl 收敛诊断(唯一保留的 .9)
];
// 应被白名单过滤掉的文件:Tlusty 中间单元、裸的 runner 已清理文件、
// 无 _chmax 的重复 .9 快照、未知后缀
let skipped_files: [String; 14] = [
"fort.1".to_string(), // 空单元
"fort.13".to_string(), // Tlusty 出射辐射场 (FREQ/FLUX/FH),未快照,丢弃
"fort.18".to_string(), // Tlusty 大气结构内部表
"fort.22".to_string(), // Tlusty 中间大气副本
"fort.82".to_string(), // Tlusty 运行时诊断表
"fort.95".to_string(), // Tlusty 旧模型定义副本
"fort.84".to_string(), // NATOMS 崩溃缓存
"residue.tmp".to_string(), // 原子写入残留
"nst".to_string(), // 裸 nstrunner 已改名为 <name>.<label>.nst
"fort.9".to_string(), // 裸 fort.9runner 已删,内容在 _chmax.9
"fort.12".to_string(), // 裸 fort.12(已 copy 为 .iden
"fort.17".to_string(), // 裸 fort.17(已 copy 为 .cont
format!("{}.nl.9", summary.name), // 无 _chmax 的 .9 快照(与 _chmax.9 重复)
format!("{}.unknown", summary.name), // 未知后缀
];
for f in kept_files.iter() {
tokio::fs::write(model_dir.join(f), "payload")
.await
.unwrap();
}
for f in skipped_files.iter() {
tokio::fs::write(model_dir.join(f), "payload")
.await
.unwrap();
}
// 符号链接(指向共享资源,应被跳过)
#[cfg(unix)]
{
let link_target = root.join("shared_data");
tokio::fs::create_dir_all(&link_target).await.unwrap();
std::os::unix::fs::symlink(&link_target, model_dir.join("data")).unwrap();
std::os::unix::fs::symlink("/dev/null", model_dir.join("fort.19")).unwrap();
}
save_result_artifacts(&result_dir, &slot_work_dir, &summary.name).await;
let dest_dir = result_dir.join(&summary.name);
assert!(dest_dir.is_dir(), "归档目标目录应被创建");
// 验证白名单产物都被拷贝
for f in &kept_files {
assert!(dest_dir.join(f).is_file(), "白名单产物 {} 应被归档", f);
}
// 验证非白名单文件未进归档
for f in &skipped_files {
assert!(!dest_dir.join(f).exists(), "非白名单文件 {} 应被跳过", f);
}
#[cfg(unix)]
{
assert!(!dest_dir.join("data").exists(), "符号链接 data 应被跳过");
assert!(
!dest_dir.join("fort.19").exists(),
"符号链接 fort.19 应被跳过"
);
}
// 不应有残留的 .result.tmp 文件
let mut rd = tokio::fs::read_dir(&dest_dir).await.unwrap();
while let Ok(Some(e)) = rd.next_entry().await {
let name = e.file_name().to_string_lossy().to_string();
assert!(!name.contains(".result.tmp"), "不应残留 tmp 文件: {}", name);
}
let _ = tokio::fs::remove_dir_all(&root).await;
}
}