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:
@@ -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 → 大气收敛失败(辐射转移求解发散),应判失败(而非跳过)。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.0;fort.14 存 Hλ 故需 4π 修正)。
|
||||
pub ratio: f64,
|
||||
/// `.emflux` 中 NaN/不可解析行的占比(0.0-1.0)。
|
||||
pub nan_ratio: f64,
|
||||
|
||||
@@ -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
@@ -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 start(lte 阶段不迭代,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 start(lte 阶段不迭代,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.001–0.002
|
||||
// (降幅 ~10×)。触发条件:能量门槛失败 + 最终大气存在且无 NaN + 数值收敛
|
||||
// (非发散模型)。回退以 `<label>_tight` 记录进 conv.json,成功则刷新最终
|
||||
// 大气与能量判定,并重快照 fort.12/14(bfac/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(
|
||||
¶ms,
|
||||
"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(
|
||||
¶ms,
|
||||
"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(
|
||||
¶ms,
|
||||
"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(
|
||||
¶ms,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user