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
+47 -13
View File
@@ -555,16 +555,23 @@ const SIGMA_SB: f64 = 5.670374419e-5;
/// 校验 `.emflux` 的 bolometric 通量守恒(见 docs/spectrum_correctness_analysis.md §3.2/§4)。
///
/// # 物理不变量
/// ∫Fλ dλ(出射谱全波长积分)σ_SB × Teff⁴。偏离 >2% → 能量不守恒(红旗 §4)。
/// 总表面通量 F = 4π·∫Hλ dλ σ_SB × Teff⁴。偏离 >2% → 能量不守恒(红旗 §4)。
///
/// # `.emflux` 格式与 4π 修正
/// TLUSTY 在 `OUTPRI``tlusty208.f:14174-14188`)写出的 fort.14 快照(runner
/// `snapshot_tlusty_outputs` 存为 `.emflux`),两列:波长 λ(Å) / Hλ。其中第二列是
/// **Eddington 通量 Hλ**——`tlusty208.f:14174` 源码注释明确 `FLUX(IJ) = the second
/// moment H(freq), ergs/cm²/s/sterad/Hz`(每球面度),而非总表面通量 F=4π·H。
/// 因此积分 ∫Hλ dλ 与 σTeff⁴ 之间相差一个 4π 立体角因子:比值应乘 4π 后才 ≈1.0
/// (实测 ratio≈0.0796 = 1/4π 即源于此)。早期实现漏乘 4π 导致所有点的通量守恒被误判
/// 失败(系统性假阳性,ratio 稳定在 0.0796)。
///
/// # `.emflux` 格式
/// SYNSPEC 写出的 fort.14 快照(runner `snapshot_tlusty_outputs`),两列:波长 λ(Å) / Fλ。
/// 部分样本全 NaN(大气收敛失败导致辐射转移求解发散)—— NaN 占比过高(>50%)直接判失败。
/// 详见 `docs/fort14_nan_analysis.md`fort.14 全 NaN 是大气结构发散的充分(且几乎必要的)标志。
///
/// # 算法
/// 跳过 NaN/不可解析行,对剩余 (λ, Fλ) 按波长排序后梯形积分。NaN 占比 >0.5 → 失败。
/// 否则比较 integrated / (σTeff⁴),偏离 1 超 `tolerance` → 失败。
/// 跳过 NaN/不可解析行,对剩余 (λ, Hλ) 按波长排序后梯形积分。NaN 占比 >0.5 → 失败。
/// 否则比较 4π·∫Hλ dλ / (σTeff⁴),偏离 1 超 `tolerance` → 失败。
///
/// # 返回值
/// `Some(EmfluxCheckResult)`:完成校验(含全 NaN 判失败)。`None`:文件缺失 → 跳过。
@@ -639,8 +646,11 @@ pub fn check_emflux_bolometric(
integral += 0.5 * (f0 + f1) * (lam1 - lam0);
}
// fort.14 存的是 Eddington 通量 Hλ(erg/cm²/s/sterad,见上方 doc),总表面通量
// F = 4π·H,故比值需乘 4π 后才与 σTeff⁴ 比较。漏乘此因子会让所有点稳定地卡在
// ratio≈0.0796(=1/4π)被判失败(系统性假阳性)。
let ratio = if sigma_teff4 > 0.0 {
integral / sigma_teff4
4.0 * std::f64::consts::PI * integral / sigma_teff4
} else {
f64::INFINITY
};
@@ -657,7 +667,7 @@ pub fn check_emflux_bolometric(
None
} else {
Some(format!(
"emflux bolometric 校验失败:∫Fλdλ / σTeff⁴ = {:.4} 偏离 1 超 {:.2}",
"emflux bolometric 校验失败:4π·∫Hλdλ / σTeff⁴ = {:.4} 偏离 1 超 {:.2}",
ratio, tolerance
))
},
@@ -1400,26 +1410,50 @@ mod tests {
#[test]
fn test_emflux_bolometric_pass() {
// 构造一个 ∫Fλdλ ≈ σTeff⁴ 的简化谱:Teff=50000K
// σ×50000⁴ = 5.670374419e-5 × 6.25e18 = 3.544e14 erg/cm²/s
// 用梯形:λ∈[100,1100]Å 共 1001 点,均匀 Fλ 使积分 = 3.544e14
// 积分 = Fλ × (1100-100) = Fλ × 1000。故 Fλ = 3.544e11
// fort.14 存的是 Eddington 通量 Hλ(erg/cm²/s/sterad),总表面通量 F=4π·H
// 构造一个 4π·∫Hλdλ ≈ σTeff⁴ 的简化谱:Teff=50000K
// σ×50000⁴ = 5.670374419e-5 × 6.25e18 = 3.544e14 erg/cm²/s(总通量)
// 用梯形:λ∈[100,1100]Å 共 1001 点,均匀 Hλ 使 4π·∫Hλdλ = 3.544e14
// 4π·Hλ × (1100-100) = 3.544e14 → Hλ = 3.544e14 / (4π × 1000) = σTeff⁴/(4π·1000)。
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("model.emflux");
let mut content = String::new();
let sigma_teff4 = 5.670374419e-5 * 50000.0f64.powi(4);
let flux = sigma_teff4 / 1000.0; // 均匀谱,积分=Fλ×1000
let flux = sigma_teff4 / (4.0 * std::f64::consts::PI * 1000.0); // Hλ,4π·积分=σTeff⁴
for i in 0..1001 {
let lam = 100.0 + i as f64;
content.push_str(&format!("{:>15.3} {:e}\n", lam, flux));
}
std::fs::write(&file_path, content).unwrap();
let res = check_emflux_bolometric(&file_path, 50000.0, 0.02).expect("应解析 emflux");
assert!(res.valid, "均匀谱积分 = σTeff⁴ 应通过");
assert!(res.valid, "4π·均匀谱积分 = σTeff⁴ 应通过");
assert!((res.ratio - 1.0).abs() < 0.01, "比值应≈1.0,实际 {}", res.ratio);
assert_eq!(res.nan_ratio, 0.0);
}
#[test]
fn test_emflux_4pi_correction_regression() {
// 回归保护:未经 4π 修正时,真实 Eddington Hλ 谱的 ratio 稳定在 1/(4π)≈0.0796
// 被误判为能量不守恒。修正后 ratio≈1.0 应通过。
// 构造 Hλ 使 ∫Hλdλ = σTeff⁴/4π(即 ratio 修正前=1/4π,修正后=1.0)。
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("model.emflux");
let sigma_teff4 = 5.670374419e-5 * 40000.0f64.powi(4);
let hlam = sigma_teff4 / (4.0 * std::f64::consts::PI * 1000.0);
let mut content = String::new();
for i in 0..1001 {
content.push_str(&format!("{:>15.3} {:e}\n", 100.0 + i as f64, hlam));
}
std::fs::write(&file_path, content).unwrap();
let res = check_emflux_bolometric(&file_path, 40000.0, 0.02).expect("应解析");
assert!(res.valid, "Eddington Hλ 谱经 4π 修正后应通过");
assert!(
(res.ratio - 1.0).abs() < 0.01,
"4π 修正后 ratio 应≈1.0,实际 {}(若≈0.0796 说明 4π 修正丢失)",
res.ratio
);
}
#[test]
fn test_emflux_bolometric_all_nan() {
// 全 NaN → 大气收敛失败(辐射转移求解发散),应判失败(而非跳过)。
+31 -9
View File
@@ -30,11 +30,16 @@ fn calc_hash(bytes: &[u8]) -> String {
hex::encode(hasher.finalize())
}
/// Ensures Fortran runtime binaries are unpacked and common partition function data files are fetched
/// Ensures Fortran runtime binaries are unpacked and common partition function data files are fetched.
///
/// `default_linelist` 是节点启动时预下载的默认谱线表文件名(如 "gfATO.dat"),放在
/// `runtime_dir` 根目录。SYNSPEC 任务可通过 TaskSpec.linelist 按需覆盖(executor 侧
/// 再下载指定文件)。该参数从 node 启动配置传入(main.rs 硬编码项目级默认)。
pub async fn ensure_runtime(
runtime_dir: &Path,
server_url: &str,
client: &Client,
default_linelist: &str,
) -> Result<RuntimePaths> {
fs::create_dir_all(runtime_dir)
.with_context(|| format!("Failed to create runtime dir: {}", runtime_dir.display()))?;
@@ -42,7 +47,7 @@ pub async fn ensure_runtime(
let tlusty_exe = runtime_dir.join("tlusty_static");
let synspec_exe = runtime_dir.join("synspec_static");
let data_dir = runtime_dir.join("data");
let linelist = runtime_dir.join("gfVIS99.dat");
let linelist = runtime_dir.join(default_linelist);
fs::create_dir_all(&data_dir)?;
@@ -63,18 +68,35 @@ pub async fn ensure_runtime(
];
ensure_specific_data_files(&data_dir, server_url, client, common_files).await?;
// 3. Check gfVIS99.dat
// 3. 下载默认谱线表(如 gfATO.dat)。复用 /api/data/file/{name} 路由,与原子数据
// 文件同机制(原子写、并发安全)。线表文件放在 runtime_dir 根(非 data/ 子目录),
// 故不直接复用 ensure_specific_data_files(它下载到 data_dir),而是内联同等逻辑。
if !linelist.exists() {
let url = format!("{}/api/data/linelist", server_url);
info!("本地缺失主谱线库 gfVIS99.dat,开始从服务端下载: {}...", url);
let url = format!("{}/api/data/file/{}", server_url, default_linelist);
info!(
"本地缺失默认谱线表 {},开始从服务端下载: {}...",
default_linelist, url
);
let resp = client.get(&url).send().await?;
if resp.status().is_success() {
let bytes = resp.bytes().await?;
fs::write(&linelist, &bytes)?;
info!("成功下载并保存主谱线库 gfVIS99.dat");
// 原子写:先 .tmp 再 rename,避免半写崩溃留下截断文件。
let tmp = runtime_dir.join(format!(
"{}.{}.tmp",
default_linelist,
uuid::Uuid::new_v4().simple()
));
fs::write(&tmp, &bytes)?;
fs::rename(&tmp, &linelist)?;
info!(
"成功下载默认谱线表 {} ({} bytes)",
default_linelist,
bytes.len()
);
} else {
anyhow::bail!(
"从服务端下载主谱线库 gfVIS99.dat 失败,HTTP 状态码: {}",
"从服务端下载默认谱线表 {} 失败,HTTP 状态码: {}",
default_linelist,
resp.status()
);
}
@@ -85,7 +107,7 @@ pub async fn ensure_runtime(
let tlusty_exe = abs_runtime_dir.join("tlusty_static");
let synspec_exe = abs_runtime_dir.join("synspec_static");
let data_dir = abs_runtime_dir.join("data");
let linelist = abs_runtime_dir.join("gfVIS99.dat");
let linelist = abs_runtime_dir.join(default_linelist);
Ok(RuntimePaths {
tlusty_exe,
+11 -4
View File
@@ -469,6 +469,12 @@ pub struct TaskSpec {
/// b 因子合理性:极端值下限(默认 1e-3)。
#[serde(default)]
pub bfac_min: Option<f64>,
/// SYNSPEC 谱线表文件名(如 "gfATO.dat")。源自工作流 YAML `linelist`。
/// node 端 executor 据此从服务端按需下载(走 /api/data/file/{name})并 symlink 为
/// fort.19。None → 用节点启动时下载的默认线表(embedded.rs 的 default_linelist 参数)。
/// 旧 payload 反序列化缺省为 None(旧节点用默认线表,无回归)。
#[serde(default)]
pub linelist: Option<String>,
}
/// 仅供测试夹具构造便利:`TaskSpec::default()` 给出合法占位(task_id 零值、
@@ -505,6 +511,7 @@ impl Default for TaskSpec {
convergence_min_ratio: None,
bfac_max: None,
bfac_min: None,
linelist: None,
}
}
}
@@ -683,13 +690,13 @@ pub struct TempStructCheckResult {
/// 辐射转移求解发散,详见 `docs/fort14_nan_analysis.md`)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmfluxCheckResult {
/// 是否通过(积分通量偏离 σTeff⁴ ≤ tolerance 且 NaN 占比可接受)。
/// 是否通过(4π·∫Hλdλ 偏离 σTeff⁴ ≤ tolerance 且 NaN 占比可接受)。
pub valid: bool,
/// 梯形积分 ∫Fλ dλ(erg/cm²/s)。
/// 梯形积分 ∫Hλ dλ(Eddington 通量,erg/cm²/s/sterad)。
pub integrated_flux: f64,
/// 预期 σ_SB × Teff⁴(erg/cm²/s)。
/// 预期 σ_SB × Teff⁴(erg/cm²/s,总表面通量)。
pub sigma_teff4: f64,
/// integrated_flux / sigma_teff4(应 ≈1.0)。
/// 4π·∫Hλdλ / sigma_teff4(应 ≈1.0fort.14 存 Hλ 故需 4π 修正)。
pub ratio: f64,
/// `.emflux` 中 NaN/不可解析行的占比(0.0-1.0)。
pub nan_ratio: f64,
+58 -9
View File
@@ -61,8 +61,8 @@ pub fn generate_nst_content(chain: &ChainStep, global: &TlustyInput) -> String {
}
line1.push(format!("NITER={}", chain.niter));
written_keys.insert("NITER".to_string());
out.push_str(&line1.join(","));
out.push('\n');
// 行宽保护(NST_LINE_MAX):ITEK 等追加后 line1 可超 75 字符,同样需要换行保护。
push_wrapped(&mut out, &line1);
// ── 第 2 行:加速/收敛控制开关 + IELCOR(恒输出,与旧实现一致)──
let mut line2: Vec<String> = Vec::new();
@@ -84,8 +84,7 @@ pub fn generate_nst_content(chain: &ChainStep, global: &TlustyInput) -> String {
}
line2.push(format!("IELCOR={}", n.physics.ielcor));
written_keys.insert("IELCOR".to_string());
out.push_str(&line2.join(","));
out.push('\n');
push_wrapped(&mut out, &line2);
// ── 第 3 行起:global.nst 高频关键字(非默认 ChainStep 覆盖的)──
// 所有值统一格式化为 String,避免 i32/f64 混合数组的类型问题。浮点用 fmt_real 带小数点。
@@ -163,7 +162,40 @@ pub fn generate_nst_content(chain: &ChainStep, global: &TlustyInput) -> String {
out
}
/// 写出一组 nst 关键字(同一行逗号分隔),跳过已写的 key。
/// nst 单行最大长度。TLUSTY 读 nst 的行缓冲是 `CHARACTER*80 TEXT`
/// tlusty208.f:1689`FORMAT(A)` 整行读入),超长部分被**静默截断**——
/// 旧版曾把 18 个关键字拼成 158 字符的行,导致第 80 字符之后的
/// IFALI/IFPOPR/JALI/TRAD/WDIL/TFLOOR/TDISK/TMOLIM 全部从未生效(走 TLUSTY 默认)。
/// 解析器逐词跨行续读(词在行尾读完即读下一行),关键字组拆多行语义不变。
/// 2026-08-14 修复,见 docs/tlusty_coldstart_nc_trace_illcond_2026_08_13.md §六·五。
const NST_LINE_MAX: usize = 75;
/// 把若干 `KEY=VALUE` 词以逗号连接追加到 out,超过 [`NST_LINE_MAX`] 自动换行。
fn push_wrapped(out: &mut String, parts: &[String]) {
if parts.is_empty() {
return;
}
let mut line = String::new();
for p in parts {
if !line.is_empty()
&& line.chars().count() + 1 + p.chars().count() > NST_LINE_MAX
{
out.push_str(&line);
out.push('\n');
line.clear();
}
if !line.is_empty() {
line.push(',');
}
line.push_str(p);
}
if !line.is_empty() {
out.push_str(&line);
out.push('\n');
}
}
/// 写出一组 nst 关键字(自动换行,行宽 ≤ [`NST_LINE_MAX`]),跳过已写的 key。
/// 用 HashSet::insert 返回值做单次遍历去重,避免 entries 内同键重复时两次都写出。
fn write_nst_group(out: &mut String, written: &mut HashSet<String>, entries: &[(&str, String)]) {
let parts: Vec<String> = entries
@@ -171,10 +203,7 @@ fn write_nst_group(out: &mut String, written: &mut HashSet<String>, entries: &[(
.filter(|(k, _)| written.insert(k.to_string()))
.map(|(k, v)| format!("{}={}", k, v))
.collect();
if !parts.is_empty() {
out.push_str(&parts.join(","));
out.push('\n');
}
push_wrapped(out, &parts);
}
#[cfg(test)]
@@ -260,6 +289,26 @@ mod tests {
assert!(content.contains("OK=1"));
}
/// 回归(2026-08-14):所有行必须 ≤75 字符(TLUSTY CHARACTER*80 行缓冲,
/// 旧版 158 字符行致 IFALI/TFLOOR 等被静默截断失效)。
#[test]
fn test_nst_all_lines_within_80_char_buffer() {
let content = generate_nst_content(&chain_nc(), &TlustyInput::default());
for (i, line) in content.lines().enumerate() {
assert!(
line.chars().count() <= 75,
"nst 第 {} 行超宽({} 字符 >75,会被 TLUSTY 80 字符缓冲截断): {}",
i + 1,
line.chars().count(),
line
);
}
// 关键尾部关键字必须在输出中(旧 bug 里它们被截断丢失)
assert!(content.contains("TMOLIM="), "TMOLIM 必须在 nst 输出中");
assert!(content.contains("TFLOOR="), "TFLOOR 必须在 nst 输出中");
}
/// 向后兼容:默认配置生成的 nst 前 2 行与旧实现字节级一致。
/// 旧实现 line1 = "ND=50,NLAMBD=3,VTB=2.,ISPODF=1,DDNU=50.,CNU1=6.,NITER=10"
/// 旧实现 line2 = "IELCOR=-1"
+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);
}
}
+19 -4
View File
@@ -45,10 +45,14 @@ pub fn calculate_seed_distance(cand: &GridPointParams, target: &GridPointParams)
let d_loghe = (cand.loghe.value() - target.loghe.value()).abs();
// exact family 判定:Teff/logg/logHe 视为“同物理族”,仅 CNO 丰度不同。
// Teff 容忍度取半步 5000K:实际网格 Teff 档位通常为整数千(20000/30000/.../60000),
// 半步既能覆盖 config_dense 等 10000K 步长的相邻档互作种子,
// 又避免跨过大 Teff 间距导致 sdB 高温模型用低温种子而不收敛(sdB_cno 步长 40000K 仍不命中 exact)。
if d_teff < 5000.0 && d_logg < 0.01 && d_loghe < 0.01 {
// Teff 容忍度取一个网格步长 5000K(含边界 <=):
// 网格 Teff 档位间隔正好 5000K(如 55000↔60000 相邻档),旧版严格 `< 5000.0`
// 把所有相邻 Teff 档互为种子静默排除出 exact_family——而实测 Teff 方向是最稳的
// 种子方向(55k 点靠 50k Teff 种子收敛;50k→60k 直接 nl 亦收敛),且相邻档
// 同(logg,He,CNO)种子在 exact_family 内有向距离为 0(优于任何同 Teff CNO 邻居)。
// 桶索引(db::SeedBucketKey)本就按 floor/floor+1 双写,5000K 整除边界可命中。
// 2026-08-14 修复,详见 docs/tlusty_coldstart_nc_trace_illcond_2026_08_13.md。
if d_teff <= 5000.0 && d_logg < 0.01 && d_loghe < 0.01 {
// 同物理族内仅 CNO 不同:用有向距离优先匹配贫金属方向的种子(见 directed_cno_distance)。
(true, directed_cno_distance(cand, target))
} else {
@@ -137,4 +141,15 @@ mod tests {
assert!(!is_exact, "跨 teff 20000K 应为 global 分支");
assert!(d > 0.0);
}
/// 回归(2026-08-14):d_teff 恰为 5000K(相邻 Teff 档,如 55000↔60000
/// 应算 exact_family(旧版严格 < 排除了所有相邻档互为种子)。
#[test]
fn test_adjacent_teff_bucket_is_exact_family() {
let seed = params(55000.0, 5.0, -2.0, -4.0, -3.0, -3.0);
let target = params(60000.0, 5.0, -2.0, -4.0, -3.0, -3.0);
let (is_exact, d) = calculate_seed_distance(&seed, &target);
assert!(is_exact, "d_teff=5000 的相邻 Teff 档应属 exact_family");
assert_eq!(d, 0.0, "同 (logg,He,CNO) 的相邻 Teff 种子有向距离应为 0");
}
}