feat(all): 源精度命名体系、工作流可观测台、节点停用管理与白名单归档

核心变更:

  1. GridAxisValue 源精度命名
     - 新增 GridAxisValue 类型,携带 f64 数值 + YAML 源书写文本(Deref 透明兼容算术)
     - config.rs 绕过 serde_yaml 归一化,逐 token 捕获轴值原文(logg: 5.0 → g5.0)
     - runner/executor/scheduler 全链路改用 DB TEXT 列权威 point_name,
       修复 REAL 列回读丢精度导致的 model_name 错配

  2. 工作流执行可观测台
     - 新增 stats/progress/points 三组 API(进度时间序列、经验速率 ETA、
       停滞预警、逐点明细分页、收敛性热力图数据)
     - 新增 workflow_progress_snapshots 表 + tasks/grid_points 耗时列
     - runner 携带 last_iter/worst_depth/n_depths 进 conv.json
     - 前端新增 hash 路由、工作流详情页(概览/网格点/收敛分析三 Tab)、YAML 编辑器

  3. 节点停用/启用管理
     - 新增 disabled 状态 + disable/enable API;停用节点保持心跳但停止分发,
       worker 空闲待命而非退出;移除 revoke API,token 失效统一走重发覆盖;
       移除 host_name 字段

  4. 白名单结果归档
     - 新增 result_filter 模块,只归档有语义产物,丢弃 Tlusty 中间单元(~2MB/模型)
     - executor 原子写入归档 + 200 点 LRU 上限

  5. 历史数据导入
     - sync_seeds 重写为 import_results:经 /admin/import_seed 标记 converged +
       按新版命名迁移产物树

  6. 部署与目录重规划
     - data/results→seeds、data/archive→result + migrate_data_dirs.sh
     - deploy.sh 增强(SSH 复用、Profile、远程 env);Dockerfile 瘦身

  7. 文档同步更新 api/database/architecture/deployment
This commit is contained in:
fmq
2026-07-31 01:34:05 +08:00
parent b91f1e4fa5
commit 1bfa240cb0
73 changed files with 12332 additions and 1608 deletions
+1 -1
View File
@@ -21,6 +21,6 @@ reqwest.workspace = true
tokio.workspace = true
[features]
default = ["embed-binaries"]
default = []
embed-binaries = []
+166 -43
View File
@@ -1,15 +1,143 @@
use crate::models::GridAxisValue;
use anyhow::{Context, Result};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::OnceLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GridAxesConfig {
pub teff: Vec<f64>,
pub logg: Vec<f64>,
pub loghe: Vec<f64>,
pub logc: Vec<f64>,
pub logn: Vec<f64>,
pub logo: Vec<f64>,
pub teff: Vec<GridAxisValue>,
pub logg: Vec<GridAxisValue>,
pub loghe: Vec<GridAxisValue>,
pub logc: Vec<GridAxisValue>,
pub logn: Vec<GridAxisValue>,
pub logo: Vec<GridAxisValue>,
}
/// 解析单个标量 token 为 `GridAxisValue`:保留 YAML 源书写原文(`5.0`→`"5.0"`
/// `20000`→`"20000"``-2`→`"-2"`),同时取其 f64 数值。
fn axis_value_from_token(tok: &str) -> Option<GridAxisValue> {
let tok = tok.trim().trim_matches(',');
// 允许的标量形式:可选符号 + 数字(含小数、科学计数)。剔除引号/非法字符。
if tok.is_empty() {
return None;
}
let cleaned = tok.trim_matches(|c| c == '"' || c == '\'');
if cleaned.parse::<f64>().is_ok() {
Some(GridAxisValue::from_text(cleaned))
} else {
None
}
}
/// 从 YAML 文本中按轴名提取**源精度原文 token**,构造 `GridAxesConfig`。
///
/// # 为什么需要它
/// `serde_yaml` 的公开 API 在解析时会把纯标量 `5.0` 解析为 `visit_f64(5.0)`、`20000`
/// 解析为 `visit_i64`**丢失原始书写文本**——而 `model_name()` 必须严格忠于源精度
/// `logg: 5.0` → `g5.0`,不是 `g5`),否则与旧版 Python `gen_input5.model_name`
/// 对不上、迁移失败。本函数直接扫 YAML 文本的 `grid:` 块,逐 token 捕获原文,绕过
/// serde_yaml 的类型归一化。
///
/// 支持两种块格式(与 run_grid.py / DCTS 配置一致):
/// - 流式:`logg: [5.0, 6.0]`
/// - 块式:`logg:\n - 5.0\n - 6.0`
fn parse_grid_axes_raw(yaml: &str) -> Option<GridAxesConfig> {
let axes_re = {
static RE: OnceLock<Regex> = OnceLock::new();
// 捕获轴名与该行 `key:` 之后的内容(流式 `[...]` 或空),供后续按行解析块式。
RE.get_or_init(|| Regex::new(r"^\s*(teff|logg|loghe|logc|logn|logo)\s*:\s*(.*)$").unwrap())
};
let mut axes: std::collections::HashMap<&str, Vec<GridAxisValue>> =
std::collections::HashMap::new();
let mut current_axis: Option<&str> = None; // 块式 `- value` 续行归属
let lines: Vec<&str> = yaml.lines().collect();
// 是否已进入 `grid:` 块(grid 块之外的同名键不误捕,尽管实际不会重名)。
let mut in_grid = false;
for (i, raw) in lines.iter().enumerate() {
let line = raw.split('#').next().unwrap_or("").trim_end();
let stripped = line.trim_start();
if stripped.is_empty() {
continue;
}
// 顶层键:grid / 其它。仅顶层(无缩进)键切换 in_grid。
if !raw.starts_with(' ') && !raw.starts_with('\t') {
in_grid = stripped.starts_with("grid:");
current_axis = None;
continue;
}
if !in_grid {
continue;
}
// 块式列表续行:` - 5.0`
if let Some(rest) = stripped.strip_prefix("- ") {
if let Some(axis) = current_axis {
if let Some(v) = axis_value_from_token(rest) {
axes.entry(axis).or_default().push(v);
}
}
continue;
}
// 轴定义行:` logg: [5.0, 6.0]` 或 ` logg:`(块式,值在下几行)
if let Some(caps) = axes_re.captures(line) {
let axis = caps.get(1).unwrap().as_str();
let tail = caps.get(2).unwrap().as_str().trim();
current_axis = Some(axis);
if tail.starts_with('[') {
// 流式:`[5.0, 6.0]` —— 可能在单行内闭合,也可能跨行(本配置不会跨行)。
let inner = tail.trim_start_matches('[').split(']').next().unwrap_or("");
for tok in inner.split(',') {
if let Some(v) = axis_value_from_token(tok) {
axes.entry(axis).or_default().push(v);
}
}
current_axis = None; // 流式在本行闭合,不再续行
}
// tail 为空 → 块式,等后续 `- value` 行(current_axis 已设)
}
// 跨行未闭合的流式列表(grid 块极少如此)不单独处理;YAML 规范允许但本配置不使用。
let _ = i;
}
// 六轴齐全才算解析成功;任一缺失回退 None(调用方走纯 serde 数值路径)。
let get = |k: &str| axes.get(k).cloned().filter(|v| !v.is_empty());
Some(GridAxesConfig {
teff: get("teff")?,
logg: get("logg")?,
loghe: get("loghe")?,
logc: get("logc")?,
logn: get("logn")?,
logo: get("logo")?,
})
}
impl GridConfig {
pub fn load_from_file(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
GridConfig::from_yaml_str(&content)
.with_context(|| format!("Failed to parse YAML config: {}", path.display()))
}
/// 解析 YAML 配置,**优先用源精度原文重建 grid 轴**。
///
/// 两步:
/// 1. `serde_yaml` 解析整个 `GridConfig`chain/synspec 等正常字段,grid 轴为数值)。
/// 2. 若能从原文捕到六轴 token,用源精度 `GridAxisValue` 覆盖 grid 字段;
/// 捕不到(格式异常)则保留 serde 数值结果(命名精度回退,不阻断解析)。
pub fn from_yaml_str(yaml: &str) -> Result<Self> {
let mut cfg: GridConfig = serde_yaml::from_str(yaml)?;
if let Some(raw_axes) = parse_grid_axes_raw(yaml) {
cfg.grid = raw_axes;
}
Ok(cfg)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -99,6 +227,11 @@ pub struct GridConfig {
pub resume: bool,
#[serde(default = "default_true")]
pub seed_step_fallback: bool,
/// **已弃用的死字段**:旧版 Python 工具链遗留,无任何代码读取(实际目录以
/// `ServerConfig.seeds_dir` / `DCTS_SEEDS_DIR` 为准)。仅因 `deny_unknown_fields`
/// 必须能解析而保留。workflow YAML 里仍可写(如 `results: data/seeds`)但被忽略。
#[deprecated(note = "死字段,实际目录以 DCTS_SEEDS_DIR 为准")]
#[serde(default)]
pub results: Option<String>,
#[serde(default)]
pub itek_fallback: Vec<StageConfig>,
@@ -125,22 +258,16 @@ fn default_true() -> bool {
true
}
impl GridConfig {
pub fn load_from_file(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let cfg: GridConfig = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse YAML config: {}", path.display()))?;
Ok(cfg)
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub bind_addr: String,
pub db_path: String,
pub queue_db_path: String,
pub results_dir: String,
/// server 端种子库目录:node 上报/历史导入收敛后,落地 `<name>/conv.json` +
/// `<name>/<name>.7` 于此,供 `download_seed` 端点给远程 node 热启动下载。
/// **永不清理**(种子是 SeedStep 热启动的必需资源,删除会导致已收敛点重算)。
/// 默认 "data/seeds",可经 DCTS_SEEDS_DIR 覆盖(回退读旧 DCTS_RESULTS_DIR)。
pub seeds_dir: String,
/// 数据库备份目录(每日自动备份落盘位置)。默认 "data/backups",可经 DCTS_BACKUP_DIR 覆盖。
pub backup_dir: String,
pub grid_config: String,
@@ -152,9 +279,6 @@ pub struct ServerConfig {
/// Admin 凭据(Dashboard 登录用)。优先 DCTS_ADMIN_TOKEN,回退旧变量 DCTS_AUTH_TOKEN。
#[serde(default)]
pub admin_token: Option<String>,
/// 兼容字段:保留以判断「是否启用鉴权」与旧中间件逻辑。取 admin_token 的值。
#[serde(default)]
pub auth_token: Option<String>,
/// 应急开关:DCTS_AUTH_DISABLE=1 时跳过全部鉴权(仅本地调试,默认关闭)。
#[serde(default)]
pub auth_disabled: bool,
@@ -167,7 +291,7 @@ impl std::fmt::Debug for ServerConfig {
.field("bind_addr", &self.bind_addr)
.field("db_path", &self.db_path)
.field("queue_db_path", &self.queue_db_path)
.field("results_dir", &self.results_dir)
.field("seeds_dir", &self.seeds_dir)
.field("backup_dir", &self.backup_dir)
.field("grid_config", &self.grid_config)
.field("stale_sec", &self.stale_sec)
@@ -178,10 +302,6 @@ impl std::fmt::Debug for ServerConfig {
"admin_token",
&self.admin_token.as_ref().map(|_| "***REDACTED***"),
)
.field(
"auth_token",
&self.auth_token.as_ref().map(|_| "***REDACTED***"),
)
.field("auth_disabled", &self.auth_disabled)
.finish()
}
@@ -200,8 +320,10 @@ impl Default for ServerConfig {
let db_path = std::env::var("DCTS_DB_PATH").unwrap_or_else(|_| "data/dcts.db".to_string());
let queue_db_path = std::env::var("DCTS_QUEUE_DB_PATH")
.unwrap_or_else(|_| "data/dcts_queue.db".to_string());
let results_dir =
std::env::var("DCTS_RESULTS_DIR").unwrap_or_else(|_| "data/results".to_string());
// 种子库目录:优先 DCTS_SEEDS_DIR,回退旧 DCTS_RESULTS_DIR(已弃用,保留兼容)。
let seeds_dir = std::env::var("DCTS_SEEDS_DIR")
.or_else(|_| std::env::var("DCTS_RESULTS_DIR"))
.unwrap_or_else(|_| "data/seeds".to_string());
let backup_dir =
std::env::var("DCTS_BACKUP_DIR").unwrap_or_else(|_| "data/backups".to_string());
let grid_config = std::env::var("DCTS_GRID_CONFIG")
@@ -226,13 +348,10 @@ impl Default for ServerConfig {
.ok()
.filter(|s| !s.is_empty())
.or_else(|| legacy_token.clone());
if legacy_token.is_some()
&& (std::env::var("DCTS_ADMIN_TOKEN").is_err()
|| std::env::var("DCTS_ENROLLMENT_TOKEN").is_err())
{
if legacy_token.is_some() && std::env::var("DCTS_ADMIN_TOKEN").is_err() {
tracing::warn!(
"检测到旧的 DCTS_AUTH_TOKEN,已自动用作 admin/enrollment 凭据。\
建议迁移到 DCTS_ADMIN_TOKEN(管理)与 DCTS_ENROLLMENT_TOKEN(节点注册)"
"检测到旧的 DCTS_AUTH_TOKEN,已自动回退用作 admin 凭据。\
建议迁移到 DCTS_ADMIN_TOKEN"
);
}
@@ -245,19 +364,11 @@ impl Default for ServerConfig {
);
}
// auth_token 兼容字段:用于 main.rs 判断「是否启用鉴权中间件」。
// 启用条件 = 显式配置了 admin 或 enrollment 凭据,且未应急关闭。
let auth_token = if auth_disabled {
None
} else {
admin_token.clone()
};
Self {
bind_addr: format!("0.0.0.0:{}", port),
db_path,
queue_db_path,
results_dir,
seeds_dir,
backup_dir,
grid_config,
stale_sec,
@@ -265,7 +376,6 @@ impl Default for ServerConfig {
mq_type,
rabbitmq_url,
admin_token,
auth_token,
auth_disabled,
}
}
@@ -278,6 +388,13 @@ pub struct NodeConfig {
pub max_slots: usize,
pub runtime_dir: String,
pub work_dir: String,
/// node 端完整计算结果归档目录:任务上报成功后、沙盒清理前,把完整科学产物
/// .spec/.cont/.iden/.7/各阶段快照/.6/.err/.log/conv.json 等)拷贝至此,
/// 避免随沙盒删除而丢失。与 server 的 `seeds` 目录区分:此处存的是**完整产物**
/// (光谱/连续谱/各阶段大气快照等),seeds 只存最小种子集(.7+conv.json)。
/// 默认 "data/result",可经 DCTS_RESULT_DIR 覆盖(回退读旧 DCTS_ARCHIVE_DIR)。
/// 超过 MAX_RESULT_MODELS 个网格点子目录时按 LRU 删除最旧的。
pub result_dir: String,
pub heartbeat_sec: u64,
}
@@ -289,6 +406,7 @@ impl std::fmt::Debug for NodeConfig {
.field("max_slots", &self.max_slots)
.field("runtime_dir", &self.runtime_dir)
.field("work_dir", &self.work_dir)
.field("result_dir", &self.result_dir)
.field("heartbeat_sec", &self.heartbeat_sec)
.finish()
}
@@ -318,6 +436,10 @@ impl Default for NodeConfig {
let runtime_dir =
std::env::var("DCTS_RUNTIME_DIR").unwrap_or_else(|_| "data/runtime".to_string());
let work_dir = std::env::var("DCTS_WORK_DIR").unwrap_or_else(|_| "data/work".to_string());
// 结果归档目录:优先 DCTS_RESULT_DIR,回退旧 DCTS_ARCHIVE_DIR(已弃用,保留兼容)。
let result_dir = std::env::var("DCTS_RESULT_DIR")
.or_else(|_| std::env::var("DCTS_ARCHIVE_DIR"))
.unwrap_or_else(|_| "data/result".to_string());
let heartbeat_sec = std::env::var("DCTS_HEARTBEAT_SEC")
.ok()
.and_then(|v| v.parse::<u64>().ok())
@@ -329,6 +451,7 @@ impl Default for NodeConfig {
max_slots,
runtime_dir,
work_dir,
result_dir,
heartbeat_sec,
}
}
+14 -14
View File
@@ -196,21 +196,21 @@ pub fn make_input5(
// Atoms block
let mut atom_rows: Vec<(i32, String)> = vec![
(2, "0.".to_string()), // 1 H
(2, fmt_abn(params.loghe)), // 2 He
(0, "0.".to_string()), // 3 Li
(0, "0.".to_string()), // 4 Be
(0, "0.".to_string()), // 5 B
(2, "0.".to_string()), // 1 H
(2, fmt_abn(*params.loghe)), // 2 He
(0, "0.".to_string()), // 3 Li
(0, "0.".to_string()), // 4 Be
(0, "0.".to_string()), // 5 B
];
if has_c {
atom_rows.push((2, fmt_abn(params.logc))); // 6 C
atom_rows.push((2, fmt_abn(*params.logc))); // 6 C
}
if has_n {
atom_rows.push((2, fmt_abn(params.logn))); // 7 N
atom_rows.push((2, fmt_abn(*params.logn))); // 7 N
}
if has_o {
atom_rows.push((2, fmt_abn(params.logo))); // 8 O
atom_rows.push((2, fmt_abn(*params.logo))); // 8 O
}
let natoms =
@@ -271,12 +271,12 @@ mod tests {
#[test]
fn test_make_input5() {
let params = GridPointParams {
teff: 35000.0,
logg: 5.5,
loghe: -1.0,
logc: -2.0,
logn: -2.0,
logo: -2.0,
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let input5 = make_input5(&params, "F", "F", "cno", 100);
assert!(input5.contains("35000.0 5.5"));
+1
View File
@@ -6,5 +6,6 @@ pub mod gen_input5;
pub mod logging;
pub mod models;
pub mod nst_writer;
pub mod result_filter;
pub mod runner;
pub mod seed_finder;
+401 -32
View File
@@ -1,19 +1,122 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::ops::Deref;
use uuid::Uuid;
/// 6D grid point parameter specification
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GridPointParams {
pub teff: f64,
pub logg: f64,
pub loghe: f64,
pub logc: f64,
pub logn: f64,
pub logo: f64,
/// 网格轴值:同时携带**数值**(供算术/排序/DB REAL 列)与**源书写文本**(供命名)。
///
/// # 为什么需要它
/// `f64` 无法区分 YAML 里 `5.0`(带小数)与 `5`(省略小数)——两者解析后都是 `5.0_f64`。
/// 而 `model_name()` 必须严格忠于源精度("配置里多少位小数就多少位"),否则 Rust 名
/// (`t20000_g5_...`) 与 Python `gen_input5.model_name` 名 (`t20000_g5.0_...`) 对不上,
/// 旧版单机网格数据无法迁移到本系统。
///
/// 本类型在反序列化时通过 `deserialize_any` 的 `visit_str` 捕获 YAML/JSON 标量原文,
/// 把它一路携带到 `model_name()` 直接拼接;算术则通过 `Deref<Target = f64>` 透明转发到数值,
/// 绝大多数 `params.teff` 算术调用点无需改动。
///
/// # serde 行为
/// - **反序列化**:优先抓原文(`5.0`→text=`"5.0"``20000`→text=`"20000"`);纯数值来源
/// DB REAL 列回读、JSON 数值 payload)无原文时用数值兜底文本(仅占位,该路径应使用
/// 存储的 `name` 列而非 `model_name()` 重推)。
/// - **序列化**:输出为纯 `f64` 数值,保证 JSON payload / 旧消费者无感。
#[derive(Debug, Clone, PartialEq)]
pub struct GridAxisValue {
value: f64,
text: Box<str>,
}
fn fmt_num(val: f64) -> String {
impl GridAxisValue {
/// 构造:用给定数值,文本由数值生成(兜底路径:DB 回读 / 程序内构造)。
pub fn from_value(value: f64) -> Self {
let text = format_float_minimal(value).into();
Self { value, text }
}
/// 构造:用给定文本,数值由文本解析(反序列化主路径,信任源精度)。
pub(crate) fn from_text(text: &str) -> Self {
let value = text.parse::<f64>().unwrap_or(f64::NAN);
Self {
value,
text: text.into(),
}
}
/// 数值(算术用)。
pub fn value(&self) -> f64 {
self.value
}
/// 源书写文本(命名用)。
pub fn text(&self) -> &str {
&self.text
}
}
/// `Deref` 到 `f64`:所有 `params.teff` 形式的算术调用点(排序、量化、求和等)
/// 无需显式 `.value()` 即可继续工作,把跨 crate 改动量压到最低。
impl Deref for GridAxisValue {
type Target = f64;
fn deref(&self) -> &f64 {
&self.value
}
}
/// 排序委托数值,保证调度排序语义不变。
impl PartialOrd for GridAxisValue {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
self.value.partial_cmp(&other.value)
}
}
/// 算术运算全部委托内部 f64,使本类型在数值计算上与 f64 完全等价。
/// 结果一律退化为纯 f64——轴值只在其作为网格轴字段时才需保留源文本,
/// 参与运算后的中间结果无需再保留精度(也不会用于命名)。
/// 借用场景(如 `&GridPointParams` 字段相减)请用 `.value()` 显式取值。
macro_rules! impl_arith {
($trait:ident, $method:ident) => {
impl std::ops::$trait for GridAxisValue {
type Output = f64;
fn $method(self, rhs: Self) -> f64 {
self.value.$method(rhs.value)
}
}
impl std::ops::$trait<f64> for GridAxisValue {
type Output = f64;
fn $method(self, rhs: f64) -> f64 {
self.value.$method(rhs)
}
}
};
}
impl_arith!(Add, add);
impl_arith!(Sub, sub);
impl_arith!(Mul, mul);
impl_arith!(Div, div);
/// `Display` 委托数值(用于 `format!("{}", params.x)` 这类数值展示场景,
/// 如 gen_input5 的 `{:.1f}` teff/logg 格式化)。命名场景请用 `.text()`。
impl std::fmt::Display for GridAxisValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.value, f)
}
}
impl From<f64> for GridAxisValue {
fn from(value: f64) -> Self {
Self::from_value(value)
}
}
impl From<i32> for GridAxisValue {
fn from(value: i32) -> Self {
Self::from_value(value as f64)
}
}
/// 把 f64 格式化为最简可解析文本(兜底路径用;源精度路径不走这里)。
/// 整数取整无小数,非整数去尾零。
fn format_float_minimal(val: f64) -> String {
let rounded = (val * 1e6).round() / 1e6;
if (rounded - rounded.round()).abs() < 1e-6 {
format!("{:.0}", rounded.round())
@@ -25,17 +128,85 @@ fn fmt_num(val: f64) -> String {
}
}
impl Serialize for GridAxisValue {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_f64(self.value)
}
}
impl<'de> Deserialize<'de> for GridAxisValue {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct AxisVisitor;
impl<'de> serde::de::Visitor<'de> for AxisVisitor {
type Value = GridAxisValue;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a numeric grid axis value")
}
// 主路径:YAML/JSON 标量原文(`5.0`、`20000`、`-2`、`"-4"`)。
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
Ok(GridAxisValue::from_text(v))
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Self::Value, E> {
Ok(GridAxisValue::from_text(&v))
}
// 兜底路径:纯数值来源(无原文)。先尝试解析原 deserializer 文本不可得,
// 这里只能从数值反推文本(占位;该路径应用存储的 name 列)。
fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Self::Value, E> {
Ok(GridAxisValue::from_value(v))
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
// 整数来源:文本取整无损(如 JSON 里 `20000`),保留无小数形式。
Ok(GridAxisValue::from_text(&v.to_string()))
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
Ok(GridAxisValue::from_text(&v.to_string()))
}
}
// deserialize_anyserde_yaml 的标量会先尝试以 str 形式投递给 visit_str
// 从而捕获原文;纯数值 deserializer(如 serde_json 的 f64 字段)会走数值分支。
deserializer.deserialize_any(AxisVisitor)
}
}
/// 6D grid point parameter specification
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GridPointParams {
pub teff: GridAxisValue,
pub logg: GridAxisValue,
pub loghe: GridAxisValue,
pub logc: GridAxisValue,
pub logn: GridAxisValue,
pub logo: GridAxisValue,
}
impl GridPointParams {
/// Generates canonical model name string e.g. "t35000_g5.5_he-1_c-2_n-2_o-2"
/// 生成规范模型名,**严格忠于各轴的源书写精度**:直接拼接 YAML 原文。
///
/// 例:YAML 中 `teff: [20000]`、`logg: [5.0]`、丰度 `[-2]` → `t20000_g5.0_he-2_c-2_n-2_o-2`。
/// 这与旧版 Python `gen_input5.model_name` 逐字符一致,保证旧数据可迁移。
pub fn model_name(&self) -> String {
format!(
"t{}_g{}_he{}_c{}_n{}_o{}",
fmt_num(self.teff),
fmt_num(self.logg),
fmt_num(self.loghe),
fmt_num(self.logc),
fmt_num(self.logn),
fmt_num(self.logo)
self.teff.text(),
self.logg.text(),
self.loghe.text(),
self.logc.text(),
self.logn.text(),
self.logo.text(),
)
}
@@ -44,7 +215,7 @@ impl GridPointParams {
/// 注:此数值专门用于网格调度中的 Wave 难度分级与保序分组(对数和越小代表重元素丰度越低,
/// 通常在大气模型计算中更容易收敛,作为冷启动基准)。
pub fn cno_sum(&self) -> f64 {
self.logc + self.logn + self.logo
*self.logc + *self.logn + *self.logo
}
}
@@ -109,6 +280,10 @@ pub struct TaskSpec {
/// 旧数据反序列化时缺省为 None。
#[serde(default)]
pub workflow_name: Option<String>,
/// 网格点所属 wave(难度分批),用于队列内按难度优先出队,恢复"先易后难积累种子"语义。
/// 旧 payload 反序列化时缺省为 0。
#[serde(default)]
pub wave: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -149,7 +324,6 @@ pub struct TaskReport {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeRegisterRequest {
pub node_id: String,
pub host_name: String,
pub max_slots: i32,
}
@@ -166,7 +340,6 @@ pub struct NodeHeartbeatRequest {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeInfo {
pub node_id: String,
pub host_name: String,
pub max_slots: i32,
pub active_slots: i32,
pub status: String,
@@ -197,6 +370,16 @@ pub struct StageSummary {
pub best_max_relc: Option<f64>,
pub elapsed_sec: f64,
pub note: Option<String>,
/// 末次迭代序号(fort.9 解析所得);NITER=0 的 grey 阶段为 null。
/// 收敛难度直接指标:17 次迭代收敛 vs 顶着 NITER 上限勉强收敛稳定性迥异。
#[serde(default)]
pub last_iter: Option<i32>,
/// 收敛最差的深度点编号(|maximum| 最大处),诊断定位用。
#[serde(default)]
pub worst_depth: Option<i32>,
/// 深度点总数,诊断用。
#[serde(default)]
pub n_depths: Option<usize>,
}
/// Full execution summary for a grid point
@@ -213,6 +396,9 @@ pub struct ModelSummary {
pub synspec_rc: Option<i32>,
pub synspec_error: Option<String>,
pub synspec_sec: Option<f64>,
/// 单点总墙钟耗时(秒)。极旧版 conv.json 可能缺此字段,default 0.0 兜底
/// (展示层把 ≤0 视为"无数据");现版 run_one.py 总是写入。
#[serde(default)]
pub elapsed_sec: f64,
pub note: Option<String>,
}
@@ -221,30 +407,213 @@ pub struct ModelSummary {
mod tests {
use super::*;
/// StageSummary 新增迭代诊断字段的向后兼容:
/// 旧 conv.json(无 last_iter/worst_depth/n_depths)必须能正常反序列化为 null,
/// 新写入的 conv.json 往返保真。
#[test]
fn test_stage_summary_iter_fields_backward_compat() {
let legacy = r#"{
"label": "nl", "chmax": 0.001, "lte": "F", "converged": true,
"best_max_relc": 0.0005, "elapsed_sec": 64.0, "note": null
}"#;
let st: StageSummary = serde_json::from_str(legacy).unwrap();
assert_eq!(st.last_iter, None);
assert_eq!(st.worst_depth, None);
assert_eq!(st.n_depths, None);
let full = StageSummary {
label: "nl".to_string(),
chmax: Some(0.001),
lte: "F".to_string(),
converged: true,
best_max_relc: Some(0.0005),
elapsed_sec: 64.0,
note: None,
last_iter: Some(17),
worst_depth: Some(1),
n_depths: Some(50),
};
let round: StageSummary =
serde_json::from_str(&serde_json::to_string(&full).unwrap()).unwrap();
assert_eq!(round.last_iter, Some(17));
assert_eq!(round.worst_depth, Some(1));
assert_eq!(round.n_depths, Some(50));
}
/// 旧版 Python run_one.py 写出的 conv.json 必须能完整解析为 ModelSummary
/// (历史结果迁移链路 import_results → /api/admin/import_seed 的兼容性命门)。
///
/// 载荷严格复刻 run_one.py 的真实输出形态:stage 含 `itek_attempts`/`final` 嵌套
/// dict、`note`、可选 `best_max_relc`,顶层含 `final_chmax`/`synspec_*`/`seed` 等。
/// Rust StageSummary 未声明的字段(itek_attempts/final)应被 serde 静默忽略。
#[test]
fn test_model_summary_parses_python_legacy_conv_json() {
let legacy = r#"{
"name": "t20000_g5.0_he-2_c-4_n-4_o-4",
"params": {"teff": 20000.0, "logg": 5.0, "loghe": -2.0, "logc": -4.0, "logn": -4.0, "logo": -4.0},
"stages": [
{"label": "lte", "chmax": null, "lte": "T",
"itek_attempts": [{"itek": null, "rc": 0, "converged": true, "max_relc": 0.0,
"note": "NITER=0 grey start (no iterations)"}],
"converged": true,
"final": {"itek": null, "rc": 0, "converged": true, "max_relc": 0.0,
"note": "NITER=0 grey start (no iterations)"},
"best_max_relc": 0.0, "elapsed_sec": 2.1},
{"label": "nc", "chmax": null, "lte": "F",
"itek_attempts": [{"itek": null, "rc": 0, "converged": false, "max_relc": 0.957,
"worst_depth": 1, "last_iter": 10, "n_depths": 50}],
"converged": false,
"final": {"itek": null, "rc": 0, "converged": false, "max_relc": 0.957,
"worst_depth": 1, "last_iter": 10, "n_depths": 50},
"note": "accepted as seed (convergence not required)",
"best_max_relc": 0.957, "elapsed_sec": 62.4},
{"label": "nl", "chmax": null, "lte": "F",
"itek_attempts": [{"itek": null, "rc": 0, "converged": true, "max_relc": 0.0069,
"worst_depth": 1, "last_iter": 17, "n_depths": 50}],
"converged": true,
"final": {"itek": null, "rc": 0, "converged": true, "max_relc": 0.0069,
"worst_depth": 1, "last_iter": 17, "n_depths": 50},
"best_max_relc": 0.0069, "elapsed_sec": 650.2}
],
"converged": true,
"final_max_relc": 0.0069,
"final_chmax": null,
"seed": null,
"atmosphere_has_nan": false,
"synspec_rc": 0,
"synspec_sec": 3.1,
"elapsed_sec": 715.0
}"#;
let s: ModelSummary = serde_json::from_str(legacy).expect("旧版 conv.json 应可解析");
assert_eq!(s.name, "t20000_g5.0_he-2_c-4_n-4_o-4");
assert_eq!(*s.params.teff, 20000.0);
assert!(s.converged);
assert!(!s.atmosphere_has_nan);
assert_eq!(s.final_max_relc, Some(0.0069));
assert_eq!(s.elapsed_sec, 715.0);
assert_eq!(s.synspec_rc, Some(0));
assert_eq!(s.stages.len(), 3);
assert_eq!(s.stages[0].label, "lte");
assert_eq!(s.stages[0].lte, "T");
assert!(s.stages[0].converged);
assert_eq!(s.stages[2].label, "nl");
assert_eq!(s.stages[2].best_max_relc, Some(0.0069));
assert_eq!(s.stages[2].elapsed_sec, 650.2);
// 旧版把迭代诊断嵌在 final/itek_attempts 里(非扁平字段)→ 扁平字段为 None,
// 但原始诊断仍随 conv.json 原文落盘(import_seed 存原始 summary_json),无数据丢失。
assert_eq!(s.stages[2].last_iter, None);
assert_eq!(s.stages[2].worst_depth, None);
}
/// 极旧 conv.json 变体(无 elapsed_sec 字段)也应可解析(default 0.0 兜底),
/// 使历史迁移不因缺耗时字段而整点跳过。
#[test]
fn test_model_summary_tolerates_missing_elapsed() {
let minimal = r#"{
"name": "t20000_g5.0_he-2_c-4_n-4_o-4",
"params": {"teff": 20000.0, "logg": 5.0, "loghe": -2.0, "logc": -4.0, "logn": -4.0, "logo": -4.0},
"stages": [],
"converged": true,
"final_max_relc": 0.001,
"seed": null,
"atmosphere_has_nan": false
}"#;
let s: ModelSummary = serde_json::from_str(minimal).expect("缺 elapsed_sec 应可解析");
assert_eq!(s.elapsed_sec, 0.0);
assert_eq!(s.synspec_rc, None);
assert_eq!(s.final_chmax, None);
}
#[test]
fn test_model_name_formatting_and_decimal_precision() {
let p1 = GridPointParams {
teff: 35000.0,
logg: 5.5,
loghe: -1.0,
logc: -2.0,
logn: -2.0,
logo: -2.0,
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
assert_eq!(p1.model_name(), "t35000_g5.5_he-1_c-2_n-2_o-2");
let p2 = GridPointParams {
teff: 35000.0,
logg: 5.25,
loghe: -1.5,
logc: -2.75,
logn: -2.0,
logo: -1.25,
teff: 35000.0.into(),
logg: 5.25.into(),
loghe: (-1.5).into(),
logc: (-2.75).into(),
logn: (-2.0).into(),
logo: (-1.25).into(),
};
assert_eq!(p2.model_name(), "t35000_g5.25_he-1.5_c-2.75_n-2_o-1.25");
assert_ne!(p1.model_name(), p2.model_name());
}
/// 回归:YAML 源书写的整数小数位必须原样保留(`logg: 5.0` → `g5.0`,不是 `g5`)。
/// 这是旧版单机网格数据迁移的命门——Python `gen_input5.model_name` 用 `g{:.1f}`
/// 故 `t20000_g5.0_he-2_c-4_n-4_o-4` 才是正确名。`f64` 无法区分 `5.0` 与 `5`
/// 必须由 `GridAxisValue` 的源文本携带。
#[test]
fn test_model_name_preserves_source_decimal_precision() {
// 模拟 serde_yaml 解析 grid: { teff: [20000], logg: [5.0], loghe: [-2], ... }
// 每个轴值通过 from_textvisit_str 路径)构造,保留原文。
let p = GridPointParams {
teff: GridAxisValue::from_text("20000"),
logg: GridAxisValue::from_text("5.0"),
loghe: GridAxisValue::from_text("-2"),
logc: GridAxisValue::from_text("-4"),
logn: GridAxisValue::from_text("-4"),
logo: GridAxisValue::from_text("-4"),
};
assert_eq!(p.model_name(), "t20000_g5.0_he-2_c-4_n-4_o-4");
// 数值正确解析(算术/排序不受影响)
assert!((p.teff.value() - 20000.0).abs() < 1e-9);
assert!((p.logg.value() - 5.0).abs() < 1e-9);
assert!((p.cno_sum() - (-12.0)).abs() < 1e-9);
}
/// 端到端:`GridConfig::from_yaml_str` 解析真实 config(含 `grid:` 块),确认
/// `logg: 5.0` 的源精度原文被捕获、命名逐字符等于 Python `gen_input5.model_name`。
///
/// 这是数据迁移的命门:旧版单机网格目录名是 `t20000_g5.0_...`DCTS 必须产出同名。
/// 注:纯 `serde_yaml::from_str` 会把 `5.0` 归一化为 `visit_f64` 丢失原文,故走
/// `from_yaml_str` 的源文本捕获路径(见 config.rs `parse_grid_axes_raw`)。
#[test]
fn test_grid_axis_value_preserves_yaml_source_via_serde() {
use crate::config::GridConfig;
// 两种块格式都覆盖:流式 + 块式。
let yaml_flow = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
let yaml_block = "grid:\n teff:\n - 20000\n logg:\n - 5.0\n loghe:\n - -2\n logc:\n - -4\n logn:\n - -4\n logo:\n - -4\n";
for (label, yaml) in [("flow", yaml_flow), ("block", yaml_block)] {
let cfg =
GridConfig::from_yaml_str(yaml).unwrap_or_else(|_| panic!("{} 解析失败", label));
let pt = GridPointParams {
teff: cfg.grid.teff[0].clone(),
logg: cfg.grid.logg[0].clone(),
loghe: cfg.grid.loghe[0].clone(),
logc: cfg.grid.logc[0].clone(),
logn: cfg.grid.logn[0].clone(),
logo: cfg.grid.logo[0].clone(),
};
assert_eq!(
pt.model_name(),
"t20000_g5.0_he-2_c-4_n-4_o-4",
"{} 格式应保留源精度",
label
);
}
}
/// `Deref<Target=f64>` 让算术调用点无感:可直接比较/运算。
#[test]
fn test_grid_axis_value_deref_to_f64() {
let v: GridAxisValue = 5.0.into();
assert!((*v - 5.0).abs() < 1e-9);
assert!(*v > 4.0);
let sum = *v + 1.0_f64;
assert!((sum - 6.0).abs() < 1e-9);
}
#[test]
fn test_grid_point_status_display_and_conversion() {
assert_eq!(GridPointStatus::Pending.to_string(), "pending");
+199
View File
@@ -0,0 +1,199 @@
//! 归档白名单:决定沙盒里的哪些文件值得进入持久归档目录(`data/result/<name>/`)。
//!
//! node 端实时结果归档(`executor::save_result_artifacts`)与离线迁移工具(`import_results`
//! 共用本模块的 [`is_result_worthy`],确保两条路径的结果归档口径一致、不会随时间漂移。
//!
//! ## 设计原则
//!
//! 归档目标只保留**有语义价值的产物**,丢弃 Tlusty/Synspec 运行时产生的中间工作单元
//! `fort.1/2/3/13/14/18/22/42/44/50/57/69/82/95` 等)。旧版用「拷贝所有普通文件」的
//! catch-all 策略,把这些无语义单元也搬进了归档,浪费磁盘(每个模型约 2MB / 4.8MB)。
//!
//! ## 保留的文件名形态(`model_name` 为网格点权威名,如 `t20000_g5.0_he-2_c-4_n-4_o-4`
//!
//! 1. **裸名保留**(有独立语义,不以 model_name 为前缀):
//! `conv.json`、`fort.8`synspec 输入大气)、`fort.55`synspec 控制卡)
//! 2. **科学核心**`<name>.7`、`<name>.spec`、`<name>.cont`、`<name>.iden`、`<name>.log`
//! 3. **阶段快照**`<name>.<label>.5/.6/.err/.nst/.7`label ∈ lte/nc/nl/seed_nc
//! 4. **收敛诊断**`<name>.<label>_chmax*.9`**唯一保留的 .9**;裸 `<name>.<label>.9`
//! 已在 runner 源头停止写出,因其与 `_chmax*.9` 内容完全重复)
//!
//! 符号链接、子目录、`.tmp`、`fort.84` 及所有 Tlusty 中间单元均不在白名单内,自然被跳过。
/// 科学核心产物的文件名后缀(挂在 `<name>.` 之后,无阶段标签)。
const SCIENCE_SUFFIXES: &[&str] = &["7", "spec", "cont", "iden", "log"];
/// 阶段快照的文件名后缀(挂在 `<name>.<label>.` 之后)。
const STAGE_SNAPSHOT_SUFFIXES: &[&str] = &["5", "6", "err", "nst", "7"];
/// 合法阶段标签(来自 `default_cold_chain` / `default_seed_chain` 的 label)。
/// 阶段标签由 workflow 配置保证唯一,不会与科学后缀或 synspec 产物冲突。
///
/// **约束**:此处硬编码了默认链的 4 个标签。runner 的 `run_model_with_timeout`
/// 虽接受 `custom_chain`(可含任意 label),但当前唯一生产调用方(executor)传 `None`
/// 走默认链,故白名单覆盖安全。若将来启用自定义 chain 且引入新标签,需同步加入此处,
/// 否则带新标签的阶段快照(`.5/.6/.err/.nst/.7`)和 `_chmax*.9` 会被白名单静默丢弃。
const STAGE_LABELS: &[&str] = &["lte", "nc", "nl", "seed_nc"];
/// 有独立语义、保留的裸文件名(不以 model_name 为前缀)。
const BARE_KEEPS: &[&str] = &["conv.json", "fort.8", "fort.55"];
/// 判断沙盒里的文件 `fname` 是否值得归档。
///
/// `model_name` 是网格点的权威名(如 `t20000_g5.0_he-2_c-4_n-4_o-4`),用于识别
/// `<name>.<后缀>` 形式的产物。裸名保留(conv.json/fort.8/fort.55)不依赖 model_name。
///
/// 返回 `true` 表示应归档,`false` 表示应跳过(中间单元、未知后缀、`.tmp`、fort.84 等)。
///
/// # 示例
/// ```
/// use common::result_filter::is_result_worthy;
/// let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
/// assert!(is_result_worthy("conv.json", name));
/// assert!(is_result_worthy(&format!("{name}.7"), name));
/// assert!(is_result_worthy(&format!("{name}.nl.nst"), name));
/// assert!(is_result_worthy(&format!("{name}.nl_chmax0.001.9"), name));
/// assert!(!is_result_worthy("fort.18", name));
/// assert!(!is_result_worthy("fort.84", name));
/// assert!(!is_result_worthy(&format!("{name}.nl.9"), name)); // 无 _chmax 的 .9 已不再写
/// ```
pub fn is_result_worthy(fname: &str, model_name: &str) -> bool {
// 原子写入残留、NATOMS 崩溃缓存:显式排除(虽不在白名单,提前 short-circuit 更清晰)。
if fname.ends_with(".tmp") || fname == "fort.84" {
return false;
}
// 1. 裸名保留(有独立语义)。
if BARE_KEEPS.contains(&fname) {
return true;
}
// 其余保留项必须以 `<model_name>.` 开头。不以该前缀开头的文件(含所有其它 fort.N
// 中间单元)一律跳过。
let prefix = format!("{}.", model_name);
let Some(rest) = fname.strip_prefix(&prefix) else {
return false;
};
// 此时 rest 形如:`7` / `spec` / `nl.7` / `nl.nst` / `nl_chmax0.001.9` 等。
// 2. 科学核心:rest 本身就是后缀(`<name>.7`、`<name>.spec` 等)。
if SCIENCE_SUFFIXES.contains(&rest) {
return true;
}
// 3. 阶段快照:`<label>.<suffix>`(如 `nl.7`、`nc.nst`)。
// 用 split_once('.', label/suffix) 切一刀;label 必须在 STAGE_LABELS 内,
// suffix 必须在 STAGE_SNAPSHOT_SUFFIXES 内。这样能精确排除 `<name>.nl.9`
// (suffix=9 不在快照后缀集)等。
if let Some((label, suffix)) = rest.split_once('.') {
if STAGE_LABELS.contains(&label) && STAGE_SNAPSHOT_SUFFIXES.contains(&suffix) {
return true;
}
}
// 4. 收敛诊断:`<label>_chmax*.9`(如 `nl_chmax0.001.9`)。
// rest 以 `<label>_chmax` 开头,以 `.9` 结尾。这是唯一保留的 .9 形态。
if rest.ends_with(".9") {
for label in STAGE_LABELS {
let tag = format!("{}_chmax", label);
if rest.starts_with(&tag) && rest.ends_with(".9") {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
const NAME: &str = "t20000_g5.0_he-2_c-4_n-4_o-4";
#[test]
fn test_bare_keeps() {
assert!(is_result_worthy("conv.json", NAME));
assert!(is_result_worthy("fort.8", NAME));
assert!(is_result_worthy("fort.55", NAME));
}
#[test]
fn test_science_core() {
for s in ["7", "spec", "cont", "iden", "log"] {
let f = format!("{}.{}", NAME, s);
assert!(is_result_worthy(&f, NAME), "{} 应归档", f);
}
}
#[test]
fn test_stage_snapshots() {
// 各阶段标签 × 快照后缀 都应保留
for label in STAGE_LABELS {
for s in STAGE_SNAPSHOT_SUFFIXES {
let f = format!("{}.{}.{}", NAME, label, s);
assert!(is_result_worthy(&f, NAME), "{} 应归档", f);
}
}
}
#[test]
fn test_convergence_diagnostic_chmax9() {
// 唯一保留的 .9:带 _chmax 标签
for label in STAGE_LABELS {
let f = format!("{}.{}_chmax0.001.9", NAME, label);
assert!(is_result_worthy(&f, NAME), "{} 应归档", f);
}
// 科学计数法形态的 chmax(1e-05)也应保留
assert!(is_result_worthy(&format!("{}.nc_chmax1e-05.9", NAME), NAME));
}
#[test]
fn test_redundant_dot9_without_chmax_skipped() {
// 无 _chmax 的阶段 .9 快照:与 _chmax.9 内容重复,跳过
for label in STAGE_LABELS {
let f = format!("{}.{}.9", NAME, label);
assert!(
!is_result_worthy(&f, NAME),
"{} 应跳过(与 _chmax.9 重复)",
f
);
}
}
#[test]
fn test_tlusty_intermediate_units_skipped() {
// 所有 Tlusty 中间工作单元一律跳过(含 0 字节空单元和大文件)
for f in [
"fort.1", "fort.2", "fort.3", "fort.10", "fort.11", "fort.13", "fort.14", "fort.18",
"fort.22", "fort.42", "fort.44", "fort.50", "fort.57", "fort.69", "fort.82", "fort.95",
"fort.84",
] {
assert!(!is_result_worthy(f, NAME), "{} 应跳过", f);
}
}
#[test]
fn test_tmp_and_unknown_skipped() {
assert!(!is_result_worthy("residue.tmp", NAME));
assert!(!is_result_worthy("random_file.txt", NAME));
assert!(!is_result_worthy("nst", NAME)); // 裸 nst(应已被 runner 改名)
assert!(!is_result_worthy("fort.9", NAME)); // 裸 fort.9(应已被 runner 删除)
assert!(!is_result_worthy("fort.12", NAME)); // 裸 fort.12(已 copy 为 .iden
assert!(!is_result_worthy("fort.17", NAME)); // 裸 fort.17(已 copy 为 .cont
}
#[test]
fn test_unknown_suffix_with_name_prefix_skipped() {
// 以 model_name 开头但后缀不在白名单的也应跳过
assert!(!is_result_worthy(&format!("{}.unknown", NAME), NAME));
assert!(!is_result_worthy(&format!("{}.nl.foo", NAME), NAME));
}
#[test]
fn test_unknown_stage_label_skipped() {
// 未知的阶段标签不归档(防御性:未来若引入新标签需显式加入 STAGE_LABELS
assert!(!is_result_worthy(&format!("{}.xxx.7", NAME), NAME));
assert!(!is_result_worthy(&format!("{}.xxx.nst", NAME), NAME));
}
}
+109 -3
View File
@@ -125,6 +125,7 @@ impl<'a> ExecutionRunner<'a> {
pub async fn run_model(
&self,
params: &GridPointParams,
name: &str,
task_type: TaskType,
custom_chain: Option<Vec<StageConfig>>,
seed_atmos: Option<&Path>,
@@ -132,6 +133,7 @@ impl<'a> ExecutionRunner<'a> {
) -> Result<ModelSummary> {
self.run_model_with_timeout(
params,
name,
task_type,
custom_chain,
seed_atmos,
@@ -141,17 +143,32 @@ impl<'a> ExecutionRunner<'a> {
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn run_model_with_timeout(
&self,
params: &GridPointParams,
name: &str,
task_type: TaskType,
custom_chain: Option<Vec<StageConfig>>,
seed_atmos: Option<&Path>,
synspec_cfg: Option<&SynspecConfig>,
timeout_sec: u64,
) -> Result<ModelSummary> {
let name = params.model_name();
let model_dir = self.work_dir.join(&name);
// `name` 取自权威的 TaskSpec.point_nameDB 的 grid_points.name 列,源精度正确),
// 而非 params.model_name()。原因:服务端把 GridPointParams 存成 6 个 REAL 数值列,
// 回读时用 from_value() 反推文本会丢精度("5.0"→"5"),导致 params.model_name()
// 产出错误名(g5 而非 g5.0)。point_name 走独立 TEXT 列,精度全程保留。
// 下游(沙盒子目录、各阶段快照、conv.json.name、归档目录)全部用此 name,
// 故只需在此处用权威 name 即可让整条链精度正确。
let derived = params.model_name();
if derived != name {
warn!(
"网格点权威名 {} 与 params 重推名 {} 不一致(DB REAL 列回读丢精度所致),\
采用权威 point_name",
name, derived
);
}
let model_dir = self.work_dir.join(name);
tokio::fs::create_dir_all(&model_dir).await?;
info!("开始物理计算网格模型 {} (类型: {:?})", name, task_type);
@@ -279,6 +296,9 @@ impl<'a> ExecutionRunner<'a> {
best_max_relc: None,
elapsed_sec: stage_t0.elapsed().as_secs_f64(),
note: None,
last_iter: None,
worst_depth: None,
n_depths: None,
};
if rc == 0 && fort7.is_file() {
@@ -287,6 +307,11 @@ impl<'a> ExecutionRunner<'a> {
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);
// Save fort.9 snapshot
let snap_name = format!("{}.{}_chmax{}.9", name, stage_def.label, eff_chmax);
@@ -306,6 +331,30 @@ impl<'a> ExecutionRunner<'a> {
stage_summary.note = Some(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;
}
}
final_chmax = stage_def.chmax;
final_converged = stage_summary.converged;
if let Some(r) = stage_summary.best_max_relc {
@@ -429,9 +478,28 @@ impl<'a> ExecutionRunner<'a> {
synspec_err = Some("No atmosphere .7 produced".to_string());
}
// 清理冗余的裸文件:这些文件的内容已被带阶段标签的快照或重命名的科学产物覆盖,
// 保留它们只会与归档里的 <name>.<label>.* / <name>.iden / <name>.cont 等重复(尤其
// .spec/.cont 是大文件,双份存储浪费磁盘)。删除后归档目录干净无冗余。
// 注意:fort.8synspec 输入大气)和 fort.55synspec 控制卡)有独立语义,予以保留。
for redundant in [
format!("{}.5", name), // 同 <name>.<最后阶段label>.5
format!("{}.6", name), // 同 <name>.<最后阶段label>.6
format!("{}.err", name), // 同 <name>.<最后阶段label>.err
"nst".to_string(), // 同 <name>.<最后阶段label>.nst
"fort.9".to_string(), // 内容已被 <name>.<label>_chmax*.9 收敛诊断覆盖
"fort.12".to_string(), // 同 <name>.idensynspec 谱线证认)
"fort.17".to_string(), // 同 <name>.contsynspec 连续谱)
] {
let p = model_dir.join(&redundant);
if p.is_file() {
let _ = tokio::fs::remove_file(&p).await;
}
}
let elapsed_sec = t0.elapsed().as_secs_f64();
let summary = ModelSummary {
name,
name: name.to_string(),
params: params.clone(),
stages: stage_summaries,
converged: final_converged,
@@ -460,6 +528,8 @@ impl<'a> ExecutionRunner<'a> {
#[cfg(test)]
mod tests {
use crate::models::{GridAxisValue, GridPointParams};
#[test]
fn test_synspec_timeout_calculation() {
let long_tlusty_timeout: u64 = 7200;
@@ -470,4 +540,40 @@ mod tests {
let synspec_timeout_short = 600_u64.min(short_tlusty_timeout);
assert_eq!(synspec_timeout_short, 300);
}
/// 回归测试:复现命名精度丢失场景,并锁定「runner 用 point_name 作权威名」的契约。
///
/// 背景:服务端 grid_points 表把 GridPointParams 存成 6 个 REAL 列,回读时用
/// `GridAxisValue::from_value()` 反推文本(`format_float_minimal`),整数-valued
/// 浮点数会丢小数(5.0 → "5")。于是 `params.model_name()` 产出 `g5` 而非 `g5.0`。
/// 而 `TaskSpec.point_name`DB 的 name TEXT 列,源精度)始终是 `g5.0`。
///
/// runner 的 `run_model_with_timeout` 现接收外部 `name: &str`(由 executor 传入
/// `task.point_name`),不再用降级的 `params.model_name()`。本测试构造降级后的
/// params,证明二者确实不同,从而确认「必须用 point_name」的修复是必要的。
#[test]
fn test_point_name_bypasses_degraded_params_model_name() {
// 模拟 DB REAL 列回读后的 paramslogg 经 from_value(5.0) 丢精度
let degraded = GridPointParams {
teff: GridAxisValue::from_value(20000.0),
logg: GridAxisValue::from_value(5.0), // text 退化为 "5"
loghe: GridAxisValue::from_value(-2.0),
logc: GridAxisValue::from_value(-4.0),
logn: GridAxisValue::from_value(-4.0),
logo: GridAxisValue::from_value(-4.0),
};
// 权威 point_nameDB name 列,保留源精度)
let point_name = "t20000_g5.0_he-2_c-4_n-4_o-4";
// 降级的 params 重推出的名字丢了 ".0"
assert_ne!(
degraded.model_name(),
point_name,
"降级 params.model_name() 应与权威 point_name 不同(这是 bug 的可观测证据)"
);
assert_eq!(degraded.model_name(), "t20000_g5_he-2_c-4_n-4_o-4");
// runner 现在直接采用 point_name(不再调 params.model_name()),故归档/产物名正确
let authoritative_name = point_name; // 即 executor 传入的 task.point_name
assert_eq!(authoritative_name, "t20000_g5.0_he-2_c-4_n-4_o-4");
}
}
+6 -6
View File
@@ -11,12 +11,12 @@ pub struct SeedMatch {
pub const MAX_GLOBAL_SEED_DISTANCE: f64 = 3.0;
pub fn calculate_seed_distance(cand: &GridPointParams, target: &GridPointParams) -> (bool, f64) {
let d_teff = (cand.teff - target.teff).abs();
let d_logg = (cand.logg - target.logg).abs();
let d_loghe = (cand.loghe - target.loghe).abs();
let d_cno = (cand.logc - target.logc).abs()
+ (cand.logn - target.logn).abs()
+ (cand.logo - target.logo).abs();
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),