feat(all): 科学计算正确性修复、调度竞态消除、节点优雅退出、安全加固与收敛分析重构

科学计算正确性:
- 修复 Fortran 无-E 科学记数法(指数≥100 时 E 被挤掉,如 -1.35+118)导致
  发散行被静默跳过、误判收敛的 bug;扩展大气无效检测覆盖 Inf 与 *** 溢出标记
- 种子匹配改为 CNO 有向距离(富金属方向重罚 4×、贫金属方向轻罚 1×),
  基于 1191 个真实种子配对回测标定,回测净改善 314 个点
- GridAxisValue 反序列化拒绝非法文本(不再静默 NaN);chmax≤0 显式报错
- ions 行宽列宽对齐真实 fort.5 格式

调度与队列竞态:
- 原子选点(IMMEDIATE 事务 SELECT+UPDATE)消除并发调度重复派发 (#5)
- 调度互斥锁 + 冷启动优先策略(SeedStep 仅作失败后救援,不再正常路径热启动)
- 毒消息 dead_letter 标记防出队死循环;clear_queue 保留 claimed 行 (#6)
- 孤儿 running 点回收兜底;stale_sec 默认 7800→21600s(3 倍超时缓冲)

节点生命周期:
- SIGTERM+SIGINT 双信号监听(修复 Docker stop 发 SIGTERM 不触发优雅退出)
- SlotGuard RAII 防活动 slot 泄漏;子进程超时增加二级 30s wait 防 Fortran hang
- SeedStep 种子下载 fail-fast + 沙盒私有副本解耦 LRU 清理竞争
- reqwest Client 增加连接/请求超时;启动清理残留 task_* 沙盒

安全加固:
- 节点注册 registration_secret 二次凭据 (H8),恒定时间比对防时序旁路
- token 缓存 generation 机制消除 reissue 后旧 token TOCTOU 复活窗口
- 新增 /api/auth/logout 服务端 session 即时撤销;fail-closed 鉴权启动策略
- 前端 token 迁移 sessionStorage;YAML 高亮改 DOM API 消除 XSS 注入面
- 备份文件权限收紧 0600;点表动态值全面 escapeHtml

前端 Dashboard:
- 收敛性分析从热力图重构为 Parallel Sets 平行集合图(6 维+状态轴,手写 SVG 零依赖)
- 进度曲线横轴改为真实时间(服务端 now 锚定,停滞期诚实留白);轮询指数退避
- 移除 imported 收敛途径分类,导入点按实际途径 cold_run/seed_step 归类
- 初始化时 /api/auth/check 校验 token;401 toast 提示替代静默 reload

服务端恢复与工具链:
- 启动恢复 initializing 态工作流;默认工作流 INSERT-only 不覆盖 API 编辑
- body limit 分层(10MB 不再截断 256MB report);multipart 显式错误处理
- 嵌入二进制原子写(tmp+rename)防半写损坏
- import_results 判定收敛途径透传 success_method;conv.json 格式对齐本项目
- push_import_results.sh 退出码修复 + .bat UTF-8 BOM + scp 上传
- Docker USE_MIRRORS 默认关闭;移除无用 assets 挂载;删除 hosts.ini 入库
This commit is contained in:
fmq
2026-08-01 17:01:40 +08:00
parent 1bfa240cb0
commit c8fd24b120
45 changed files with 2821 additions and 831 deletions
+19 -2
View File
@@ -328,11 +328,28 @@ impl Default for ServerConfig {
std::env::var("DCTS_BACKUP_DIR").unwrap_or_else(|_| "data/backups".to_string());
let grid_config = std::env::var("DCTS_GRID_CONFIG")
.unwrap_or_else(|_| "workflows/sdB_cno.yaml".to_string());
// 默认设置为 7800 秒,比计算任务默认超时(7200 秒)高 600 秒缓冲,避免两边的超时检测同时触发冲突
// 默认设置为 21600 秒6 小时),是计算任务默认超时(7200 秒)的 3 倍缓冲。
// 历史值 7800s 仅比 7200s 高 600s,在科学计算网格的长尾难收敛点上极易出现:
// 一个耗时接近 timeout 的任务,在上报链路抖动/排队时被后台 requeue_stale_tasks
// 重置为 pending → 原节点完成上报时 verify_task_claim 因 status≠claimed 拒绝 →
// 昂贵的计算结果被静默丢弃、重投重算。3 倍缓冲可基本消除该竞态。
// 若显式配置了 DCTS_STALE_SEC,但小于 timeout 默认 7200 的 1.5 倍,给出告警。
let stale_sec = std::env::var("DCTS_STALE_SEC")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(7800);
.unwrap_or(21600);
const DEFAULT_TIMEOUT_SEC: u64 = 7200;
if stale_sec < DEFAULT_TIMEOUT_SEC * 3 / 2 {
tracing::warn!(
"DCTS_STALE_SEC={} 过小(小于默认 timeout {} 的 1.5 倍 {}):\
耗时接近 timeout 的任务在上报前可能被重投,导致计算结果被丢弃重算。\
建议 ≥ {}timeout 的 3 倍)。",
stale_sec,
DEFAULT_TIMEOUT_SEC,
DEFAULT_TIMEOUT_SEC * 3 / 2,
DEFAULT_TIMEOUT_SEC * 3
);
}
let node_stale_sec = std::env::var("DCTS_NODE_STALE_SEC")
.ok()
.and_then(|v| v.parse::<u64>().ok())
+174 -8
View File
@@ -7,6 +7,44 @@ use std::sync::OnceLock;
static FORT9_RE: OnceLock<Regex> = OnceLock::new();
static NAN_RE: OnceLock<Regex> = OnceLock::new();
/// 匹配 Fortran 无-E 科学记数法的尾数+指数部分(归一化用,见 parse_fortran_float)。
static NO_E_EXP_RE: OnceLock<Regex> = OnceLock::new();
/// 解析 fort.9 / fort.7 中的数值字符串为 f64,兼容 Fortran 的**无-E 科学记数法**。
///
/// # 背景
/// Fortran 在数值指数 ≥ 100 时会输出省略 `E` 的科学记数法(字段宽度不够把 E 挤掉),
/// 例如把 `-1.35E+118` 输出成 `-1.35+118`、`9.99E+108` 输出成 `9.99+108`。这出现在
/// tlusty 迭代发散时的 fort.9 中(布居数变化达 ±100~±200 量级)。
///
/// 历史问题:直接 `s.parse::<f64>()` 对 `-1.35+118` 返回 ErrRust 的 f64::from_str
/// 不认无-E 记数法),旧代码 `Err(_) => continue` 会**静默跳过该行**。若被跳过的恰好是
/// 本次迭代最差的 depth 行,会低估 max_relc,把发散误判为收敛(科学正确性 bug)。
///
/// # 规则
/// 1. 先尝试标准 `parse`(覆盖所有正常情况:整数、小数、带 E/e 的科学记数法)。
/// 2. 失败则归一化:形如 `[前导符号?]<尾数>[+/-]<指数>`(无 E)的,在尾部符号前插 E
/// 再 parse。如 `-2.37+192` → `-2.37E+192`。
/// 3. 指数导致 f64 上溢时返回 ±Infinity(对收敛判断是正确的:发散值 → max_relc=Inf →
/// is_finite()=false → converged=false,标记为未收敛而非崩溃)。
fn parse_fortran_float(s: &str) -> Option<f64> {
let s = s.trim();
// 1. 标准 parse
if let Ok(v) = s.parse::<f64>() {
return Some(v);
}
// 2. 归一化无-E 记数法:[前导符号?]<尾数>(含小数点或多位数字)[+/-]<指数>
let re = NO_E_EXP_RE.get_or_init(|| {
Regex::new(r"^([+-]?[\d.]+)([+-]\d+)$").unwrap()
});
if let Some(caps) = re.captures(s) {
let normalized = format!("{}E{}", &caps[1], &caps[2]);
if let Ok(v) = normalized.parse::<f64>() {
return Some(v); // 含 Inf/-Inf(溢出)
}
}
None
}
#[derive(Debug, Clone)]
struct Fort9Row {
@@ -16,6 +54,24 @@ struct Fort9Row {
/// Parses `fort.9` and evaluates convergence against `chmax`
pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
// M10:非法 chmax 防御。chmax ≤ 0(误配为负或 0)会使 `max_relc < chmax` 永远为假,
// 整个工作流的所有 stage 都永不收敛却无任何提示,表现为"全部失败"难以定位。
// 在此显式拒绝并返回错误,让调用方/日志能立即看出是配置问题而非物理发散。
if !chmax.is_finite() || chmax <= 0.0 {
return ConvCheckResult {
converged: false,
max_relc: f64::INFINITY,
worst_depth: -1,
last_iter: None,
n_depths: 0,
chmax,
error: Some(format!(
"非法 chmax={}(须为正有限数):请检查工作流 YAML 中该 stage 的 chmax 配置",
chmax
)),
};
}
let file = match File::open(path) {
Ok(f) => f,
Err(e) => {
@@ -52,9 +108,9 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
Ok(v) => v,
Err(_) => continue,
};
let maximum: f64 = match caps[7].parse() {
Ok(v) => v,
Err(_) => continue,
let maximum: f64 = match parse_fortran_float(&caps[7]) {
Some(v) => v,
None => continue,
};
if cur_iter != Some(iter) {
@@ -120,7 +176,16 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
}
}
/// Checks if an atmosphere file (.7) contains NaN lines (>10% NaN lines = invalid) using exact word boundary
/// Checks if an atmosphere file (.7) contains invalid numeric lines (NaN / Inf / Fortran overflow `***`).
///
/// 检测内容:
/// - NaNFortran 写出 `NaN`/`nan`/`NAN`,数值发散的典型产物)。
/// - Inf / InfinityFortran 除零/溢出)。
/// - Fortran 字段宽度溢出标记 `***`(如 `********`):Tlusty 数值溢出发散时常以星号填满
/// 字段而非写 NaN。历史上只检 `\bnan\b`,全溢出发散的大气会被判"无 NaN"→converged
/// 产出物理上完全错误的大气。
///
/// 超过 10% 的行命中任一标记即判定无效。
///
/// 文件缺失时返回 `false`(语义:不存在 NaN 内容)。这与“含 NaN 导致无效”是不同语义;
/// 调用方需先自行确认文件存在性,不应将“缺失”与“含 NaN”混为一谈。
@@ -131,13 +196,15 @@ pub fn atmosphere_has_nan(path: &Path) -> bool {
};
let reader = BufReader::new(file);
let mut total_lines = 0;
let mut nan_lines = 0;
let nan_re = NAN_RE.get_or_init(|| Regex::new(r"(?i)\bnan\b").unwrap());
let mut bad_lines = 0;
let nan_re = NAN_RE.get_or_init(|| {
Regex::new(r"(?i)(\bnan\b|\binf(?:inity)?\b|\*{3,})").unwrap()
});
for line in reader.lines().map_while(Result::ok) {
total_lines += 1;
if nan_re.is_match(&line) {
nan_lines += 1;
bad_lines += 1;
}
}
@@ -145,7 +212,7 @@ pub fn atmosphere_has_nan(path: &Path) -> bool {
return true;
}
(nan_lines as f64) > (total_lines as f64 * 0.1)
(bad_lines as f64) > (total_lines as f64 * 0.1)
}
#[cfg(test)]
@@ -171,5 +238,104 @@ mod tests {
// Missing file returns false (absence != contains NaN)
let missing_path = dir.path().join("missing.7");
assert!(!atmosphere_has_nan(&missing_path));
// M11: Fortran 字段溢出标记 ***(数值发散时的常见形态,历史上漏检)
let overflow_file_path = dir.path().join("overflow.7");
std::fs::write(&overflow_file_path, "******** 2 3\n******** 5 6\n7 8 9\n").unwrap();
assert!(atmosphere_has_nan(&overflow_file_path));
// M11: Inf / Infinity 也应被识别为无效数值
let inf_file_path = dir.path().join("inf.7");
std::fs::write(&inf_file_path, "Inf 2 3\nInfinity 5 6\n7 8 9\n").unwrap();
assert!(atmosphere_has_nan(&inf_file_path));
}
/// 验证 parse_fortran_float 对各种数值格式(含 Fortran 无-E 记数法)的解析。
#[test]
fn test_parse_fortran_float() {
// 标准 parse 能覆盖的
assert_eq!(parse_fortran_float("100"), Some(100.0));
assert_eq!(parse_fortran_float("-0.001"), Some(-0.001));
assert_eq!(parse_fortran_float("3.14"), Some(3.14));
assert_eq!(parse_fortran_float("-5.42E+72"), Some(-5.42e72));
assert_eq!(parse_fortran_float("1.5e-99"), Some(1.5e-99));
assert_eq!(parse_fortran_float("0"), Some(0.0));
// Fortran 无-E 记数法(指数≥100 时 E 被挤掉)
assert_eq!(parse_fortran_float("-2.37+192"), Some(-2.37e192));
assert_eq!(parse_fortran_float("-1.35+118"), Some(-1.35e118));
assert_eq!(parse_fortran_float("9.99+108"), Some(9.99e108));
// 负指数的无-E 记数法
assert_eq!(parse_fortran_float("1.5-99"), Some(1.5e-99));
// 指数导致 f64 上溢 → Inf(发散值,应被识别为无效→未收敛)
assert_eq!(parse_fortran_float("1.5+400"), Some(f64::INFINITY));
assert_eq!(parse_fortran_float("-9.99+500"), Some(f64::NEG_INFINITY));
// Rust 的 f64::from_str 接受 "NaN"/"inf",返回 NaN/Inf(非 None)。
// 这些在 check_fort9 中会被 is_finite() 判为无效 → converged=false,行为正确。
assert!(parse_fortran_float("NaN").map(|v| v.is_nan()).unwrap_or(false));
assert_eq!(parse_fortran_float("inf"), Some(f64::INFINITY));
// 无法解析的垃圾 → None(调用方 continue 跳过)
assert_eq!(parse_fortran_float("abc"), None);
assert_eq!(parse_fortran_float("--1.0"), None);
assert_eq!(parse_fortran_float("1.2.3"), None);
}
/// 回归测试:fort.9 含 Fortran 无-E 记数法的发散行不得被静默跳过。
///
/// 历史 bug:旧代码 `caps[7].parse::<f64>()` 对 `-1.35+118` 返回 Err → `continue`
/// 跳过该行。若被跳过的是最差 depth 行,会低估 max_relc,把发散误判为收敛。
/// 修复后:无-E 记数法被正确归一化为 -1.35E+118,发散行参与 max_relc 计算 →
/// converged=false(而非崩溃或误判收敛)。
#[test]
fn test_fort9_with_no_e_notation_diverged() {
let dir = tempfile::tempdir().unwrap();
let fort9 = dir.path().join("diverged.9");
// 构造一个发散的 fort.9:某次迭代的 maximum 列含无-E 记数法(极大值)。
// 格式:iter depth fr0 chg tcorr pop max_relc ...max_relc 是 caps[7]
// 这里 caps[7] 放 -1.35+118(无-E= -1.35E+118,发散)。
std::fs::write(
&fort9,
// 行格式参照真实 fort.9:两列整数后跟若干数值列 + 末尾两列整数
// 第7个数值字段(caps[7]) 是 maximum
" 10 1 0 1.0E-3 1.0E-3 1.0E-3 -1.35+118 5 10\n\
10 2 0 1.0E-3 1.0E-3 1.0E-3 2.0E-4 5 10\n",
)
.unwrap();
let res = check_fort9(&fort9, 0.001);
// 发散行(-1.35E+118)未被静默跳过,max_relc 应为 1.35e118(极大,远超 chmax
assert!(
!res.converged,
"含发散行(无-E 记数法)的迭代不得被误判为收敛"
);
assert!(
res.max_relc > 1.0e100,
"发散行应参与 max_relc 计算,实际 max_relc={}",
res.max_relc
);
assert!(
res.error.is_none(),
"归一化后的发散值不应产生解析错误,实际 error={:?}",
res.error
);
}
/// 验证正常收敛的 fort.9(无无-E 记数法)不受影响。
#[test]
fn test_fort9_normal_converged_unchanged() {
let dir = tempfile::tempdir().unwrap();
let fort9 = dir.path().join("converged.9");
// max_relc(caps[7]) 远小于 chmax → 收敛
std::fs::write(
&fort9,
" 10 1 0 1.0E-3 1.0E-3 1.0E-3 1.0E-5 5 10\n",
)
.unwrap();
let res = check_fort9(&fort9, 0.001);
assert!(res.converged, "正常收敛行应判为收敛");
assert!((res.max_relc - 1.0e-5).abs() < 1e-15);
}
}
+18 -5
View File
@@ -151,17 +151,30 @@ fn write_if_changed(target_path: &Path, content: &[u8], executable: bool) -> Res
};
if should_write {
let mut file = File::create(target_path)?;
file.write_all(content)?;
file.flush()?;
// 原子写:先写 .tmp 再 rename,避免半写后崩溃留下截断/损坏的可执行二进制。
// 历史上直接 File::create 覆盖目标,若 write_all 中途进程被杀/磁盘满,会留下
// 截断的 tlusty_static,且权限可能已设 0o755(可执行但损坏),node 尝试运行时
// 产生难以定位的 Fortran 崩溃。tmp + rename 保证目标要么是完整旧版、要么是完整新版。
let tmp_path = target_path.with_extension("tmp.write");
{
let mut file = File::create(&tmp_path)
.with_context(|| format!("创建临时文件失败: {}", tmp_path.display()))?;
file.write_all(content)?;
file.flush()?;
// drop 前 sync_all 确保数据落盘,降低断电丢数据的概率。
let _ = file.sync_all();
}
#[cfg(unix)]
if executable {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(target_path)?.permissions();
let mut perms = fs::metadata(&tmp_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(target_path, perms)?;
fs::set_permissions(&tmp_path, perms)?;
}
fs::rename(&tmp_path, target_path)
.with_context(|| format!("原子重命名 {} -> {} 失败", tmp_path.display(), target_path.display()))?;
}
Ok(())
+65 -3
View File
@@ -239,12 +239,23 @@ pub fn make_input5(
for ion in &ions {
let ilast = if ion.nlevs == 1 { 1 } else { 0 };
let ilvl = if ion.nlevs == 1 { 0 } else { ilvlin };
// ions 行字段右对齐到固定列,与真实 tlusty 输入文件
// (tests/tlusty/hhe/fort.5) 逐字节一致:
// iat 结束于 col4(|iat|=4), iz col10(+6), nlevs col16(+6),
// ilast col23(+7), ilvl col30(+7), nonstd col37(+7)。
// 实测:此宽列宽与窄列宽对 tlusty 输出(fort.7/9 等)完全相同(list-directed I/O
// 列宽无关),但对齐真实文件便于与历史参考 diff、符合 tlusty 官方输入惯例。
// 历史上曾用 `" {} {:2} {:5}..."`(窄列宽,与 Python 旧实现一致但偏离真实 fort.5)。
ions_block.push_str(&format!(
" {:2} {:2} {:5} {:5} {:5} 0 '{}' '{}'\n",
ion.iat, ion.iz, ion.nlevs, ilast, ilvl, ion.typion, ion.filei
"{:>4}{:>6}{:>6}{:>7}{:>7}{:>7} '{}' '{}'\n",
ion.iat, ion.iz, ion.nlevs, ilast, ilvl, 0, ion.typion, ion.filei
));
}
ions_block.push_str(" 0 0 0 -1 0 0 ' ' ' '\n");
// 终止行:(iat,iz,nlevs,ilast,ilvl,nonstd)=(0,0,0,-1,0,0)typion/filei 全空。
ions_block.push_str(&format!(
"{:>4}{:>6}{:>6}{:>7}{:>7}{:>7} '{}' '{}'\n",
0, 0, 0, -1, 0, 0, " ", " "
));
format!(
"{:.1} {:.1} ! TEFF, GRAV\n \
@@ -285,4 +296,55 @@ mod tests {
assert!(input5.contains("data/n1.dat"));
assert!(input5.contains("data/o1_23+10lev.dat"));
}
/// ions 行必须与真实 tlusty 输入文件 (tests/tlusty/hhe/fort.5) 的宽列宽逐字节一致。
/// 字段右对齐到固定列:iat→col4, iz→col10, nlevs→col16, ilast→col23, ilvl→col30, nonstd→col37。
/// 一旦此处失败,说明 ions 格式被改动且偏离了真实 tlusty 输入惯例(虽不影响 list-directed
/// 解析结果,但破坏与历史参考文件的可 diff 性)。
#[test]
fn test_ions_block_byte_perfect_with_real_fort5() {
// H+He 仅(metals="")对应真实 hhe fort.5 的 ions 集。
let params = GridPointParams {
teff: 35000.0.into(),
logg: 4.0.into(),
loghe: (-2.0).into(),
logc: (-1.0).into(),
logn: (-1.0).into(),
logo: (-1.0).into(),
};
let input5 = make_input5(&params, "T", "T", "", 100);
let lines: Vec<&str> = input5.lines().collect();
// 真实 fort.5 的 ions 数据行(数值部分 + typion/filei)。
// 注:filei 真实用 './data/...'Rust/Python 生成用 'data/...'DCTS 靠 data 软链解析),
// 这是既有独立差异,本测试只校验数值列宽 + typion。
let expected_ions_num: &[&str] = &[
" 1 0 9 0 100 0",
" 1 1 1 1 0 0",
" 2 0 14 0 100 0",
" 2 1 14 0 100 0",
" 2 2 1 1 0 0",
" 0 0 0 -1 0 0",
];
// 从生成结果中提取 ions 数据行的数值部分(引号前)。
let gen_ions_num: Vec<String> = lines
.iter()
.filter(|l| {
// ions 数据行:含引号且首 token 是整数
l.contains('\'')
&& l
.split_whitespace()
.next()
.map(|t| t.parse::<i32>().is_ok())
.unwrap_or(false)
})
.map(|l| l.split('\'').next().unwrap().trim_end().to_string())
.collect();
assert_eq!(
gen_ions_num.as_slice(),
expected_ions_num,
"ions 行数值部分必须与真实 fort.5 逐字节一致(宽列宽)"
);
}
}
+36 -2
View File
@@ -152,12 +152,25 @@ impl<'de> Deserialize<'de> for GridAxisValue {
}
// 主路径:YAML/JSON 标量原文(`5.0`、`20000`、`-2`、`"-4"`)。
// 文本必须可解析为 f64,否则返回反序列化错误——而非静默退化为 NaN。
// 旧实现对不可解析文本(如 YAML 里写成 `abc`)静默产生 NaN,NaN 随后流入
// cno_sum 求和、调度排序(partial_cmp(...).unwrap_or(Equal) 视 NaN 为相等)、
// 种子距离计算,导致难以定位的诡异行为。
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
Ok(GridAxisValue::from_text(v))
match v.parse::<f64>() {
Ok(value) => Ok(GridAxisValue {
value,
text: v.into(),
}),
Err(_) => Err(serde::de::Error::custom(format!(
"网格轴值不是合法数值: {:?}",
v
))),
}
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Self::Value, E> {
Ok(GridAxisValue::from_text(&v))
self.visit_str(&v)
}
// 兜底路径:纯数值来源(无原文)。先尝试解析原 deserializer 文本不可得,
@@ -614,6 +627,27 @@ mod tests {
assert!((sum - 6.0).abs() < 1e-9);
}
/// 回归:反序列化路径必须拒绝不可解析为 f64 的文本,而非静默退化为 NaN。
///
/// 旧实现 `from_text` 对非法文本用 `unwrap_or(f64::NAN)`NaN 随后流入 cno_sum
/// 求和、调度排序(partial_cmp().unwrap_or(Equal) 视 NaN 为相等)、种子距离计算,
/// 造成难以定位的诡异行为。现在字符串反序列化路径显式校验并返回错误。
#[test]
fn test_grid_axis_value_rejects_non_numeric_text() {
// 合法数值文本仍正常解析并保留源精度
let ok: Result<GridAxisValue, _> = serde_json::from_str("\"5.0\"");
assert!(ok.is_ok());
assert!((ok.unwrap().value() - 5.0).abs() < 1e-9);
// 非法文本应被拒绝(不再静默产生 NaN)
let bad: Result<GridAxisValue, _> = serde_json::from_str("\"abc\"");
assert!(bad.is_err(), "非法文本应触发反序列化错误而非产生 NaN");
// 确认不会再产生 NaN 值
let nan_text: Result<GridAxisValue, _> = serde_json::from_str("\"NaN-text\"");
assert!(nan_text.is_err());
}
#[test]
fn test_grid_point_status_display_and_conversion() {
assert_eq!(GridPointStatus::Pending.to_string(), "pending");
+111 -40
View File
@@ -98,20 +98,81 @@ pub fn default_seed_chain() -> Vec<StageConfig> {
]
}
/// 运行子进程,带超时与优雅退出(shutdown)感知。
///
/// 三种终止路径:
/// 1. 子进程正常结束 → 返回 ExitStatus。
/// 2. 超时(timeout_sec)→ SIGKILL 子进程 + 二级 30s 等待 reap,超时则放弃 Childkill_on_drop 兜底)。
/// 3. shutdown 信号(节点收到 SIGTERM/SIGINT)→ 立即 SIGKILL 子进程并快速返回 Err
/// 让上层尽快退出(在途任务的结果会丢失,由服务端 stale 重投兜底)。
///
/// 历史 bug:超时 kill 后 `child.wait().await` 无二级超时,Fortran 进程若卡死
/// OpenMP hang / ptrace)会使 wait 永久阻塞,超时机制名存实亡、slot 永久泄漏。
async fn run_child_async_with_timeout(
mut child: tokio::process::Child,
timeout_sec: u64,
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Result<std::process::ExitStatus> {
match tokio::time::timeout(tokio::time::Duration::from_secs(timeout_sec), child.wait()).await {
Ok(res) => Ok(res?),
Err(_) => {
let timeout_fut = tokio::time::timeout(
tokio::time::Duration::from_secs(timeout_sec),
child.wait(),
);
// 若提供了 shutdown 标志,则与超时/正常结束三路 select;否则只等超时/正常结束。
let outcome: Result<std::process::ExitStatus, ShutdownOrTimeout> = if let Some(flag) = shutdown {
let shutdown_watcher = async move {
// 轮询 shutdown 标志(10ms 粒度足够灵敏,开销可忽略)。
loop {
if flag.load(std::sync::atomic::Ordering::Acquire) {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
};
tokio::select! {
biased; // 优先响应 shutdown
_ = shutdown_watcher => Err(ShutdownOrTimeout::Shutdown),
r = timeout_fut => match r {
Ok(res) => Ok(res?),
Err(_) => Err(ShutdownOrTimeout::Timeout),
},
}
} else {
match timeout_fut.await {
Ok(res) => Ok(res?),
Err(_) => Err(ShutdownOrTimeout::Timeout),
}
};
match outcome {
Ok(status) => Ok(status),
Err(ShutdownOrTimeout::Shutdown) => {
let _ = child.start_kill();
let _ = child.wait().await;
let _ = tokio::time::timeout(
std::time::Duration::from_secs(30),
child.wait(),
)
.await;
anyhow::bail!("节点收到退出信号,子进程已被终止");
}
Err(ShutdownOrTimeout::Timeout) => {
let _ = child.start_kill();
let _ = tokio::time::timeout(
std::time::Duration::from_secs(30),
child.wait(),
)
.await;
anyhow::bail!("进程计算超时 (上限: {} 秒)", timeout_sec);
}
}
}
#[derive(Debug)]
enum ShutdownOrTimeout {
Shutdown,
Timeout,
}
pub struct ExecutionRunner<'a> {
pub runtime: &'a RuntimePaths,
pub work_dir: PathBuf,
@@ -139,6 +200,7 @@ impl<'a> ExecutionRunner<'a> {
seed_atmos,
synspec_cfg,
7200,
None,
)
.await
}
@@ -153,6 +215,7 @@ impl<'a> ExecutionRunner<'a> {
seed_atmos: Option<&Path>,
synspec_cfg: Option<&SynspecConfig>,
timeout_sec: u64,
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Result<ModelSummary> {
// `name` 取自权威的 TaskSpec.point_nameDB 的 grid_points.name 列,源精度正确),
// 而非 params.model_name()。原因:服务端把 GridPointParams 存成 6 个 REAL 数值列,
@@ -160,14 +223,10 @@ impl<'a> ExecutionRunner<'a> {
// 产出错误名(g5 而非 g5.0)。point_name 走独立 TEXT 列,精度全程保留。
// 下游(沙盒子目录、各阶段快照、conv.json.name、归档目录)全部用此 name,
// 故只需在此处用权威 name 即可让整条链精度正确。
let derived = params.model_name();
if derived != name {
warn!(
"网格点权威名 {} 与 params 重推名 {} 不一致(DB REAL 列回读丢精度所致),\
采用权威 point_name",
name, derived
);
}
//
// 历史:此处曾把 params.model_name() 与 name 对比并 warn 不一致。但该不一致是
// DB REAL 列回读丢精度的已知现象(runner 端无法修复,根治需改 DB schema 存原文),
// 且 runner 已全程采用权威 name,对比结果不参与任何决策——故移除这段噪音 warn。
let model_dir = self.work_dir.join(name);
tokio::fs::create_dir_all(&model_dir).await?;
@@ -276,7 +335,7 @@ impl<'a> ExecutionRunner<'a> {
.kill_on_drop(true)
.spawn()?;
let status_res = run_child_async_with_timeout(child, timeout_sec).await;
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) => {
@@ -393,38 +452,49 @@ impl<'a> ExecutionRunner<'a> {
if final_7.is_file() {
let syn_t0 = Instant::now();
let _ = tokio::fs::copy(&final_7, model_dir.join("fort.8")).await;
let _ = tokio::fs::remove_file(model_dir.join("fort.7")).await;
// H10synspec 输入文件(fort.8 大气 / fort.55 控制卡)写入失败不可静默吞掉。
// 历史上用 `let _ =` 忽略错误,磁盘满/inode 耗尽时 synspec 会读到旧/缺失的
// fort.8 产出垃圾光谱,却仍生成 .spec 并被归档为"成功"。现改为写入失败即记
// synspec_err 并跳过 synspec 阶段,避免产出物理上错误的谱。
if let Err(e) = tokio::fs::copy(&final_7, model_dir.join("fort.8")).await {
warn!("synspec 输入 fort.8 (大气) 复制失败,跳过 synspec: {}", e);
synspec_err = Some(format!("fort.8 copy failed: {}", e));
} else {
let _ = tokio::fs::remove_file(model_dir.join("fort.7")).await;
// Fort.55 parameter generation or symlink
let fort55_path = model_dir.join("fort.55");
let fort19_path = model_dir.join("fort.19");
// Fort.55 parameter generation or symlink
let fort55_path = model_dir.join("fort.55");
let fort19_path = model_dir.join("fort.19");
let _ = tokio::fs::remove_file(&fort55_path).await;
let _ = tokio::fs::remove_file(&fort19_path).await;
let _ = tokio::fs::remove_file(&fort55_path).await;
let _ = tokio::fs::remove_file(&fort19_path).await;
let default_cfg = SynspecConfig {
wstart: 1400.0,
wend: 1410.0,
imode: 0,
idrv: 50,
ifreq: 1,
rel_cutoff: 0.0001,
abs_cutoff: 0.01,
};
let fort55_text = generate_fort55_content(synspec_cfg.unwrap_or(&default_cfg));
let _ = tokio::fs::write(&fort55_path, &fort55_text).await;
#[cfg(unix)]
{
let abs_linelist = tokio::fs::canonicalize(&self.runtime.linelist)
.await
.unwrap_or_else(|_| self.runtime.linelist.clone());
let _ = std::os::unix::fs::symlink(&abs_linelist, &fort19_path);
let default_cfg = SynspecConfig {
wstart: 1400.0,
wend: 1410.0,
imode: 0,
idrv: 50,
ifreq: 1,
rel_cutoff: 0.0001,
abs_cutoff: 0.01,
};
let fort55_text = generate_fort55_content(synspec_cfg.unwrap_or(&default_cfg));
if let Err(e) = tokio::fs::write(&fort55_path, &fort55_text).await {
warn!("synspec 输入 fort.55 (控制卡) 写入失败,跳过 synspec: {}", e);
synspec_err = Some(format!("fort.55 write failed: {}", e));
} else {
#[cfg(unix)]
{
let abs_linelist = tokio::fs::canonicalize(&self.runtime.linelist)
.await
.unwrap_or_else(|_| self.runtime.linelist.clone());
let _ = std::os::unix::fs::symlink(&abs_linelist, &fort19_path);
}
}
}
let input5_path = model_dir.join(format!("{}.5", name));
if input5_path.is_file() {
if synspec_err.is_none() && input5_path.is_file() {
let fin = File::open(&input5_path).await?.into_std().await;
let fout = File::create(model_dir.join(format!("{}.log", name)))
.await?
@@ -440,7 +510,8 @@ impl<'a> ExecutionRunner<'a> {
.spawn()?;
let synspec_timeout_sec = 600_u64.min(timeout_sec);
let status_res = run_child_async_with_timeout(child, synspec_timeout_sec).await;
let status_res =
run_child_async_with_timeout(child, synspec_timeout_sec, shutdown.clone()).await;
let rc = match status_res {
Ok(st) => st.code().unwrap_or(-1),
Err(e) => {
+95 -6
View File
@@ -10,29 +10,118 @@ pub struct SeedMatch {
pub const MAX_GLOBAL_SEED_DISTANCE: f64 = 3.0;
/// CNO 有向距离:富金属方向(目标比种子富)重罚,贫金属方向(目标比种子贫)轻罚。
///
/// **数据标定依据**——对历史 1191 个真实 seed_step(种子,目标)配对的成败统计:
/// - 贫金属方向(种子更富、目标往贫走,delta=目标−种子 < 0):成功率 **4254%**
/// - 富金属方向(目标更富、delta > 0):成功率仅 **311%**
/// (每个 loghe 分层该规律独立成立,he=−4 时贫方向 54% vs 富方向 3%,差 18 倍)
///
/// **物理解释**:从高金属丰度的收敛解出发**减少**金属(贫方向)是稳定微扰;
/// 反过来从贫金属种子**增加**金属(富方向),新增的紫外谱线辐射驱动会破坏已建立的
/// 辐射平衡,导致大气发散出现 NaN。这是 NLTE 辐射流体计算的已知特性。
///
/// **回测**:对 871 个失败点的 exact_family 种子池回测,旧版绝对值距离有 55% 选了糟糕的
/// 富方向种子;改用此非对称距离后,81% 选贫方向,净改善 314 个点(旧选富→新选贫)。
fn directed_cno_distance(cand: &GridPointParams, target: &GridPointParams) -> f64 {
const RICH_PENALTY: f64 = 4.0; // 目标比种子富 → 该方向微扰不稳定,重罚
const POOR_PENALTY: f64 = 1.0; // 目标比种子贫 → 该方向微扰稳定,轻罚
// delta = target cand:正 = 目标更富(坏方向),负 = 目标更贫(好方向)
let penalize = |delta: f64| if delta > 0.0 { delta * RICH_PENALTY } else { -delta * POOR_PENALTY };
penalize(target.logc.value() - cand.logc.value())
+ penalize(target.logn.value() - cand.logn.value())
+ penalize(target.logo.value() - cand.logo.value())
}
pub fn calculate_seed_distance(cand: &GridPointParams, target: &GridPointParams) -> (bool, f64) {
let d_teff = (cand.teff.value() - target.teff.value()).abs();
let d_logg = (cand.logg.value() - target.logg.value()).abs();
let d_loghe = (cand.loghe.value() - target.loghe.value()).abs();
let d_cno = (cand.logc.value() - target.logc.value()).abs()
+ (cand.logn.value() - target.logn.value()).abs()
+ (cand.logo.value() - target.logo.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 {
(true, d_cno)
// 同物理族内仅 CNO 不同:用有向距离优先匹配贫金属方向的种子(见 directed_cno_distance)。
(true, directed_cno_distance(cand, target))
} else {
// 距离公式物理意义与标定阐释:
// 在恒星非局部热力学平衡(NLTE)辐射流体力学与光谱大气计算中,不同物理自由度对于迭代收敛过程的基本影响层级截然相反:
// 1. Teff (有效温度) 通常达数千至数十万 K,主导连续谱黑体势函数与强激发电离步阶,故除以 5000.0 归一化为基底主控距离量;
// 2. logg (表面重力加速度) 对静力学与辐射光致压差梯度的平衡破坏力极烈,压强差稍高会触发极大激波不平衡,因此乘上 2.0 予以最高维权惩罚;
// 3. loghe (氦丰度) 对自由电子密度与热库贡献次于 H-He 电离梯度,乘 0.5 作为次要控制项;
// 4. CNO 金属元素虽然影响紫外谱线辐射驱动但整体状态基本可作次优微扰微增系数看待,乘 0.1;
// 通过上述尺度正态映射可挑选得到高收敛继承性的初态迭代种子模型
// 4. CNO 金属元素影响紫外谱线辐射驱动,其方向性同样关键(富方向不稳定,见 directed_cno_distance),
// 乘 0.1 归一化后纳入全局距离
let d_cno = directed_cno_distance(cand, target);
let global_d = (d_teff / 5000.0) + (d_logg * 2.0) + (d_loghe * 0.5) + (d_cno * 0.1);
(false, global_d)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::GridAxisValue;
fn params(teff: f64, logg: f64, loghe: f64, logc: f64, logn: f64, logo: f64) -> GridPointParams {
GridPointParams {
teff: GridAxisValue::from_value(teff),
logg: GridAxisValue::from_value(logg),
loghe: GridAxisValue::from_value(loghe),
logc: GridAxisValue::from_value(logc),
logn: GridAxisValue::from_value(logn),
logo: GridAxisValue::from_value(logo),
}
}
/// exact_family 内,同 CNO 差幅度下,贫方向距离应远小于富方向距离(4:1)。
#[test]
fn test_directed_cno_distance_favors_poor_metal_direction() {
let seed = params(40000.0, 6.0, -2.0, -2.0, -2.0, -2.0); // 种子 CNO=-6
// 贫方向:目标 CNO=-7(种子更富,目标往贫走),|Δ|=1
let poor_target = params(40000.0, 6.0, -2.0, -3.0, -2.0, -2.0);
// 富方向:目标 CNO=-5(目标更富),|Δ|=1,同一分量、同幅度
let rich_target = params(40000.0, 6.0, -2.0, -1.0, -2.0, -2.0);
let d_poor = directed_cno_distance(&seed, &poor_target);
let d_rich = directed_cno_distance(&seed, &rich_target);
assert!(d_poor < d_rich, "贫方向应更近");
assert!(
(d_rich / d_poor - 4.0).abs() < 1e-9,
"富/贫方向同幅度距离比应为 RICH/POOR=4.0,实际 {} / {} = {}",
d_rich,
d_poor,
d_rich / d_poor
);
}
/// exact_family 选种应在多个候选中优先选贫方向种子,即便其 CNO 绝对差更大。
#[test]
fn test_exact_family_prefers_poor_direction_seed() {
let target = params(40000.0, 6.0, -2.0, -2.0, -2.0, -2.0); // 目标 CNO=-6
// 候选A:富方向种子(目标比种子富),CNO 绝对差=1
let rich_seed = params(40000.0, 6.0, -2.0, -3.0, -2.0, -2.0); // CNO=-7, 目标更富
// 候选B:贫方向种子(目标比种子贫),CNO 绝对差=2(更大)
let poor_seed = params(40000.0, 6.0, -2.0, -1.0, -1.0, -2.0); // CNO=-4, 目标更贫
let (_, d_rich) = calculate_seed_distance(&rich_seed, &target);
let (_, d_poor) = calculate_seed_distance(&poor_seed, &target);
// 贫种子虽 CNO 绝对差更大(2 vs 1),但因方向有利,距离应更小
assert!(
d_poor < d_rich,
"贫方向种子距离 {} 应小于富方向 {}(即便 CNO 绝对差更大)",
d_poor,
d_rich
);
}
/// 跨 teff(非 exact)时仍标记 global,且方向性体现在 global_d 里。
#[test]
fn test_global_branch_marks_non_exact_and_keeps_direction() {
let target = params(40000.0, 6.0, -2.0, -2.0, -2.0, -2.0);
// 跨 teff 20000K(超过 exact 容忍 5000K)→ global
let cand = params(60000.0, 6.0, -2.0, -2.0, -2.0, -2.0);
let (is_exact, d) = calculate_seed_distance(&cand, &target);
assert!(!is_exact, "跨 teff 20000K 应为 global 分支");
assert!(d > 0.0);
}
}