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 篇根因分析/验证文档
This commit is contained in:
fmq
2026-08-17 23:55:26 +08:00
parent 43b82b1ae2
commit b058e66722
54 changed files with 5624 additions and 347 deletions
+724 -188
View File
@@ -254,6 +254,240 @@ impl<'a> ExecutionRunner<'a> {
.await
}
/// 执行收敛链中的单个 TLUSTY 阶段(2026-08-14 从 run_model_with_timeout 的
/// 阶段循环体抽取,供主链与 nl_direct 回退共用)。
///
/// 职责:写 .5/nst 输入 → 按 seed 铺 fort.8 → 运行 tlusty → fort.9 收敛诊断
/// (含假收敛排查与 STOP 留言提取)→ 快照阶段产物(.5/.6/.err/nst/_chmax*.9)。
///
/// 返回 `(StepSummary, 产出的阶段种子路径)`rc==0 且 fort.7 存在时,
/// fort.7 被快照为 `<name>.<label>.7` 并作为返回种子路径(供下一阶段 fort.8);
/// 否则返回 None(调用方保留上一阶段种子)。
#[allow(clippy::too_many_arguments)]
async fn execute_tlusty_stage(
&self,
model_dir: &std::path::Path,
name: &str,
params: &GridPointParams,
stage_def: &ChainStep,
input_cfg: &TlustyInput,
seed: Option<&std::path::Path>,
convergence_min_ratio: Option<f64>,
timeout_sec: u64,
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Result<(StepSummary, Option<std::path::PathBuf>)> {
let stage_t0 = Instant::now();
let input5_text = make_input5(params, stage_def, input_cfg);
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, input_cfg);
tokio::fs::write(model_dir.join("nst"), &nst_text).await?;
// Prepare fort.8 for this stage
let fort8 = model_dir.join("fort.8");
if stage_def.ltgray == "T" {
if fort8.exists() {
let _ = tokio::fs::remove_file(&fort8).await;
}
} else if let Some(s_path) = 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).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(),
conv_trace_check: None,
};
// 产出的阶段种子:rc==0 且 fort.7 存在时快照为阶段种子文件并返回路径。
let mut produced_seed: Option<std::path::PathBuf> = None;
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.clone();
// 假收敛排查(§2.1/§3.1):仅对 converged=true 的 stage 做。
// Ng/Kantorovich 加速可压低 max_relc 造成数值达标但平衡未达成。
if res.converged {
if let Some(min_ratio) = convergence_min_ratio {
if let Some(tc) = check_convergence_trace(&res.itek_history, min_ratio) {
if !tc.valid {
stage_summary.converged = false;
stage_summary.note = Some(format!(
"未收敛 [{}]",
tc.error.as_deref().unwrap_or("假收敛排查失败")
));
}
stage_summary.conv_trace_check = Some(tc);
}
}
}
// 漏洞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
),
});
}
}
// NaN 伪收敛防护:TLUSTY 从 NaN 污染种子启动时会立即崩溃,fort.9 写出
// 全 0.00E+00(NaN 参与的相对变化算不出,写出零),max_relc=0 < chmax
// 会被误判收敛(生产实测 261 个点因此跳过 nl_direct 回退直接进门槛失败)。
// 阶段级防线:converged=true 时复查本阶段 fort.7,含 NaN/Inf/溢出即否决,
// 使 require_converged 中止逻辑与 nl_direct 回退得以触发;且不产出污染种子。
if stage_summary.converged && atmosphere_has_nan(&fort7) {
warn!(
"阶段 {} fort.9 达标但 fort.7 含 NaN/Inf/溢出,判未收敛(NaN 伪收敛防护)",
stage_def.label
);
stage_summary.converged = false;
stage_summary.note = Some(
"未收敛 [阶段大气含 NaN/Inf/溢出(NaN 伪收敛防护:fort.9 全零假达标)]"
.to_string(),
);
}
// 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;
if stage_summary.converged {
produced_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/nl_direct),
// 不会与 synspec 产物冲突。
//
// 不快照 fort.9:上方已把 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;
}
}
Ok((stage_summary, produced_seed))
}
/// 阶段独立配置执行入口(见 docs/task_engine_decoupling_design.md §5)。
///
/// `tlusty_enabled` / `synspec_enabled` 控制各阶段是否运行:
@@ -362,196 +596,26 @@ impl<'a> ExecutionRunner<'a> {
// 默认值定义集中在 config.rs 的 TlustyInput::default(),此处不再重复维护。
let default_input = TlustyInput::default();
let input_cfg: &TlustyInput = tlusty_input.unwrap_or(&default_input);
for stage_def in &chain {
let mut failed_stage_idx: Option<usize> = None;
for (stage_idx, stage_def) in chain.iter().enumerate() {
if tlusty_skipped {
break;
}
let stage_t0 = Instant::now();
let input5_text = make_input5(params, stage_def, input_cfg);
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, input_cfg);
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(),
conv_trace_check: None,
};
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.clone();
// 假收敛排查(§2.1/§3.1):仅对 converged=true 的 stage 做。
// Ng/Kantorovich 加速可压低 max_relc 造成数值达标但平衡未达成。
if res.converged {
if let Some(min_ratio) = convergence_min_ratio {
if let Some(tc) =
check_convergence_trace(&res.itek_history, min_ratio)
{
if !tc.valid {
stage_summary.converged = false;
stage_summary.note = Some(format!(
"未收敛 [{}]",
tc.error.as_deref().unwrap_or("假收敛排查失败")
));
}
stage_summary.conv_trace_check = Some(tc);
}
}
}
// 漏洞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;
}
let (stage_summary, produced_seed) = self
.execute_tlusty_stage(
&model_dir,
name,
params,
stage_def,
input_cfg,
current_seed.as_deref(),
convergence_min_ratio,
timeout_sec,
shutdown.clone(),
)
.await?;
if let Some(p) = produced_seed {
current_seed = Some(p);
}
final_chmax = stage_def.chmax;
@@ -566,10 +630,72 @@ impl<'a> ExecutionRunner<'a> {
"阶段 {} 要求收敛但未达标,中止后续收敛链阶段",
stage_def.label
);
failed_stage_idx = Some(stage_idx);
break;
}
}
// ── nl_direct 回退(2026-08-14)──
// 背景:种子链 seed_nc→nl 中,seed_nc 发散(Kantorovich 雪崩)时其被污染的
// fort.7 会被无条件传给 nl 并毒死 nl;而 nl 自身从原始干净种子出发通常能
// 直接收敛(实测 5 个最难失败点中 3 个仅靠此回退恢复,另 2 个经
// seed_nc 预热后收敛——两条路径失败集互补,故仅作回退、不替换主链)。
// 详见 docs/tlusty_coldstart_nc_trace_illcond_2026_08_13.md。
// 触发条件:require_converged 阶段失败 + 失败阶段非链首(存在中间阶段
// 污染可能)+ 存在原始种子文件。回退以 `<label>_direct` 作为独立阶段
// 记录进 conv.json,成功则采纳其收敛结果。
if tlusty_enabled && !final_converged {
if let Some(idx) = failed_stage_idx {
if idx > 0 {
if let Some(orig_seed) = seed_atmos {
if orig_seed.is_file() {
let failed_label = chain[idx].label.clone();
info!(
"阶段 {} 未收敛:尝试 nl_direct 回退(用原始种子直接重跑该阶段)",
failed_label
);
let mut direct_stage = chain[idx].clone();
direct_stage.label = format!("{}_direct", failed_label);
let (mut direct_summary, direct_seed) = self
.execute_tlusty_stage(
&model_dir,
name,
params,
&direct_stage,
input_cfg,
Some(orig_seed),
convergence_min_ratio,
timeout_sec,
shutdown.clone(),
)
.await?;
if direct_summary.converged {
info!("nl_direct 回退成功:{} 自原始种子直接收敛", failed_label);
final_converged = true;
final_chmax = direct_stage.chmax;
if let Some(r) = direct_summary.best_max_relc {
final_max_relc = Some(r);
}
if let Some(p) = direct_seed {
current_seed = Some(p);
}
if direct_summary.note.is_none() {
direct_summary.note = Some(
"nl_direct 回退收敛(跳过被污染的中间阶段,用原始种子直接求解)"
.to_string(),
);
}
} else if direct_summary.note.is_none() {
direct_summary.note =
Some("nl_direct 回退未收敛".to_string());
}
stage_summaries.push(direct_summary);
}
}
}
}
}
// Final atmosphere file .7
let final_7 = model_dir.join(format!("{}.7", name));
if let Some(ref s_path) = current_seed {
@@ -604,7 +730,7 @@ impl<'a> ExecutionRunner<'a> {
// `(RAD+CON)/TOT` 列。任一深度偏离 1 超阈值即判失败(与 atmosphere_has_nan 同级)。
// 此时 `<name>.6` 仍存在(行 688 的冗余清理在此之后),内容是最后阶段日志。
// energy_tolerance=None → 跳过(非常规配置);`.6` 无能量守恒表 → 函数返回 None 跳过。
let energy_check = if tlusty_enabled {
let mut energy_check = if tlusty_enabled {
energy_tolerance.and_then(|tol| {
let fort6 = model_dir.join(format!("{}.6", name));
check_energy_conservation(&fort6, tol)
@@ -620,6 +746,75 @@ impl<'a> ExecutionRunner<'a> {
}
}
// ── nl_tight 回退(2026-08-17,能量边际专用)──
// 背景:7 个生产失败点的唯一失败项是最深层(ID=ND)能量残差 1.0–2.3% 超
// 1% 阈值,其余门槛(emflux/温度/bfac)全过。此类残差源于 CHMAX=1e-3 停机
// 时统计平衡未完全达成。实测(test/20260817_failed400/mg_tight):从自身
// 最终模型续迭代、CHMAX 收紧 10×(1e-4),~19 次迭代后残差降至 0.0010.002
// (降幅 ~10×)。触发条件:能量门槛失败 + 最终大气存在且无 NaN + 数值收敛
// (非发散模型)。回退以 `<label>_tight` 记录进 conv.json,成功则刷新最终
// 大气与能量判定,并重快照 fort.12/14bfac/emflux 检查读快照文件)。
if tlusty_enabled && energy_failed && !atmo_has_nan && final_7.is_file() {
if let Some(last_stage) = chain.last() {
let tight_chmax = last_stage.chmax.map(|c| c / 10.0);
let mut tight_stage = last_stage.clone();
tight_stage.label = format!("{}_tight", last_stage.label);
tight_stage.chmax = tight_chmax;
info!(
"能量边际回退:以 CHMAX={} 从自身最终模型续迭代 {}",
tight_chmax.map(|c| c.to_string()).unwrap_or_default(),
tight_stage.label
);
let tight_seed = final_7.clone();
let (mut tight_summary, tight_produced) = self
.execute_tlusty_stage(
&model_dir,
name,
params,
&tight_stage,
input_cfg,
Some(&tight_seed),
convergence_min_ratio,
timeout_sec,
shutdown.clone(),
)
.await?;
let tight_ok = tight_summary.converged;
if tight_ok {
tight_summary.note = Some(
"nl_tight 回退收敛(能量边际:CHMAX 收紧 10× 从自身模型续迭代)"
.to_string(),
);
if let Some(p) = tight_produced {
// 刷新最终大气 + 出射谱快照(后续 bfac/emflux 门槛读快照文件)
let _ = tokio::fs::copy(&p, &final_7).await;
snapshot_tlusty_outputs(&model_dir, name).await;
// 重新判定能量守恒:tight 阶段的 stdout 直接写 <name>.6
//(每阶段覆盖),此处读到的是 tight 阶段的能量表。
if let Some(tol) = energy_tolerance {
let fort6 = model_dir.join(format!("{}.6", name));
if fort6.is_file() {
energy_check = check_energy_conservation(&fort6, tol);
}
}
energy_failed = matches!(&energy_check, Some(ec) if !ec.valid);
if !energy_failed {
final_converged = true;
final_chmax = tight_stage.chmax;
if let Some(r) = tight_summary.best_max_relc {
final_max_relc = Some(r);
}
info!("nl_tight 回退成功:能量守恒达标");
}
}
} else {
tight_summary.note =
Some("nl_tight 回退未收敛".to_string());
}
stage_summaries.push(tight_summary);
}
}
// 温度结构边界校验(docs/spectrum_correctness_analysis.md §3.1/§4):
// 解析 `.7`(最终大气)逐深度温度 T,表层 >max_factor×Teff 或全层越界判失败。
// 读 final_7(此时已就位);tlusty_enabled=false 时 final_7 来自外部种子,跳过。
@@ -1042,4 +1237,345 @@ mod tests {
assert!(!model_dir.join(format!("{}.bfac", name)).exists());
assert!(!model_dir.join(format!("{}.emflux", name)).exists());
}
/// 快速单元回归(2026-08-14):nl_direct 回退接线。用假 tlusty 脚本模拟
/// 「seed_nc 发散 → nl 失败 → 回退 nl_direct 收敛」三步,验证:
/// 1) nl 失败后触发 <label>_direct 阶段并用原始种子重跑;
/// 2) 回退收敛被采纳为最终结果(final 阶段链含 nl_direct 且 converged)。
#[tokio::test]
async fn unit_nl_direct_fallback_wiring() {
let work = std::env::temp_dir().join(format!("dcts_unit_fallback_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&work);
tokio::fs::create_dir_all(&work).await.unwrap();
// 假 tlusty:按调用序号产出 fort.9 —— #1(seed_nc) 发散、#2(nl) 失败、
// #3(nl_direct) 收敛。fort.7 写哑内容(无 NaN 字样)。
let fake = work.join("fake_tlusty.sh");
let script = r#"#!/usr/bin/env bash
n=$(cat ./call_count 2>/dev/null || echo 0); n=$((n+1)); echo $n > ./call_count
cat /dev/stdin > /dev/null
echo "fake model" > fort.7
case $n in
1|2) printf ' 1 50 1.0E-02 1.0E-02 1.0E-02 1.0E-02 1.0E+16 75 1\n' > fort.9 ;;
3) printf ' 1 50 1.0E-02 1.0E-02 1.0E-02 1.0E-02 1.0E-05 75 1\n' > fort.9 ;;
esac
exit 0
"#;
tokio::fs::write(&fake, script).await.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let runtime = crate::embedded::RuntimePaths {
tlusty_exe: fake.clone(),
synspec_exe: work.join("synspec_absent"),
data_dir: work.clone(),
linelist: work.join("linelist_absent"),
};
// 原始种子:普通文件存在即可(fake 不读内容)
let seed = work.join("orig_seed.7");
tokio::fs::write(&seed, "orig clean seed").await.unwrap();
let runner = super::ExecutionRunner::new(&runtime, work.join("models"));
let params = crate::models::GridPointParams {
teff: crate::models::GridAxisValue::from_value(60000.0),
logg: crate::models::GridAxisValue::from_value(5.5),
loghe: crate::models::GridAxisValue::from_value(-2.0),
logc: crate::models::GridAxisValue::from_value(-4.0),
logn: crate::models::GridAxisValue::from_value(-4.0),
logo: crate::models::GridAxisValue::from_value(-4.0),
};
let summary = runner
.run_model_with_timeout(
&params,
"t60000_g5.5_he-2_c-4_n-4_o-4",
"seed_step",
Some(super::default_seed_chain()),
Some(&seed),
None,
true,
false,
60,
None,
None, None, None, None, None, None, None, None, None,
)
.await
.unwrap();
let labels: Vec<(String, bool)> = summary
.stages
.iter()
.map(|s| (s.label.clone(), s.converged))
.collect();
assert_eq!(
labels,
vec![
("seed_nc".to_string(), false),
("nl".to_string(), false),
("nl_direct".to_string(), true),
],
"应触发 nl_direct 回退并采纳其收敛"
);
let _ = std::fs::remove_dir_all(&work);
}
/// nl_tight 回退接线回归(2026-08-17):收敛模型但能量边际失败(最深层残差
/// 2% > 1%)→ 应以 CHMAX 收紧 10× 从自身模型续迭代,回退后能量达标。
#[tokio::test]
async fn unit_nl_tight_fallback_wiring() {
let work =
std::env::temp_dir().join(format!("dcts_unit_nltight_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&work);
tokio::fs::create_dir_all(&work).await.unwrap();
// 假 tlusty#1(nl) 数值收敛但能量表残差 0.02#2(nl_tight) 残差 0.005。
let fake = work.join("fake_tlusty.sh");
let script = r#"#!/usr/bin/env bash
n=$(cat ./call_count 2>/dev/null || echo 0); n=$((n+1)); echo $n > ./call_count
cat /dev/stdin > /dev/null
echo "clean model" > fort.7
printf ' 1 50 1.0E-02 1.0E-02 1.0E-02 1.0E-02 1.0E-05 75 1\n' > fort.9
case $n in
1) printf ' depth TOT (RAD+CON)/TOT\n 1 1.00\n 50 1.02\n' ;;
2) printf ' depth TOT (RAD+CON)/TOT\n 1 1.00\n 50 1.005\n' ;;
esac
exit 0
"#;
tokio::fs::write(&fake, script).await.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let runtime = crate::embedded::RuntimePaths {
tlusty_exe: fake.clone(),
synspec_exe: work.join("synspec_absent"),
data_dir: work.clone(),
linelist: work.join("linelist_absent"),
};
let seed = work.join("orig_seed.7");
tokio::fs::write(&seed, "orig clean seed").await.unwrap();
let runner = super::ExecutionRunner::new(&runtime, work.join("models"));
let params = crate::models::GridPointParams {
teff: crate::models::GridAxisValue::from_value(60000.0),
logg: crate::models::GridAxisValue::from_value(5.0),
loghe: crate::models::GridAxisValue::from_value(-2.0),
logc: crate::models::GridAxisValue::from_value(-2.0),
logn: crate::models::GridAxisValue::from_value(-4.0),
logo: crate::models::GridAxisValue::from_value(-3.0),
};
// 单阶段 nl 链
let chain = vec![super::ChainStep {
label: "nl".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
niter: 100,
chmax: Some(0.001),
orelax: Some(0.5),
ilvlin: 100,
require_converged: true,
itek: None,
ichang: None,
idlte: None,
iacc: None,
}];
let summary = runner
.run_model_with_timeout(
&params,
"t60000_g5.0_he-2_c-2_n-4_o-3",
"cold_run",
Some(chain),
Some(&seed),
None,
true,
false,
60,
None,
None, // tlusty_input
Some(0.01), // energy_tolerance
None, None, None, None, None, None, None,
)
.await
.unwrap();
let labels: Vec<(String, bool)> = summary
.stages
.iter()
.map(|s| (s.label.clone(), s.converged))
.collect();
assert_eq!(
labels,
vec![
("nl".to_string(), true),
("nl_tight".to_string(), true),
],
"能量边际应触发 nl_tight 回退并收敛(阶段: {:?}",
labels
);
let ec = summary.energy_check.as_ref().expect("应有能量检查结果");
assert!(ec.valid, "回退后能量应达标: {:?}", ec.error);
let _ = std::fs::remove_dir_all(&work);
}
/// NaN 伪收敛防护回归(2026-08-17):TLUSTY 从 NaN 污染种子启动会立即崩溃,
/// fort.9 全 0.00E+00 假达标(max_relc=0 < chmax)——生产实测 261 个点因此
/// 被误判 nl 收敛、跳过 nl_direct 回退。防护后:fort.7 含 NaN 即否决 converged
/// 且不产出污染种子(下一阶段回退用原始种子)。
#[tokio::test]
async fn unit_nan_pseudo_convergence_veto() {
let work =
std::env::temp_dir().join(format!("dcts_unit_nanveto_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&work);
tokio::fs::create_dir_all(&work).await.unwrap();
// 假 tlusty#1(seed_nc) 写全零 fort.9(假达标)+ 含 NaN 的 fort.7
// #2(nl) 从原始种子正常收敛。
let fake = work.join("fake_tlusty.sh");
let script = r#"#!/usr/bin/env bash
n=$(cat ./call_count 2>/dev/null || echo 0); n=$((n+1)); echo $n > ./call_count
cat /dev/stdin > /dev/null
case $n in
1) printf ' 50 5.0E+03 NaN NaN NaN\n' > fort.7
printf ' 1 50 0.00E+00 0.00E+00 0.00E+00 0.00E+00 0.00E+00 0 9\n' > fort.9 ;;
2) echo "clean model" > fort.7
printf ' 1 50 1.0E-02 1.0E-02 1.0E-02 1.0E-02 1.0E-05 75 1\n' > fort.9 ;;
esac
exit 0
"#;
tokio::fs::write(&fake, script).await.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let runtime = crate::embedded::RuntimePaths {
tlusty_exe: fake.clone(),
synspec_exe: work.join("synspec_absent"),
data_dir: work.clone(),
linelist: work.join("linelist_absent"),
};
let seed = work.join("orig_seed.7");
tokio::fs::write(&seed, "orig clean seed").await.unwrap();
let runner = super::ExecutionRunner::new(&runtime, work.join("models"));
let params = crate::models::GridPointParams {
teff: crate::models::GridAxisValue::from_value(60000.0),
logg: crate::models::GridAxisValue::from_value(6.0),
loghe: crate::models::GridAxisValue::from_value(-4.0),
logc: crate::models::GridAxisValue::from_value(-4.0),
logn: crate::models::GridAxisValue::from_value(-4.0),
logo: crate::models::GridAxisValue::from_value(-4.0),
};
let summary = runner
.run_model_with_timeout(
&params,
"t60000_g6.0_he-4_c-4_n-4_o-4",
"seed_step",
Some(super::default_seed_chain()),
Some(&seed),
None,
true,
false,
60,
None,
None, None, None, None, None, None, None, None, None,
)
.await
.unwrap();
let labels: Vec<(String, bool)> = summary
.stages
.iter()
.map(|s| (s.label.clone(), s.converged))
.collect();
assert_eq!(
labels,
vec![
("seed_nc".to_string(), false),
("nl".to_string(), true),
],
"全零 fort.9 + NaN fort.7 应被否决收敛;nl 应回退到原始种子并收敛(阶段: {:?})",
labels
);
let seed_nc = summary.stages.iter().find(|s| s.label == "seed_nc").unwrap();
assert!(
seed_nc.note.as_deref().unwrap_or("").contains("NaN"),
"note 应标注 NaN 伪收敛防护,实际: {:?}",
seed_nc.note
);
let _ = std::fs::remove_dir_all(&work);
}
/// 端到端回归(2026-08-14#[ignore]:真实运行 TLUSTY,约 15-40 分钟):
/// nl_direct 回退。场景取自生产实测失败点 t60000_g5.5_he-2_c-4_n-4_o-4
/// seed_nc 发散 → 其输出毒死 nl → 回退用原始种子直接跑 nl 应收敛。
/// 对应修复验证数据见 docs/tlusty_coldstart_nc_trace_illcond_2026_08_13.md。
#[tokio::test]
#[ignore = "真实运行 TLUSTY~40min),验证修复时用 cargo test -p common nl_direct -- --ignored --nocapture"]
async fn e2e_nl_direct_fallback_recovers_poisoned_seed_chain() {
use crate::models::GridAxisValue;
let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let repo_root: &std::path::Path = manifest.parent().and_then(|p| p.parent()).unwrap();
let seed_path = repo_root.join(
"test/20260813_cold_nc_trace_fix/hevalidation/p4_nldirect_cno1/inputs/fort.8",
);
if !seed_path.is_file() {
eprintln!("跳过:种子模型不存在({}),需先准备 p4 场景种子", seed_path.display());
return;
}
let runtime = crate::embedded::RuntimePaths {
tlusty_exe: repo_root.join("assets/tlusty_static"),
synspec_exe: repo_root.join("assets/synspec_static"),
data_dir: repo_root.join("assets/data"),
linelist: repo_root.join("assets/data/gfATO.dat"),
};
let work = std::env::temp_dir().join(format!("dcts_e2e_nl_direct_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&work);
tokio::fs::create_dir_all(&work).await.unwrap();
let runner = super::ExecutionRunner::new(&runtime, work.clone());
let params = GridPointParams {
teff: GridAxisValue::from_value(60000.0),
logg: GridAxisValue::from_value(5.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),
};
let name = "t60000_g5.5_he-2_c-4_n-4_o-4";
let chain = super::default_seed_chain();
let summary = runner
.run_model_with_timeout(
&params,
name,
"seed_step",
Some(chain),
Some(&seed_path),
None, // synspec_cfg
true, // tlusty_enabled
false, // synspec_enabled
3600,
None,
None,
None, None, None, None, None, None, None, None,
)
.await
.unwrap();
// 成功判据(两条路径之一,均为修复目标):
// a) 主链 nl 自收敛(nst 截断修复后 IFALI/IFPOPR/JALI 等真正生效,nl 可自愈),或
// b) nl 失败触发 nl_direct 回退且回退收敛(p4 场景实测 13it/6.3e-4)。
let labels: Vec<(&str, bool)> = summary
.stages
.iter()
.map(|s| (s.label.as_str(), s.converged))
.collect();
eprintln!("stages: {:?}", labels);
let main_nl_ok = summary.stages.iter().any(|s| s.label == "nl" && s.converged);
let direct_ok = summary.stages.iter().any(|s| s.label == "nl_direct" && s.converged);
assert!(
main_nl_ok || direct_ok,
"种子链应经主链 nl 或 nl_direct 回退之一收敛,实际阶段: {:?}",
labels
);
let _ = std::fs::remove_dir_all(&work);
}
}