Files
DCTS/crates/common/src/runner.rs
T
fmq f2700031ce feat(all): seed_step_stab 稳定化种子链与同族种子轮换、ladder/自适应 Teff/丰度轴延拓三级回退与梯级种子入库复用、热启动首末比豁免、workflow 完成翻转回退链阻塞修复、大气收敛权威统计与 M14 回填
收敛攻坚(81/400 失败点根因与实测,见 docs/failed81_cno_seed_popzer_dpsilg_2026_08_18.md):
- runner: 新增 seed_step_stab 策略链——同物理族(同 Teff/logg/logHe)CNO 邻居种子
  + DPSILG=3.0 λ 算子欠松弛 + POPZER=1E-10 微布居置零(联动 POPZR2/RADZER 同值
  + NITZER=1),针对 20kK He 富大气 He I/II 电离前沿布居极限环
- runner: 三级链内延拓回退(主链与 nl_direct 全败后自动触发):
  · 固定 ladder 步进——plan_ladder_steps 按归一化间隔选轴,Δlogg≤0.25/ΔTeff≤2.5kK,≤4 步
  · 自适应 Teff 延拓——步长 1250K 起、成功 ×1.5 恢复、失败二分至 25K 折叠墙,≤48 步
  · C/N 丰度轴延拓——高温域(Teff>30kK)专用,严格同族种子沿 C(优先)/N 轴 0.2 dex 起步
  延拓阶段 NITER 下限提至 300(慢收敛 waypoint 迭代饥饿误判修复)
- runner: 稳定化多档回退(DPSILG/POPZER 三档互补,联合回收 41/65);
  域门控 Teff≤30kK——高温高金属域实测旋钮致散(17 拍爆至 4e16),域外跳过
- 梯级种子持久化: 收敛中间模型登记 ladder_seeds 随上报落 server seeds 表
  (任务失败也上传,合成名 _ladder 与真实网格点零冲突),簇内相邻失败点自动复用

调度与执行:
- scheduler: 策略解析新增 seed_step_stab 臂——find_exact_family_seed_from_db 严格
  同物理族判定(不做 global 退化,防 ladder 中间种子 ΔTeff≤5000K 误命中),
  排除本点历史已用种子实现重试轮换;链在 stab 耗尽时轮换未试过同族邻居重派
- executor: seed_step_stab 补种子下载(漏列曾致 78 任务假失败,seed_nc 无 fort.8 崩溃);
  Teff>30kK 域外自动降级普通种子链

收敛判据:
- conv_check: 热启动豁免——首拍 max_relc<1(种子已近解)时首末比 1e3 判据数学上
  不可达,豁免后交五重物理硬门槛裁决(修复 nl_ladder 0.038→6e-4 物理全过被误杀);
  冷启动仍受判据门控

workflow 生命周期:
- workflows/tasks: 完成 flip 增加「未消费回退链」阻塞子句——failed 点策略链未耗尽
  或链尾 seed_step_stab 尚有未试过同族种子时不得置 completed(修复最后活跃点 cold
  失败上报抢先 flip、still_running 守卫拦截后续策略永不派发);按 failed_stage 归因
  (synspec 失败行只看 synspec 链,防 stale 审计副本永久卡死)+ json_valid 脏行防护

统计与前端:
- 统计新增权威口径 tlusty_converged(不按策略拆)与 seed_step_stab_converged 分项,
  前端详情页色带/概览卡消费权威总数并新增稳定化青色段(修复 stab 收敛点漏计,
  生产 9137/9216 差额);M14 迁移回填历史 completed 点的 tlusty_status

文档:
- 新增 failed81 POPZER/DPSILG 制胜配方根因分析、Windows 节点经跳板 RDP 运维手册;
  failed400 增补 ladder 生产化实现与第二轮 121 残点实测矩阵
2026-09-02 19:37:25 +08:00

2484 lines
117 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_bfactor, check_convergence_trace, check_emflux_bolometric,
check_energy_conservation, check_fort9, check_temperature_structure, 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};
/// 阶梯/延拓阶段(Teff 自适应延拓、固定梯、丰度轴延拓)的迭代下限。
///
/// 这些阶段克隆自 nl 步(NITER=100),而慢收敛 waypoint 常在 chmax=0.001 门槛前
/// 耗尽迭代——生产实证(2026-08-21t60000_g5.0_he-4_c-4_n-4_o-1):N 轴延拓
/// 在 n=-3.53 处 best=0.002/0.005 卡满 100 迭代且仍在单调下降(迭代饥饿而非
/// 发散),二分触底 0.025 dex 后被误判为轴折叠。TLUSTY 收敛即停,本上限只给
/// 慢阶段 3× 余量,不影响快阶段;真发散步会提前 STOP in SOLVE,也不受损。
const LADDER_STAGE_NITER: i32 = 300;
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,
ichang: None,
idlte: None,
iacc: None,
orelax: None,
dpsilg: None,
popzer: 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,
ichang: None,
idlte: None,
iacc: None,
orelax: None,
dpsilg: None,
popzer: 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,
ichang: None,
idlte: None,
iacc: None,
orelax: None,
dpsilg: None,
popzer: 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,
ichang: Some(0),
idlte: None,
iacc: None,
orelax: Some(0.3),
dpsilg: None,
popzer: 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,
ichang: Some(0),
idlte: None,
iacc: None,
orelax: Some(0.5),
dpsilg: None,
popzer: None,
},
]
}
/// 稳定化种子步进链(策略 `seed_step_stab`)。
///
/// 面向难收敛角落(2026-08-18 实测 20kK He 富大气 He I/II 电离前沿布居极限环):
/// 同物理族(同 Teff/logg/logHe)不同 CNO 的已收敛邻居种子 + POPZER 微布居置零 +
/// DPSILG λ 算子欠松弛。代表点 t20000_g6.5_he2_c-4_n-3_o-4 其余配置组合全部发散,
/// 本配方收敛且五重物理硬门全过(emflux 1.0013、首末比 4.5e5)。
pub fn default_seed_stab_chain() -> Vec<ChainStep> {
default_seed_chain()
.into_iter()
.map(|mut s| {
s.dpsilg = Some(3.0);
s.popzer = Some(1e-10);
s
})
.collect()
}
/// 按当前策略选默认执行链(`custom_chain` 为 None/空时的兜底)。
///
/// Phase 6P8)起取代废弃的 task_type 匹配:`"seed_step"` → 种子热启动链,
/// `"seed_step_stab"` → 稳定化种子链(POPZER+DPSILG),其余策略(`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 if current_strategy == "seed_step_stab" {
default_seed_stab_chain()
} else {
default_cold_chain()
}
}
/// ladder 步进规划(纯函数):给定种子参数与目标参数,产出中间步坐标。
///
/// 规则(2026-08-17 实测验证:test/20260817_failed400/ladder_*):
/// - 只沿与种子差值更大的轴步进(归一化:Δlogg/0.25 vs Δteff/2500);
/// - 步长上限 Δlogg=0.25、ΔTeff=2500K,中间步数 ≤4
/// - 间隔已在单步收敛域内(Δlogg≤0.25 且 ΔTeff≤2500)→ 返回空(无需 ladder
/// 该场景本就不该触发);
/// - 均匀切分:n = ceil(归一化间隔),每步走 gap/n。
///
/// 返回 (teff, logg, label) 列表,label 如 `ladder_g6.25` / `ladder_t57.5k`。
/// 目标步不在其中(由调用方用原 ChainStep 跑目标参数)。
pub fn plan_ladder_steps(
from_teff: f64,
from_logg: f64,
to_teff: f64,
to_logg: f64,
) -> Vec<(f64, f64, String)> {
let d_logg = (to_logg - from_logg).abs();
let d_teff = (to_teff - from_teff).abs();
// 归一化到"单步上限"的单位数
let n_logg = (d_logg / 0.25).ceil() as usize;
let n_teff = (d_teff / 2500.0).ceil() as usize;
// 双轴都在单步收敛域内(含恰好等于上限)→ 无需 ladder(direct nl 即可覆盖)
if n_logg <= 1 && n_teff <= 1 {
return Vec::new();
}
// 轴选择:需要更多步的轴(间隔更远 = 收敛域外的主因)
let use_logg = n_logg >= n_teff;
let n = if use_logg { n_logg } else { n_teff }.clamp(1, 4);
(1..=n)
.map(|k| {
let f = k as f64 / n as f64;
let (t, g) = if use_logg {
(to_teff, from_logg + f * (to_logg - from_logg))
} else {
(from_teff + f * (to_teff - from_teff), to_logg)
};
let label = if use_logg {
format!("ladder_g{}", (g * 100.0).round() / 100.0)
} else {
format!("ladder_t{}k", (t / 1000.0 * 10.0).round() / 10.0)
};
(t, g, label)
})
.collect()
}
/// 运行子进程,带超时与优雅退出(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>,
seed_params: Option<&GridPointParams>,
synspec_cfg: Option<&SynspecInput>,
tlusty_input: Option<&TlustyInput>,
energy_tolerance: Option<f64>,
temp_max_factor: Option<f64>,
temp_floor: Option<f64>,
temp_ceiling: Option<f64>,
emflux_tolerance: Option<f64>,
convergence_min_ratio: Option<f64>,
bfac_max: Option<f64>,
bfac_min: Option<f64>,
) -> Result<ModelSummary> {
self.run_model_with_timeout(
params,
name,
current_strategy,
custom_chain,
seed_atmos,
seed_params,
synspec_cfg,
true,
true,
7200,
None,
tlusty_input,
energy_tolerance,
temp_max_factor,
temp_floor,
temp_ceiling,
emflux_tolerance,
convergence_min_ratio,
bfac_max,
bfac_min,
)
.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` 控制各阶段是否运行:
/// - 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>,
seed_params: Option<&crate::models::GridPointParams>,
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>,
energy_tolerance: Option<f64>,
temp_max_factor: Option<f64>,
temp_floor: Option<f64>,
temp_ceiling: Option<f64>,
emflux_tolerance: Option<f64>,
convergence_min_ratio: Option<f64>,
bfac_max: Option<f64>,
bfac_min: Option<f64>,
) -> 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;
// ladder 中间梯级(收敛 + 无 NaN 双过才登记),随 ModelSummary 上报持久化
let mut ladder_seeds: Vec<crate::models::LadderSeedInfo> = Vec::new();
// 阶段独立配置(见 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;
// tlusty_input 为 NoneYAML 未配 tlusty_input 块)时用默认值。
// 默认值定义集中在 config.rs 的 TlustyInput::default(),此处不再重复维护。
let default_input = TlustyInput::default();
let input_cfg: &TlustyInput = tlusty_input.unwrap_or(&default_input);
let mut failed_stage_idx: Option<usize> = None;
for (stage_idx, stage_def) in chain.iter().enumerate() {
if tlusty_skipped {
break;
}
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;
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
);
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);
}
}
}
}
}
// ── ladder 步进回退(2026-08-17,真发散硬核专用)──
// 背景:~131 个生产失败点从任何网格邻居种子直接求解都在 Newton 收敛域外
// (首 6-13 迭代即 1e16 STOPORELAX 无效)。实测(ladder_60k_he4 /
// ladder_20k_he2):把种子→目标的参数间隔切成 ≤4 段(Δlogg≤0.25 或
// ΔTeff≤2.5kK)逐步热启动,两个最难簇代表点均完全收敛(物理三项过 +
// emflux 达标)。触发条件:此前全部失败 + 有原始种子 + 知道种子参数
// (ladder 规划需要种子与目标的差值)。
// 每个中间步收敛即登记进 ladder_seedsnode→server 持久化为可复用梯级,
// 簇内相邻失败点可共享),链在中途断掉已完成的梯级仍有价值。
if tlusty_enabled && !final_converged {
if let (Some(orig_seed), Some(sp)) = (seed_atmos, seed_params) {
if orig_seed.is_file() {
if let Some(last_stage) = chain.last() {
let steps = plan_ladder_steps(
sp.teff.value(),
sp.logg.value(),
params.teff.value(),
params.logg.value(),
);
// 2026-08-21 自适应 Teff 延拓(60k/g5.0 攻坚,10/13 点唯一
// 制胜路径,docs/failed81_...md §十一):Teff 轴为主且间隔
// 超一档时,固定 ≤2500K 均分档在 58–59.5k 折叠墙前全灭
// (实测一步 2500K 必发散、二分到 25–150K 才能穿过)。
// 改为自适应步长控制:初始 1250K,成功 ×1.5(上限 1250K
// 恢复、失败二分(下限 25K),最多 48 步。logg 轴与短间隔
// 维持原固定档(logg 轴未经自适应验证)。
let d_teff_gap =
(params.teff.value() - sp.teff.value()).abs();
let teff_axis_adaptive = d_teff_gap > 2500.0 && {
let n_logg = ((params.logg.value() - sp.logg.value()).abs()
/ 0.25)
.ceil() as usize;
let n_teff = (d_teff_gap / 2500.0).ceil() as usize;
n_teff > n_logg
};
if teff_axis_adaptive {
info!(
"ladder 回退(自适应 Teff 延拓):种子 t{}/g{} → 目标 t{}/g{},步长 1250K 起、失败二分至 25K",
sp.teff.value(),
sp.logg.value(),
params.teff.value(),
params.logg.value()
);
let mut ladder_seed_now: Option<PathBuf> =
Some(orig_seed.to_path_buf());
let teff_target = params.teff.value();
let mut t_now = sp.teff.value();
let mut d_t = 1250.0_f64;
let mut steps_done = 0usize;
const MAX_WALK_STAGES: usize = 48;
const MIN_DT: f64 = 25.0;
while (teff_target - t_now).abs() > 1.0
&& steps_done < MAX_WALK_STAGES
{
let t_next = if teff_target > t_now {
(t_now + d_t).min(teff_target)
} else {
(t_now - d_t).max(teff_target)
};
let label = format!(
"ladder_t{:.2}k",
(t_next / 1000.0 * 100.0).round() / 100.0
);
let step_params = crate::models::GridPointParams {
teff: crate::models::GridAxisValue::from_value(t_next),
logg: params.logg.clone(),
loghe: params.loghe.clone(),
logc: params.logc.clone(),
logn: params.logn.clone(),
logo: params.logo.clone(),
};
let mut step_def = last_stage.clone();
step_def.label = label.clone();
step_def.require_converged = true;
step_def.niter = step_def.niter.max(LADDER_STAGE_NITER);
let (step_summary, step_produced) = self
.execute_tlusty_stage(
&model_dir,
name,
&step_params,
&step_def,
input_cfg,
ladder_seed_now.as_deref(),
convergence_min_ratio,
timeout_sec,
shutdown.clone(),
)
.await?;
steps_done += 1;
let ok = step_summary.converged;
if ok {
if let Some(p) = step_produced {
ladder_seeds.push(crate::models::LadderSeedInfo {
label: label.clone(),
point_name: format!(
"{}_ladder",
step_params.model_name()
),
teff: t_next,
logg: params.logg.value(),
loghe: params.loghe.value(),
logc: params.logc.value(),
logn: params.logn.value(),
logo: params.logo.value(),
});
ladder_seed_now = Some(p);
}
t_now = t_next;
d_t = (d_t * 1.5).min(1250.0);
} else {
// 失败步产物不传递(沿用上一收敛态),步长二分;
// 低于下限视为折叠墙,中止链。
d_t /= 2.0;
if d_t < MIN_DT {
warn!(
"自适应 Teff 延拓在 {} 处遇到折叠墙(步长 <{MIN_DT}K 仍失败)",
label
);
stage_summaries.push(step_summary);
break;
}
}
stage_summaries.push(step_summary);
}
// 延拓末步通常已 clamp 到目标参数(step_def 即 chain.last()
// 的 nl 定义,与目标一致)→ 直接采纳,不再跑冗余的 nl_ladder
// 同参数重跑(审查修复 2026-08-21)。仅当中途断链
// (步数上限/折叠墙)时保持未收敛,交给后续回退顺位。
if !final_converged
&& (teff_target - t_now).abs() <= 1.0
&& stage_summaries.last().map(|s| s.converged).unwrap_or(false)
&& ladder_seed_now.is_some()
&& ladder_seed_now.as_deref() != Some(orig_seed)
{
final_converged = true;
final_chmax = last_stage.chmax;
if let Some(s) = stage_summaries.last() {
if let Some(r) = s.best_max_relc {
final_max_relc = Some(r);
}
if s.label == "nl_ladder" {
// 不可能:本分支不跑 nl_ladder;占位防误标
}
}
current_seed = ladder_seed_now.clone();
info!("自适应 Teff 延拓成功:末步已达目标并收敛");
}
} else if !steps.is_empty() {
info!(
"ladder 回退:{} 步中间参数步进(种子 t{}/g{} → 目标 t{}/g{}",
steps.len(),
sp.teff.value(),
sp.logg.value(),
params.teff.value(),
params.logg.value()
);
let mut ladder_seed_now: Option<PathBuf> =
Some(orig_seed.to_path_buf());
for (t, g, label) in steps {
let step_params = crate::models::GridPointParams {
teff: crate::models::GridAxisValue::from_value(t),
logg: crate::models::GridAxisValue::from_value(g),
loghe: params.loghe.clone(),
logc: params.logc.clone(),
logn: params.logn.clone(),
logo: params.logo.clone(),
};
let mut step_def = last_stage.clone();
step_def.label = label.clone();
step_def.require_converged = true;
step_def.niter = step_def.niter.max(LADDER_STAGE_NITER);
let (step_summary, step_produced) = self
.execute_tlusty_stage(
&model_dir,
name,
&step_params,
&step_def,
input_cfg,
ladder_seed_now.as_deref(),
convergence_min_ratio,
timeout_sec,
shutdown.clone(),
)
.await?;
let ok = step_summary.converged;
if let Some(p) = step_produced {
// 收敛 + NaN 否决双过(execute_tlusty_stage 保证
// converged ⇒ fort.7 干净)→ 登记为可复用梯级
if ok {
ladder_seeds.push(crate::models::LadderSeedInfo {
label: label.clone(),
point_name: format!(
"{}_ladder",
step_params.model_name()
),
teff: t,
logg: g,
loghe: params.loghe.value(),
logc: params.logc.value(),
logn: params.logn.value(),
logo: params.logo.value(),
});
}
ladder_seed_now = Some(p);
}
stage_summaries.push(step_summary);
if !ok {
warn!("ladder 步进在 {} 处未收敛,中止步进链", label);
break;
}
}
// 全部中间步通过 → 用目标参数跑最终步
if !final_converged
&& stage_summaries.last().map(|s| s.converged).unwrap_or(false)
&& ladder_seed_now.is_some()
&& ladder_seed_now.as_deref() != Some(orig_seed)
{
let mut final_step = last_stage.clone();
final_step.label = "nl_ladder".to_string();
final_step.require_converged = true;
// 与中间梯级同口径(LADDER_STAGE_NITER):末步是从最后
// 梯级到目标的逼近,越近目标越慢收敛——恰恰是最可能
// 迭代饥饿的一步(2026-08-22 审查补充)。
final_step.niter = final_step.niter.max(LADDER_STAGE_NITER);
let (mut final_summary, final_produced) = self
.execute_tlusty_stage(
&model_dir,
name,
params,
&final_step,
input_cfg,
ladder_seed_now.as_deref(),
convergence_min_ratio,
timeout_sec,
shutdown.clone(),
)
.await?;
if final_summary.converged {
final_summary.note = Some(
"nl_ladder 回退收敛(连续步进链最终步)".to_string(),
);
final_converged = true;
final_chmax = final_step.chmax;
if let Some(r) = final_summary.best_max_relc {
final_max_relc = Some(r);
}
if let Some(p) = final_produced {
current_seed = Some(p);
}
info!("nl_ladder 回退成功:目标点经步进链收敛");
} else if final_summary.note.is_none() {
final_summary.note =
Some("nl_ladder 回退未收敛".to_string());
}
stage_summaries.push(final_summary);
}
}
}
}
}
}
// ── 稳定化多档回退(2026-08-18/19)──
// 背景:20kK He 富大气布居极限环对 (DPSILG, POPZER) 组合逐点敏感,实测
// test/20260818_failed81/full20k* 全 65 点批量)三档互补,联合回收 41/65:
// 一档 DPSILG=3.0/POPZER=1E-1017 点,seed_step_stab 策略链自带)
// 二档 DPSILG=2.0/POPZER=3E-11+14 点)
// 三档 DPSILG=2.0/POPZER=1E-11+10 点,含全部 g5.5
// 收敛点全部通过温度结构物理判据。此回退在全部常规路径失败后逐档自原始
// 种子重跑最后的 require_converged 阶段,任一档收敛即采纳。
// 详见 docs/failed81_cno_seed_popzer_dpsilg_2026_08_18.md。
const STAB_TIERS: [(f64, f64); 3] = [(2.0, 3e-11), (2.0, 1e-11), (2.5, 1e-11)];
// 2026-08-21 域门控(60k/g5.0 攻坚实测,docs/failed81_...md §十一):
// DPSILG/POPZER 族旋钮仅在低温 He 富域(Teff≤30kK)有效;60k 高金属域
// 实测自复现收敛种子加档后 17 拍爆到 4e16(致散而非稳定)。域外跳过
// 全部档位,避免白跑与二次伤害。
if tlusty_enabled && !final_converged && params.teff.value() <= 30000.0 {
if let Some(idx) = failed_stage_idx {
if let Some(orig_seed) = seed_atmos {
if orig_seed.is_file() {
for (tier_no, (dg, pz)) in STAB_TIERS.iter().enumerate() {
let mut tier_stage = chain[idx].clone();
// 链自带一档参数时跳过同档重跑
if tier_stage.dpsilg == Some(*dg)
&& tier_stage.popzer == Some(*pz)
{
continue;
}
tier_stage.label = format!("nl_stab{}", tier_no + 2);
tier_stage.dpsilg = Some(*dg);
tier_stage.popzer = Some(*pz);
info!(
"尝试 {} 回退:DPSILG={}/POPZER={:.0E} 自原始种子重跑 {}",
tier_stage.label,
dg,
pz,
chain[idx].label
);
let (mut tier_summary, tier_seed) = self
.execute_tlusty_stage(
&model_dir,
name,
params,
&tier_stage,
input_cfg,
Some(orig_seed),
convergence_min_ratio,
timeout_sec,
shutdown.clone(),
)
.await?;
if tier_summary.converged {
info!("{} 回退成功:稳定化参数收敛", tier_stage.label);
final_converged = true;
final_chmax = tier_stage.chmax;
if let Some(r) = tier_summary.best_max_relc {
final_max_relc = Some(r);
}
if let Some(p) = tier_seed {
current_seed = Some(p);
}
if tier_summary.note.is_none() {
tier_summary.note = Some(format!(
"{} 回退收敛(DPSILG={}/POPZER={:.0E} 稳定化)",
tier_stage.label, dg, pz
));
}
stage_summaries.push(tier_summary);
break;
} else if tier_summary.note.is_none() {
tier_summary.note =
Some(format!("{} 回退未收敛", tier_stage.label));
}
stage_summaries.push(tier_summary);
}
}
}
}
}
// ── 丰度轴延拓回退(2026-08-2160k/g5.0 折叠角攻坚)──
// 背景:60k/g5.0 高金属角(o-1 × 高 C+N)三点上,Teff 轴(自适应二分至 25K)、
// logg 轴、O 丰度轴全部在目标前折叠,但 **C 或 N 丰度轴延拓全部走通**
// test/20260820_residual27/p60/*.ax_*.log3/3 点物理收敛)——静力解存在,
// 只是可达方向在 C/N。配方:同 (Teff,logg,logHe) 的 CNO 邻居收敛种子
// seed_step_stab 的 exact_family 种子),沿 C 轴(3/3 验证,优先)或
// N 轴自适应小步爬向目标丰度:初始 0.2 dex,成功 ×1.4(上限 0.25)、
// 失败二分(下限 0.025 dex)、每轴最多 40 步;失败步产物不传递。
// 与稳定化域门控互补:本回退仅高温域(Teff>30kK)启用(低温域由
// seed_step_stab 稳定化档覆盖,见上方域门控注释)。
if tlusty_enabled && !final_converged && params.teff.value() > 30000.0 {
if let (Some(orig_seed), Some(sp), Some(last_stage)) =
(seed_atmos, seed_params, chain.last())
{
// 仅严格同物理族种子(同 Teff/logg/logHe、仅 CNO 不同)——延拓前提
// 是结构与背景大气已处于目标 T/g/He 的解分支附近。
let same_family = (sp.teff.value() - params.teff.value()).abs() < 1e-9
&& (sp.logg.value() - params.logg.value()).abs() < 1e-9
&& (sp.loghe.value() - params.loghe.value()).abs() < 1e-9;
let cno_differs = (sp.logc.value() - params.logc.value()).abs() > 1e-9
|| (sp.logn.value() - params.logn.value()).abs() > 1e-9
|| (sp.logo.value() - params.logo.value()).abs() > 1e-9;
if orig_seed.is_file() && same_family && cno_differs {
// 轴顺序:C 优先(3/3 验证),N 兜底(he-2 验证)
for (axis_name, axis_from, axis_to) in [
("c", sp.logc.value(), params.logc.value()),
("n", sp.logn.value(), params.logn.value()),
] {
if (axis_to - axis_from).abs() < 1e-9 || final_converged {
continue;
}
info!(
"丰度轴延拓回退:沿 {axis_name} 轴 {axis_from} → {axis_to}(种子 {} → 目标 {}",
sp.model_name(),
params.model_name()
);
let mut seed_now: Option<PathBuf> = Some(orig_seed.to_path_buf());
let mut v_now = axis_from;
let mut d_v = 0.2_f64;
let mut steps_done = 0usize;
const MAX_AX_STAGES: usize = 40;
const MIN_DV: f64 = 0.025;
while (axis_to - v_now).abs() > 1e-6 && steps_done < MAX_AX_STAGES {
let v_next = if axis_to > v_now {
(v_now + d_v).min(axis_to)
} else {
(v_now - d_v).max(axis_to)
};
let label = format!("axlad_{}{:.3}", axis_name, v_next);
let step_params = crate::models::GridPointParams {
teff: params.teff.clone(),
logg: params.logg.clone(),
loghe: params.loghe.clone(),
logc: if axis_name == "c" {
crate::models::GridAxisValue::from_value(v_next)
} else {
params.logc.clone()
},
logn: if axis_name == "n" {
crate::models::GridAxisValue::from_value(v_next)
} else {
params.logn.clone()
},
logo: params.logo.clone(),
};
let mut step_def = last_stage.clone();
step_def.label = label.clone();
step_def.require_converged = true;
step_def.niter = step_def.niter.max(LADDER_STAGE_NITER);
let (step_summary, step_produced) = self
.execute_tlusty_stage(
&model_dir,
name,
&step_params,
&step_def,
input_cfg,
seed_now.as_deref(),
convergence_min_ratio,
timeout_sec,
shutdown.clone(),
)
.await?;
steps_done += 1;
if step_summary.converged {
if let Some(p) = step_produced {
ladder_seeds.push(crate::models::LadderSeedInfo {
label: label.clone(),
point_name: format!("{}_ladder", step_params.model_name()),
teff: step_params.teff.value(),
logg: step_params.logg.value(),
loghe: step_params.loghe.value(),
logc: step_params.logc.value(),
logn: step_params.logn.value(),
logo: step_params.logo.value(),
});
seed_now = Some(p);
}
v_now = v_next;
d_v = (d_v * 1.4).min(0.25);
} else {
d_v /= 2.0;
if d_v < MIN_DV {
warn!(
"丰度轴 {axis_name} 延拓在 {v_next} 处折叠(步长 <{MIN_DV} dex 仍失败)"
);
stage_summaries.push(step_summary);
break;
}
}
stage_summaries.push(step_summary);
}
// 走到目标丰度且末步收敛 → 采纳(末步参数即目标)
if !final_converged
&& (axis_to - v_now).abs() <= 1e-6
&& stage_summaries.last().map(|s| s.converged).unwrap_or(false)
&& seed_now.is_some()
&& seed_now.as_deref() != Some(orig_seed)
{
final_converged = true;
final_chmax = last_stage.chmax;
if let Some(s) = stage_summaries.last() {
if let Some(r) = s.best_max_relc {
final_max_relc = Some(r);
}
}
current_seed = seed_now.clone();
info!("丰度轴 {axis_name} 延拓成功:末步已达目标丰度并收敛");
}
if final_converged {
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;
}
// 能量守恒硬门槛(docs/spectrum_correctness_analysis.md §1.1 第一层 #4):
// 解析 `.6`(收敛链最后阶段 stdout,含 LFIN 时 OUTPRI 写出的能量守恒表)的
// `(RAD+CON)/TOT` 列。任一深度偏离 1 超阈值即判失败(与 atmosphere_has_nan 同级)。
// 此时 `<name>.6` 仍存在(行 688 的冗余清理在此之后),内容是最后阶段日志。
// energy_tolerance=None → 跳过(非常规配置);`.6` 无能量守恒表 → 函数返回 None 跳过。
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)
})
} else {
None
};
let mut energy_failed = false;
if let Some(ref ec) = energy_check {
if !ec.valid {
final_converged = false;
energy_failed = true;
}
}
// ── 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 来自外部种子,跳过。
let teff = params.teff.value();
let temp_check = if tlusty_enabled {
temp_max_factor.and_then(|mf| {
let floor = temp_floor.unwrap_or(10.0);
let ceiling = temp_ceiling.unwrap_or(1.0e8);
check_temperature_structure(&final_7, teff, mf, floor, ceiling)
})
} else {
None
};
let mut temp_failed = false;
if let Some(ref tc) = temp_check {
if !tc.valid {
final_converged = false;
temp_failed = true;
}
}
// emflux bolometric 通量守恒校验(§3.2/§4):
// 解析 `.emflux`snapshot_tlusty_outputs 已快照),梯形积分 ∫Fλdλ 比 σTeff⁴。
// 全 NaN(辐射转移失败)也判失败。文件缺失 → 跳过(非常规配置)。
let emflux_check = if tlusty_enabled {
emflux_tolerance.and_then(|tol| {
let emflux_path = model_dir.join(format!("{}.emflux", name));
check_emflux_bolometric(&emflux_path, teff, tol)
})
} else {
None
};
let mut emflux_failed = false;
if let Some(ref ec) = emflux_check {
if !ec.valid {
final_converged = false;
emflux_failed = true;
}
}
// b 因子合理性校验(§3.1/§6 #19):解析 `.bfac`snapshot_tlusty_outputs 已快照),
// 检查 NLTE 偏离因子 b 无极端值(>1e3 或 <1e-3 占比 >10% → 失败)。
// 文件缺失/无有效 b 因子 → 跳过(纯 LTE 模型或 grey start)。
let bfac_check = if tlusty_enabled {
bfac_max.and_then(|mx| {
let mn = bfac_min.unwrap_or(1.0e-3);
let bfac_path = model_dir.join(format!("{}.bfac", name));
check_bfactor(&bfac_path, mx, mn)
})
} else {
None
};
let mut bfac_failed = false;
if let Some(ref bc) = bfac_check {
if !bc.valid {
final_converged = false;
bfac_failed = true;
}
}
// 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;
// synspec_cfg 为 None(工作流未配 synspec_input 块)时用默认值。
// 默认值定义集中在 config.rs 的 SynspecInput::default(),此处不再重复维护。
let default_cfg = SynspecInput::default();
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 energy_failed {
if let Some(ref ec) = energy_check {
if let Some(ref err) = ec.error {
notes.push(err.clone());
}
}
}
if temp_failed {
if let Some(ref tc) = temp_check {
if let Some(ref err) = tc.error {
notes.push(err.clone());
}
}
}
if emflux_failed {
if let Some(ref ec) = emflux_check {
if let Some(ref err) = ec.error {
notes.push(err.clone());
}
}
}
if bfac_failed {
if let Some(ref bc) = bfac_check {
if let Some(ref err) = bc.error {
notes.push(err.clone());
}
}
}
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,
energy_check,
temp_check,
emflux_check,
bfac_check,
ladder_seeds,
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]
/// seed_step_stab 策略链:种子链形状 + 全步带 POPZER=1e-10/DPSILG=3.0 稳定化旋钮。
#[test]
fn test_default_seed_stab_chain() {
let chain = super::default_seed_stab_chain();
assert_eq!(
chain.iter().map(|s| s.label.as_str()).collect::<Vec<_>>(),
vec!["seed_nc", "nl"],
"stab 链应保持种子链形状"
);
for step in &chain {
assert_eq!(step.dpsilg, Some(3.0), "步 {} 应带 DPSILG=3.0", step.label);
assert_eq!(step.popzer, Some(1e-10), "步 {} 应带 POPZER=1e-10", step.label);
}
// 策略映射
let via_strategy = super::default_chain_for_strategy("seed_step_stab");
assert_eq!(via_strategy.len(), chain.len());
assert!(via_strategy.iter().all(|s| s.popzer == Some(1e-10)));
}
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());
}
/// 快速单元回归(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,
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),
dpsilg: None,
popzer: None,
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, // seed_params
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,
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, // seed_params
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);
}
/// plan_ladder_steps 步进规划(纯函数)回归:
/// - logg 间隔 0.5> 单步上限 0.25)→ logg 轴 2 步,每步 0.25
/// - teff 间隔 5000= 2×2500)→ teff 轴 2 步,每步 2500
/// - 双轴都在单步域内 → 空(不该触发 ladder)。
#[test]
fn test_plan_ladder_steps() {
// logg 轴主导:Δlogg=0.5、Δteff=0
let s = super::plan_ladder_steps(60000.0, 5.5, 60000.0, 6.0);
assert_eq!(s.len(), 2);
assert!((s[0].1 - 5.75).abs() < 1e-9, "{:?}", s);
assert!((s[1].1 - 6.0).abs() < 1e-9);
assert!(s[0].2.starts_with("ladder_g"));
// teff 轴主导:Δteff=5000、Δlogg=0.2
let s = super::plan_ladder_steps(55000.0, 5.0, 60000.0, 5.2);
assert_eq!(s.len(), 2);
assert!((s[0].0 - 57500.0).abs() < 1e-9, "{:?}", s);
assert!((s[0].1 - 5.2).abs() < 1e-9);
// 单步域内 → 空
assert!(super::plan_ladder_steps(60000.0, 5.0, 57500.0, 5.1).is_empty());
assert!(super::plan_ladder_steps(60000.0, 5.0, 60000.0, 5.25).is_empty());
}
/// ladder 回退接线回归(2026-08-17):种子链 + nl_direct 全失败 + 种子参数
/// Δlogg=0.5 → 触发 2 步 logg 步进 + 目标步 nl_ladder;中间步登记 ladder_seeds。
#[tokio::test]
async fn unit_ladder_fallback_wiring() {
let work =
std::env::temp_dir().join(format!("dcts_unit_ladder_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&work);
tokio::fs::create_dir_all(&work).await.unwrap();
// 假 tlusty#1(seed_nc)/#2(nl)/#3(nl_direct) 发散;#4/#5(中间步)/#6(nl_ladder)
// 收敛。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 " 50 1.0E+03 2.0E+03" > fort.7
case $n in
1|2|3) printf ' 1 50 1.0E-02 1.0E-02 1.0E-02 1.0E-02 1.0E+16 75 1\n' > fort.9 ;;
*) 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),
};
// 种子参数 Δlogg=0.5(同 teff/丰度)→ 2 步 logg 步进
let seed_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(-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),
Some(&seed_params),
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(), false),
("ladder_g5.75".to_string(), true),
("ladder_g6".to_string(), true),
("nl_ladder".to_string(), true),
],
"应触发 ladder 步进链并经 nl_ladder 收敛(阶段: {:?}",
labels
);
assert_eq!(summary.ladder_seeds.len(), 2, "两个中间步都应登记梯级");
assert_eq!(
summary.ladder_seeds[0].point_name,
"t60000_g5.75_he-4_c-4_n-4_o-4_ladder"
);
assert!((summary.ladder_seeds[0].logg - 5.75).abs() < 1e-9);
assert!((summary.ladder_seeds[1].logg - 6.0).abs() < 1e-9);
assert!(summary.result_valid);
let _ = std::fs::remove_dir_all(&work);
}
/// nl_stab2 回退接线(2026-08-18):种子链 seed_nc/nl/nl_direct 全发散、
/// 无种子参数(ladder 不触发)→ nl_stab2 以二档稳定化参数自原始种子收敛。
#[tokio::test]
async fn unit_nl_stab2_fallback_wiring() {
let work = std::env::temp_dir().join(format!("dcts_unit_stab2_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&work);
tokio::fs::create_dir_all(&work).await.unwrap();
// 假 tlusty#1(seed_nc)/#2(nl)/#3(nl_direct) 发散;#4(nl_stab2) 收敛。
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 " 50 1.0E+03 2.0E+03" > fort.7
case $n in
1|2|3) printf ' 1 50 1.0E-02 1.0E-02 1.0E-02 1.0E-02 1.0E+16 75 1\n' > fort.9 ;;
*) 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(20000.0),
logg: crate::models::GridAxisValue::from_value(6.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(-4.0),
};
let summary = runner
.run_model_with_timeout(
&params,
"t20000_g6.0_he2_c-2_n-4_o-4",
"seed_step",
Some(super::default_seed_chain()),
Some(&seed),
None, // 种子参数未知 → ladder 回退不触发,直达 nl_stab2
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(), false),
("nl_stab2".to_string(), true),
],
"全链失败后应触发 nl_stab2 二档回退(阶段: {:?}",
labels
);
assert!(summary.result_valid);
let _ = std::fs::remove_dir_all(&work);
}
/// 丰度轴延拓接线回归(2026-08-21,60k 折叠角攻坚):高温域(Teff>30kK
/// 下常规链全失败(stab 档因域门控跳过)→ 从同族 CNO 邻居种子沿 C 轴
/// 自适应延拓,第 4 次调用收敛。种子参数与目标同 T/g/He、仅 C 不同。
#[tokio::test]
async fn unit_axlad_c_axis_fallback_wiring() {
let work = std::env::temp_dir().join(format!("dcts_unit_axlad_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&work);
tokio::fs::create_dir_all(&work).await.unwrap();
// 假 tlusty#1(seed_nc)/#2(nl)/#3(nl_direct) 发散;#4 起收敛。
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 " 50 1.0E+03 2.0E+03" > fort.7
case $n in
1|2|3) printf ' 1 50 1.0E-02 1.0E-02 1.0E-02 1.0E-02 1.0E+16 75 1\n' > fort.9 ;;
*) 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();
// 种子参数:同 T/g/He、仅 logC 与目标不同 → C 轴延拓触发
let seed_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(-4.0),
logc: crate::models::GridAxisValue::from_value(-1.0),
logn: crate::models::GridAxisValue::from_value(-4.0),
logo: crate::models::GridAxisValue::from_value(-1.0),
};
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(-4.0),
logc: crate::models::GridAxisValue::from_value(-4.0),
logn: crate::models::GridAxisValue::from_value(-4.0),
logo: crate::models::GridAxisValue::from_value(-1.0),
};
let summary = runner
.run_model_with_timeout(
&params,
"t60000_g5.0_he-4_c-4_n-4_o-1",
"seed_step",
Some(super::default_seed_chain()),
Some(&seed),
Some(&seed_params),
None,
true,
false,
120,
None,
None, None, None, None, None, None, None, None, None,
)
.await
.unwrap();
let labels: Vec<String> = summary.stages.iter().map(|s| s.label.clone()).collect();
assert!(
labels.iter().any(|l| l.starts_with("axlad_c")),
"高温域全链失败后应触发 C 轴丰度延拓(阶段: {:?})",
labels
);
assert!(summary.result_valid, "延拓收敛后结果应有效");
assert!(summary
.stages
.last()
.map(|s| s.converged)
.unwrap_or(false));
let _ = std::fs::remove_dir_all(&work);
}
/// parse_point_name 回归:规范名与 _ladder 后缀名均可解析,乱名返回 None。
#[test]
fn test_parse_point_name() {
let p = crate::models::GridPointParams::parse_point_name("t20000_g5.0_he-2_c-1_n-3_o-4");
assert!(p.is_some());
let p = p.unwrap();
assert_eq!(p.teff.value(), 20000.0);
assert_eq!(p.logg.value(), 5.0);
assert_eq!(p.loghe.value(), -2.0);
// _ladder 合成名(ladder 持久化路径)
let p = crate::models::GridPointParams::parse_point_name(
"t60000_g5.75_he-4_c-4_n-4_o-4_ladder",
);
assert!(p.is_some());
assert!((p.unwrap().logg - 5.75).abs() < 1e-9);
assert!(crate::models::GridPointParams::parse_point_name("garbage").is_none());
assert!(crate::models::GridPointParams::parse_point_name("").is_none());
}
}