feat(all): 物理正确性五重硬门槛、输入文件结构化与 fort.55 错位修复、conv 诊断 DB 化与阶段归因修复、ORELAX 收敛修复与导入工具下线

物理正确性校验体系(common/conv_check.rs +494 行)
- 新增 5 类硬门槛:能量守恒(.6)、温度结构(.7)、emflux 积分校验(.emflux,含全 NaN 判失败)、假收敛排查(itek 轨迹首末比)、b 因子合理性(.bfac)
- runner 在 TLUSTY 阶段结束后执行全部校验,任一失败判 final_converged=false
- GridConfig 新增 8 个可配阈值,经 scheduler→executor→runner 全链路透传

输入文件配置结构化重构(config.rs +1453 行)
- TlustyInput 拆为 dot5/nst 分层结构,字段名严格映射 tlusty208.f READ 语句;SynspecInput 重构为 9 个 Fort55Line 子结构体
- 移除 ChainStep.metals 字段,元素集改由 dot5.atoms/ions 显式声明(gen_input5/nst_writer 同步重写为三源融合 / 分层覆盖)
- fort.55 修复行结构 bug:补全分子表行(7→9 行),IDSTD 50→0 错位修正(影响全部光谱线强归一化,需重算 SYNSPEC 阶段)

conv 诊断 DB 化与阶段归因修复(server)
- 单点详情 conv 面板从磁盘 conv.json 改读 DB grid_points.summary_json;grid_points 新增 summary_json/last_elapsed_sec 两列(旧库幂等 ALTER)
- record_task_report 阶段归因列加 CASE 守卫 + clear_synspec 对称处理,修复 synspec-only/TLUSTY-only 重跑污染统计
- 新增 summary_merge.rs 点级增量合并,避免重跑覆盖诊断字段

收敛性 ORELAX 修复与 seed_chain 可配(sdB_cno.yaml + node)
- nl 阶段加 orelax=0.5、seed_nc 加 orelax=0.3,阻尼中温区 relc 振荡发散
- seed_chain 块可配,executor 优先采用用户配置而非内置默认链

导入工具下线
- 删除 import_results 客户端工具及 Windows 推送脚本;移除 /admin/import_seed 端点
- 改为服务端临时 migrate_conv 端点(扫 conv.json 增量合并入库,迁移后可删)

文档与分析
- 新增 1305 失败点根因分析、fort.14 全 NaN 物理含义分析两份深度文档
- spectrum_correctness_analysis 两次修订标注已修复项;fetch_results.sh 修 trap RETURN 的 set -u 报错
This commit is contained in:
fmq
2026-08-09 12:09:48 +08:00
parent d16b3d3cdc
commit 43b82b1ae2
45 changed files with 6059 additions and 3184 deletions
+164
View File
@@ -0,0 +1,164 @@
//! 一次性迁移端点:把 seeds_dir 下的 conv.json 批量导入 grid_points.summary_json。
//!
//! # 背景
//! 旧版 conv.json 只存磁盘不进 DB。本端点扫 `seeds_dir/<name>/conv.json`
//! 解析为 ModelSummary 后经 `merge_point_summary` 增量合并写入 `grid_points.summary_json`
//! 让详情页 conv 诊断面板从 DB 读取(不再依赖磁盘文件)。
//!
//! # 快速删除
//! 本模块是**临时迁移工具**,迁移完成后删除本文件 + main.rs 中路由注册即可。
use super::AppState;
use axum::{
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use common::models::ModelSummary;
use serde::Deserialize;
use serde_json::json;
use std::path::{Path, PathBuf};
use tracing::{info, warn};
/// `/admin/migrate_conv` 的查询参数。
#[derive(Debug, Deserialize)]
pub struct MigrateConvQuery {
/// 目标工作流名(必填)。
pub workflow: String,
/// 扫描根目录(缺省取 AppState.seeds_dir)。
pub dir: Option<String>,
}
/// 递归收集目录下所有 conv.json 路径。
///
/// 用 std::fs 而非 walkdir,避免给 server 引入临时依赖(本模块要快速删除)。
fn collect_conv_jsons(dir: &Path, results: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_conv_jsons(&path, results);
} else if path.file_name().map(|n| n == "conv.json").unwrap_or(false) {
results.push(path);
}
}
}
/// POST /api/admin/migrate_conv — 扫描 seeds_dir 的 conv.json → 增量合并写入 grid_points.summary_json。
///
/// - Admin 鉴权(与其他 /admin/* 端点一致)。
/// - 幂等:`upsert_grid_point_named` 用 `ON CONFLICT DO NOTHING`,可重复运行。
/// - 增量合并:synspec-only 的 conv.json 只覆盖 synspec 字段,保留既有 TLUSTY 诊断。
pub async fn migrate_conv(
State(state): State<AppState>,
Query(query): Query<MigrateConvQuery>,
) -> Result<impl IntoResponse, crate::api::AppError> {
let scan_dir = query.dir.unwrap_or_else(|| state.seeds_dir.clone());
let workflow = query.workflow;
// 1. 递归扫描 conv.json。
let mut paths = Vec::new();
collect_conv_jsons(Path::new(&scan_dir), &mut paths);
if paths.is_empty() {
return Ok((
StatusCode::OK,
Json(json!({
"success": true,
"message": format!("目录 {} 下未找到 conv.json", scan_dir),
"imported": 0,
"skipped": 0,
})),
));
}
info!("迁移开始:扫到 {} 份 conv.jsondir={}, workflow={}", paths.len(), scan_dir, workflow);
// 2. 逐份解析 + upsert + 增量合并。
let mut ok = 0usize;
let mut skipped = 0usize;
for path in &paths {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
warn!("读取失败 {}: {}", path.display(), e);
skipped += 1;
continue;
}
};
// 少数"conv.json"实际是 node 端失败错误占位(`{"error": "..."}`,见 reporter 错误路径),
// 非有效 ModelSummary → 反序列化失败,正确行为是跳过(这些点无有效收敛数据)。
// name 缺失(ModelSummary.name 必填)也属此类,不应兜底导入伪造点。
let summary: ModelSummary = match serde_json::from_str(&text) {
Ok(s) => s,
Err(e) => {
warn!("解析失败 {}: {}", path.display(), e);
skipped += 1;
continue;
}
};
// 权威点名取 summary.name(源精度真名);空则回退目录名。
let point_name = if !summary.name.is_empty() {
summary.name.clone()
} else {
match path.parent().and_then(|p| p.file_name()) {
Some(n) => n.to_string_lossy().to_string(),
None => {
skipped += 1;
continue;
}
}
};
// methodstages 含 seed_nc → seed_step,否则 cold_run。
let method = if summary.stages.iter().any(|s| s.label == "seed_nc") {
"seed_step"
} else {
"cold_run"
};
// upsert 建点(幂等)+ 增量合并写入 summary_json。
let wave = state
.db
.compute_wave_for_cno_sum(&workflow, summary.params.cno_sum())
.await
.unwrap_or(0);
if let Err(e) = state
.db
.upsert_grid_point_named(&point_name, &summary.params, wave, &workflow)
.await
{
warn!("点 {} upsert 失败: {}", point_name, e);
skipped += 1;
continue;
}
if let Err(e) = state
.db
.upsert_point_summary(&point_name, &workflow, &summary, method)
.await
{
warn!("点 {} summary 写入失败: {}", point_name, e);
skipped += 1;
continue;
}
ok += 1;
}
info!("迁移完成:成功 {} / 跳过 {} / 共 {}", ok, skipped, ok + skipped);
Ok((
StatusCode::OK,
Json(json!({
"success": true,
"message": format!("迁移完成:成功 {} / 跳过 {} / 共 {}", ok, skipped, ok + skipped),
"imported": ok,
"skipped": skipped,
"total": ok + skipped,
})),
))
}
+3 -2
View File
@@ -2,6 +2,7 @@ pub mod admin;
pub mod auth;
pub mod data;
pub mod error;
pub mod migrate;
pub mod node;
pub mod rate_limit;
pub mod seed;
@@ -100,8 +101,8 @@ fn required_role(path: &str, method: &axum::http::Method) -> Option<Role> {
if path == "/status" && method == Method::GET {
return Some(Role::Admin);
}
// 管理 API(节点凭据查看/审批/重发/停用/启用、历史种子导入等)→ Admin
// 注:所有 /admin/* 均需 Admin 鉴权(含 /admin/import_seed,统一在此判定即可,
// 管理 API(节点凭据查看/审批/重发/停用/启用等)→ Admin
// 注:所有 /admin/* 均需 Admin 鉴权,统一在此判定即可,
// 无需为单个子路径重复列举(避免出现被前缀匹配遮蔽的不可达分支)。
if path.starts_with("/admin/") {
return Some(Role::Admin);
+35 -232
View File
@@ -1,11 +1,10 @@
use super::{AppState, AuthenticatedNode};
use axum::{
extract::{Extension, Multipart, Query, State},
extract::{Extension, Multipart, State},
response::IntoResponse,
Json,
};
use common::models::{GridPointParams, ModelSummary, TaskReport, TaskStatus};
use serde::Deserialize;
use serde_json::json;
use std::path::Path;
use tokio::fs;
@@ -268,12 +267,43 @@ pub async fn report_task(
if fs::create_dir_all(&model_dir).await.is_ok() {
let conv_tmp = model_dir.join(format!("conv.json.{}.tmp", uuid::Uuid::new_v4().simple()));
let conv_path = model_dir.join("conv.json");
if fs::write(&conv_tmp, &report.summary_json).await.is_ok() {
// conv.json 写入内容选择:
// - 成功:从 DB 读取合并后的 summary_jsonrecord_task_report 已通过
// merge_point_summary 合并 prior TLUSTY 诊断 + incoming synspec 字段)。
// synspec-only 重跑成功后 raw incoming 只有 synspec 字段(stages 为空),
// 直接写 raw 会丢失 TLUSTY 诊断 → 离线 salvage 导入读 conv.json 时无法恢复。
// - 失败:写 raw incoming(含错误路径 {"error":...},供排错),不读 DBDB
// 可能因 incoming 不可解析而保留 prior,丢失本次错误信息)。
let conv_content = if report.result_valid
&& !report.atmosphere_has_nan
&& report.status == TaskStatus::Completed
{
state
.db
.get_point_summary_json(&workflow_name, &name)
.await
.ok()
.flatten()
.unwrap_or_else(|| report.summary_json.clone())
} else {
report.summary_json.clone()
};
if fs::write(&conv_tmp, &conv_content).await.is_ok() {
let _ = fs::rename(&conv_tmp, &conv_path).await;
}
// Save seed file .7 using atomic temporary writing strategy
if report.result_valid && !report.atmosphere_has_nan {
// Save seed file .7 using atomic temporary writing strategy.
// §7 修复:synspec-only 任务(tlusty_enabled=false)未重算大气,节点上传的 .7 是
// 从服务端下载的原样字节 → 跳过写入,避免冗余 I/O 和潜在的误覆盖(如节点本地归档
// 版本与服务端不一致)。查询失败时 fallback 为 true(保守,不阻断种子写入)。
let tlusty_was_enabled = state
.db
.get_task_tlusty_enabled(&report.task_id)
.await
.ok()
.flatten()
.unwrap_or(true);
if report.result_valid && !report.atmosphere_has_nan && tlusty_was_enabled {
if let Some(bytes) = seed_file_data {
let seed_tmp =
model_dir.join(format!("{}.7.{}.tmp", name, uuid::Uuid::new_v4().simple()));
@@ -341,230 +371,3 @@ fn extract_params(report: &TaskReport) -> Option<GridPointParams> {
.ok()
.map(|summary| summary.params)
}
/// `/admin/import_seed` 的查询参数。
#[derive(Debug, Deserialize)]
pub struct ImportSeedQuery {
/// 目标工作流名(导入到此工作流的 grid_points)。缺省归入 `imported` 工作流。
#[serde(default = "default_import_workflow")]
pub workflow: String,
}
fn default_import_workflow() -> String {
"imported".to_string()
}
/// 历史种子导入端点(Admin 鉴权)。
///
/// 供 `tools/import_results` 把旧版单机 `run_grid.py` 产物(`conv.json` + `.7` 大气文件)
/// 批量回灌进 DCTS。与 `/task/report` 的关键区别:
/// - **跳过任务归属校验**`verify_task_claim`):历史数据无领用语义,导入端点不经过
/// claim/report 队列,直接幂等落库。
/// - **`point_name` 取旧 `conv.json` 的 `name` 字段**Python `gen_input5.model_name`
/// 生成的源精度真名,如 `t20000_g5.0_...`),而非从数值重推——保证迁移逐字符保真。
/// - **真实 `max_relc`** 取自 `summary.final_max_relc`(旧版已记录),不硬编码。
///
/// 幂等:`upsert_grid_point` 用 `ON CONFLICT DO NOTHING``.7`/`conv.json` 原子覆盖写,
/// 可重复运行。
pub async fn import_seed(
State(state): State<AppState>,
Query(query): Query<ImportSeedQuery>,
mut multipart: Multipart,
) -> Result<impl IntoResponse, crate::api::AppError> {
let mut summary_json: Option<String> = None;
let mut seed_file_data: Option<Vec<u8>> = None;
// 大气收敛途径(cold_run/seed_step):由 import_results 工具依据旧 conv.json 的 stages 是否
// 含 seed_nc 判定后透传。缺失或非法时兜底 cold_run(容错旧版工具 / 防注入)。
// 语义为 TLUSTY 阶段策略,写入 grid_points.tlusty_success_method。
let mut success_method: Option<String> = None;
let mut multipart_error = false;
// 显式遍历全部字段,记录读取错误。旧实现 `while let Ok(...)` 在首字段出错时静默停止,
// 可能丢失后续 seed_file/report 字段导致半截请求被处理。
loop {
match multipart.next_field().await {
Ok(Some(field)) => {
let field_name = field.name().unwrap_or("").to_string();
if field_name == "report" {
match field.bytes().await {
Ok(bytes) => {
summary_json = Some(String::from_utf8_lossy(&bytes).to_string());
}
Err(e) => {
warn!("历史种子导入:读取 report 字段失败: {}", e);
multipart_error = true;
}
}
} else if field_name == "seed_file" {
match field.bytes().await {
Ok(bytes) => {
seed_file_data = Some(bytes.to_vec());
}
Err(e) => {
warn!("历史种子导入:读取 seed_file 字段失败: {}", e);
multipart_error = true;
}
}
} else if field_name == "tlusty_success_method" {
match field.text().await {
Ok(text) => {
success_method = Some(text);
}
Err(e) => {
warn!("历史种子导入:读取 tlusty_success_method 字段失败: {}", e);
multipart_error = true;
}
}
}
}
Ok(None) => break,
Err(e) => {
warn!("历史种子导入:解析 multipart 字段时出错: {}", e);
multipart_error = true;
break;
}
}
}
if multipart_error {
return Err(crate::api::AppError::BadRequest(
"multipart 请求体解析不完整(字段读取失败)".to_string(),
));
}
let summary_json = match summary_json {
Some(s) => s,
None => {
return Err(crate::api::AppError::BadRequest(
"请求中缺少 report 字段(旧版 conv.json 内容)".to_string(),
));
}
};
// 解析旧版 conv.jsonModelSummary 结构)取 name / params / 收敛状态 / 真实 max_relc。
let summary: ModelSummary = match serde_json::from_str(&summary_json) {
Ok(s) => s,
Err(e) => {
warn!("历史种子导入:conv.json 解析失败: {}", e);
return Err(crate::api::AppError::BadRequest(
"conv.json 解析失败,非合法 ModelSummary".to_string(),
));
}
};
// point_name 优先用旧 conv.json 的 name(源精度真名);回退到 params 规范名。
let name = if !summary.name.is_empty() {
summary.name.clone()
} else {
summary.params.model_name()
};
// 名称合法性校验(防路径穿越),与 report_task 同口径。
if !super::workflow::is_valid_point_name(&name) {
warn!("历史种子导入:拒绝非法网格点名称: {}", name);
return Err(crate::api::AppError::BadRequest(
"非法的网格点名称参数".to_string(),
));
}
let workflow_name = query.workflow;
let params = summary.params.clone();
let converged = summary.result_valid && !summary.atmosphere_has_nan;
let max_relc = summary.final_max_relc;
// 1. 幂等写入 grid_pointsON CONFLICT DO NOTHING):无需事先 start 工作流。
// 用权威 name(旧 conv.json 的源精度真名),而非从 params 重推——导入路径的
// params 来自旧 JSON(无源文本,model_name() 会失真)。
// wave 修复(2026-08-04):此前硬编码 wave=0,导入点全部被错误归入第一波,
// 前端难度波次推进显示错误。现按 initialize_grid 同口径计算波次(该工作流内
// cno_sum 严格小于本点的去重值个数),新导入点落库即归入正确波次。
let wave = state
.db
.compute_wave_for_cno_sum(&workflow_name, params.cno_sum())
.await
.unwrap_or(0);
if let Err(e) = state
.db
.upsert_grid_point_named(&name, &params, wave, &workflow_name)
.await
{
tracing::error!("历史种子导入:upsert grid_points {} 失败: {}", name, e);
return Err(crate::api::AppError::Internal(e));
}
// 2. 落地 conv.json(原子 tmp→rename)。
let model_dir = Path::new(&state.seeds_dir).join(&name);
if fs::create_dir_all(&model_dir).await.is_ok() {
let conv_tmp = model_dir.join(format!("conv.json.{}.tmp", uuid::Uuid::new_v4().simple()));
let conv_path = model_dir.join("conv.json");
if fs::write(&conv_tmp, &summary_json).await.is_ok() {
let _ = fs::rename(&conv_tmp, &conv_path).await;
}
// 3. 收敛且干净才写 .7 + 入种子库(与 report_task 同口径)。
if converged {
if let Some(bytes) = seed_file_data {
let seed_tmp =
model_dir.join(format!("{}.7.{}.tmp", name, uuid::Uuid::new_v4().simple()));
let seed_path = model_dir.join(format!("{}.7", name));
if fs::write(&seed_tmp, bytes).await.is_ok()
&& fs::rename(&seed_tmp, &seed_path).await.is_ok()
{
info!(
"历史种子导入:网格点 {} 收敛种子已落地: {} (max_relc={:?})",
name,
seed_path.display(),
max_relc
);
let _ = state
.db
.insert_seed_named(&name, &params, &seed_path.to_string_lossy())
.await;
}
} else {
warn!(
"历史种子导入:网格点 {} 声称收敛但未上传 seed_file,跳过种子写入",
name
);
}
}
}
// 4. 更新 grid_points 状态:收敛→converged(大气归因 tlusty_success_method=工具判定的途径);否则维持 pending
// 让正常调度处理(导入未收敛点无意义,但记录其尝试)。
// 途径缺失或非法时兜底 cold_run(容错旧版工具 / 防注入),由 db 层再次白名单校验。
if converged {
// 归一化:仅接受 cold_run / seed_step,其余(含 None)一律兜底 cold_run。
let method = match success_method.as_deref() {
Some("seed_step") => "seed_step",
_ => "cold_run",
};
if let Err(e) = state
.db
.mark_grid_point_imported(&name, &workflow_name, Some(summary.elapsed_sec), method)
.await
{
warn!("历史种子导入:标记 {} 为 converged 失败: {}", name, e);
}
}
info!(
"历史种子导入完成:网格点 {} (workflow={}, converged={}, success_method={}, max_relc={:?})",
name,
workflow_name,
converged,
success_method.as_deref().unwrap_or("(default cold_run)"),
max_relc
);
Ok((
StatusCode::OK,
Json(json!({
"status": "ok",
"point_name": name,
// 注:此 "converged" 键是大气收敛标志(由 summary.result_valid 派生),与 grid_points.status
// 的 completed 重命名无关,勿改(改键名会破坏历史种子导入客户端)。
"converged": converged,
"max_relc": max_relc,
})),
))
}
+19 -32
View File
@@ -615,12 +615,11 @@ pub async fn get_workflow_points(
}
}
/// 单网格点详情:点行 + 全部尝试历史 + conv.json 逐阶段诊断。
/// 单网格点详情:点行 + 全部尝试历史 + summary_json 逐阶段诊断。
///
/// conv.json 读自 `seeds_dir/<point>/conv.json`(单层目录)。点名经 `is_valid_point_name`
/// 白名单(无 `/`、`\`,拒前导 `.`)——即路径穿越的前置闸门;读盘后再做 canonicalize
/// 归属兜底校验(纵深防御)。缺失/读失败/解析失败一律 `conv: null`(仍 200),
/// 前端降级显示"诊断文件不可用"。
/// conv 诊断面板读自 `grid_points.summary_json`(点级诊断快照,DB 权威源)。
/// synspec-only 重跑时由 `record_task_report` 的增量合并保证 TLUSTY 字段不丢失。
/// 缺失/读失败/解析失败一律 `conv: null`(仍 200),前端降级显示"诊断文件不可用"。
pub async fn get_workflow_point_detail(
State(state): State<AppState>,
AxumPath((name, point)): AxumPath<(String, String)>,
@@ -643,33 +642,21 @@ pub async fn get_workflow_point_detail(
};
let attempts = state.db.list_point_attempts(&name, &point).await?;
let conv_path = std::path::Path::new(&state.seeds_dir)
.join(&point)
.join("conv.json");
let conv: Option<common::models::ModelSummary> =
match tokio::fs::read_to_string(&conv_path).await {
Ok(s) => {
let confined = std::path::Path::new(&state.seeds_dir)
.canonicalize()
.ok()
.zip(conv_path.canonicalize().ok())
.map(|(root, f)| f.starts_with(root))
.unwrap_or(false);
if confined {
match serde_json::from_str(&s) {
Ok(summary) => Some(summary),
Err(e) => {
tracing::warn!("网格点 {} 的 conv.json 解析失败: {}", point, e);
None
}
}
} else {
tracing::warn!("网格点 {} 的 conv.json 路径越界,拒绝读取", point);
None
}
}
Err(_) => None,
};
// conv 诊断面板数据源:grid_points.summary_json(点级诊断快照)。
// 此前读磁盘 conv.jsonsynspec-only 重跑会覆盖 TLUSTY 字段;改为查 DB 后由
// record_task_report 的增量合并保证 TLUSTY 诊断不丢失。
let conv: Option<common::models::ModelSummary> = match state
.db
.get_point_summary_json(&name, &point)
.await
{
Ok(Some(json)) => serde_json::from_str(&json).ok(),
Ok(None) => None,
Err(e) => {
tracing::warn!("网格点 {} 的 summary_json 读取失败: {}", point, e);
None
}
};
Ok((
StatusCode::OK,
+101 -48
View File
@@ -52,10 +52,10 @@ impl Database {
/// 的去重值个数,即 `cno_sum` 升序中的桶序号——与 `initialize_grid` 的波次分组口径一致
/// `scheduler.rs` 沿排序序扫描,cno_sum 变化 >1e-5 则 wave+1)。
///
/// 历史种子导入(`import_seed`)用它替代此前硬编码的 `wave=0`:导入点按现有规则归入
/// 正确波次,前端难度波次推进不再把全部导入点挤在第一波。对已完全填充的工作流(所有
/// cno_sum 等级都在库内),任意导入顺序的 rank 都正确;对全新工作流,配合 import_results
/// 工具按 cno_sum 升序导入即可保证最终波次一致。
/// 离线导入工具(`tools/import_db`)用它替代此前硬编码的 `wave=0`:导入点按现有规则
/// 归入正确波次,前端难度波次推进不再把全部导入点挤在第一波。对已完全填充的工作流
/// (所有 cno_sum 等级都在库内),任意导入顺序的 rank 都正确;对全新工作流,配合
/// 导入工具按 cno_sum 升序导入即可保证最终波次一致。
pub async fn compute_wave_for_cno_sum(&self, workflow_name: &str, cno_sum: f64) -> Result<i32> {
let pool = self.pool.clone();
let wf = workflow_name.to_string();
@@ -527,50 +527,6 @@ impl Database {
Ok(changed)
}
/// 历史种子导入专用:把网格点标记为 converged 并记录大气收敛途径 `tlusty_success_method`。
///
/// 与正常 `record_task_report` 路径的区别:导入不走 task 队列,无 task_type 可取,
/// 故由导入工具(import_results)依据旧 conv.json 的 stages 是否含 seed_nc 判定该点
/// 当初是冷启动收敛(cold_run)还是种子步进收敛(seed_step),经 multipart 字段透传至此。
/// 导入点因此融入冷启动/种子步进统计,而非独立为 imported 分类。
///
/// - `success_method`:须为 "cold_run" 或 "seed_step",非法值兜底为 "cold_run"(防注入)。
/// 语义为 TLUSTY 阶段策略,写入 `tlusty_success_method` 列。
/// - `elapsed_sec`:旧版 conv.json 的单点墙钟耗时(`summary.elapsed_sec`),落入
/// `last_elapsed_sec` 列使迁移点在详情页/点表保留真实耗时;无此数据传 None。
pub async fn mark_grid_point_imported(
&self,
name: &str,
workflow_name: &str,
elapsed_sec: Option<f64>,
success_method: &str,
) -> Result<()> {
// 白名单校验:仅接受两种合法途径,非法值兜底 cold_run(避免拼接 SQL 注入风险)。
let method = match success_method {
"seed_step" => "seed_step",
_ => "cold_run",
};
let pool = self.pool.clone();
let name_owned = name.to_string();
let wf = workflow_name.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
// 阶段列(5b):导入点是历史 TLUSTY 大气收敛 → tlusty_status='converged'
// synspec_status 保持 NULL(历史结果仅大气,光谱未运行/未记录)。与正常结算路径的
// 状态一致性(审查 #2 修正:原实现不设阶段列,导入点与正常点状态口径不一致)。
conn.execute(
"UPDATE grid_points SET status = 'completed', tlusty_success_method = ?1, last_elapsed_sec = ?2, tlusty_status = 'converged' \
WHERE name = ?3 AND workflow_name = ?4",
params![method, elapsed_sec, name_owned, wf],
)?;
Ok(())
})
.await??;
Ok(())
}
pub async fn get_grid_point_status(
&self,
name: &str,
@@ -934,4 +890,101 @@ impl Database {
})
.await?
}
/// 取网格点的 summary_json(点级诊断快照),供详情页 conv 诊断面板渲染。
///
/// 与 `get_workflow_point_row` 分离:summary_json 可能很大(含 stages/itek_history
/// 等嵌套诊断),列表查询(`list_workflow_points`)不需要它,故独立查询避免每行
/// 拖带大 JSON。返回 None = 该点无诊断数据(未跑过或尚未结算)。
pub async fn get_point_summary_json(
&self,
wf: &str,
point: &str,
) -> Result<Option<String>> {
let pool = self.pool.clone();
let wf = wf.to_string();
let point = point.to_string();
tokio::task::spawn_blocking(move || -> Result<Option<String>> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let res = conn.query_row(
"SELECT summary_json FROM grid_points WHERE name = ?1 AND workflow_name = ?2",
params![point, wf],
|r| r.get::<_, Option<String>>(0),
);
match res {
Ok(v) => Ok(v),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await?
}
/// 离线/批量导入专用:把一个点级 [`ModelSummary`] 增量合并写入 grid_points。
///
/// 供离线导入工具(`tools/import_db`)逐份合并多节点 conv.json 时复用。与在线
/// `record_task_report` 路径共享 `merge_point_summary` 合并逻辑,保证一致性:
/// incoming.stages 非空 → 整体替换;为空 → 只覆盖 synspec 字段,保留既有 TLUSTY 诊断。
///
/// 同时更新 status / method / elapsed_sec / tlusty_status 等调度列(等价于旧
/// `mark_grid_point_imported` 的职责并入此方法)。调用方须先 `upsert_grid_point_named`
/// 建点(保证行存在)。
///
/// - `method`TLUSTY 收敛策略(`cold_run` / `seed_step`),由 stages 是否含 `seed_nc` 判定。
/// - 返回合并后的 summary 文本(供调用方日志/验证)。
pub async fn upsert_point_summary(
&self,
name: &str,
workflow_name: &str,
incoming: &common::models::ModelSummary,
method: &str,
) -> Result<String> {
// 白名单校验:仅接受两种合法途径,非法值兜底 cold_run(避免拼接 SQL 注入风险)。
let method = match method {
"seed_step" => "seed_step",
_ => "cold_run",
};
let converged = incoming.result_valid && !incoming.atmosphere_has_nan;
// 增量合并:读旧 summary_json → merge_point_summary → 写回。
let prior_json = self.get_point_summary_json(workflow_name, name).await?;
let prior = prior_json
.as_deref()
.and_then(|s| serde_json::from_str::<common::models::ModelSummary>(s).ok());
let merged = common::summary_merge::merge_point_summary(prior.as_ref(), incoming);
let merged_json = serde_json::to_string(&merged).unwrap_or_default();
let pool = self.pool.clone();
let name_owned = name.to_string();
let wf = workflow_name.to_string();
let merged_json_clone = merged_json.clone();
let elapsed_sec = merged.elapsed_sec;
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
// 收敛 → completed(与 record_task_report success 分支同口径:tlusty_status='converged')。
// 未收敛 → pending(让调度器后续处理,不在此翻 failed——离线导入无失败语义)。
if converged {
conn.execute(
"UPDATE grid_points SET status = 'completed', last_elapsed_sec = ?1, \
tlusty_success_method = ?2, tlusty_status = 'converged', summary_json = ?3 \
WHERE name = ?4 AND workflow_name = ?5",
params![elapsed_sec, method, merged_json_clone, name_owned, wf],
)?;
} else {
// 未收敛:仍写入 summary_json(保留诊断供排查),但状态不变更。
conn.execute(
"UPDATE grid_points SET last_elapsed_sec = ?1, summary_json = ?2 \
WHERE name = ?3 AND workflow_name = ?4",
params![elapsed_sec, merged_json_clone, name_owned, wf],
)?;
}
Ok(())
})
.await??;
Ok(merged_json)
}
}
+726 -13
View File
@@ -69,7 +69,12 @@ const GRID_POINTS_SCHEMA: &str = "CREATE TABLE grid_points (
-- H1 修复(M11):运行时回退把点打回 pending 时记录的「剩余策略链」(JSON 数组)。
-- 供调度路径识别「该点已失败过 cold_run、正在等种子」→ 重派时用剩余链而非完整 YAML 链,
-- 避免重跑已失败策略导致的无界失败重试活锁(见 scheduler.rs H1 注释)。NULL = 无标记。
pending_strategies TEXT
pending_strategies TEXT,
-- 最近一次尝试的真实墙钟耗时(秒,Worker 回报值;旧数据为 None)。
last_elapsed_sec REAL,
-- 点级诊断快照(完整 ModelSummary JSON)。详情页 conv 诊断面板的数据源。
-- synspec-only 重跑时经 merge_point_summary 增量合并,保留 TLUSTY 字段不丢失。
summary_json TEXT
)";
#[derive(Debug)]
@@ -431,15 +436,20 @@ impl Database {
if !has_tasks_elapsed {
let _ = conn.execute("ALTER TABLE tasks ADD COLUMN elapsed_sec REAL", []);
}
let has_gp_elapsed = conn
.prepare("PRAGMA table_info(grid_points)")?
.query_map([], |r| r.get::<_, String>(1))?
.any(|r| r.map(|n| n == "last_elapsed_sec").unwrap_or(false));
if !has_gp_elapsed {
let _ = conn.execute(
"ALTER TABLE grid_points ADD COLUMN last_elapsed_sec REAL",
[],
);
// grid_points 幂等补列:last_elapsed_sec(列表展示用)与 summary_json(点级诊断快照)。
// 新库由 GRID_POINTS_SCHEMA 建表时即含此二列;此 ALTER 仅兜底旧库(表已存在但缺列)。
// 与上方 tasks 列迁移同模式:PRAGMA 检测 → 缺列才 ALTER。
for (col, sql) in [
("last_elapsed_sec", "ALTER TABLE grid_points ADD COLUMN last_elapsed_sec REAL"),
("summary_json", "ALTER TABLE grid_points ADD COLUMN summary_json TEXT"),
] {
let has_col = conn
.prepare("PRAGMA table_info(grid_points)")?
.query_map([], |r| r.get::<_, String>(1))?
.any(|r| r.map(|n| n == col).unwrap_or(false));
if !has_col {
let _ = conn.execute(sql, []);
}
}
// 阶段独立配置迁移(见 docs/task_engine_decoupling_design.md §4.1):
@@ -1143,9 +1153,30 @@ mod tests {
db.update_grid_status(&p.model_name(), GridPointStatus::Queued, "wf_b")
.await
.unwrap();
db.mark_grid_point_imported(&p2.model_name(), "wf_a", None, "seed_step")
.await
.unwrap();
{
let summary = common::models::ModelSummary {
name: p2.model_name(),
params: p2.clone(),
stages: Vec::new(),
result_valid: true,
final_max_relc: Some(0.001),
final_chmax: Some(0.001),
seed: None,
atmosphere_has_nan: false,
synspec_rc: None,
synspec_error: None,
synspec_sec: None,
elapsed_sec: 0.0,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
db.upsert_point_summary(&summary.name, "wf_a", &summary, "seed_step")
.await
.unwrap();
}
// 全局(None):3 个点,pending/queued/converged 分开计数;
// 导入点按 seed_step 途径计入 seed_step_converged(不再有独立 imported 分类)
@@ -1782,8 +1813,17 @@ mod tests {
synspec_config: common::models::PhaseConfig::default_synspec(),
synspec_params: None,
tlusty_chain_params: None,
seed_chain_params: None,
tlusty_input_params: None,
atmosphere_ref: None,
energy_tolerance: None,
temp_max_factor: None,
temp_floor: None,
temp_ceiling: None,
emflux_tolerance: None,
convergence_min_ratio: None,
bfac_max: None,
bfac_min: None,
};
let syn_task = Uuid::new_v4();
let old_task = Uuid::new_v4();
@@ -3553,4 +3593,677 @@ mod tests {
})
);
}
/// synspec-only 重跑后 tlusty_success_method / tlusty_status 须保留 prior 值,
/// 不能被 NULL 覆写(CASE 守卫修复验证)。
///
/// 场景:先以 TLUSTY 启用(cold_run)跑成功 → tlusty_success_method = "cold_run"。
/// 再以 TLUSTY 关闭(仅 SYNSPEC,场景 B)重跑成功 → tlusty_success_method 仍须为
/// "cold_run",不能被覆写为 NULL。synspec_success_method 应更新为 "standard"。
#[tokio::test]
async fn test_synspec_only_rerun_preserves_tlusty_attribution() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("synrerun.db");
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let wf = "wf_synrerun";
let params = GridPointParams {
teff: 25000.0.into(),
logg: 5.0.into(),
loghe: 2.0.into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let name = params.model_name();
db.upsert_grid_point(&params, 0, wf).await.unwrap();
// ── 第一轮:TLUSTY 启用 + cold_run,成功 ──
let task1 = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some(wf.to_string()),
wave: 0,
timeout_sec: 7200,
tlusty_config: PhaseConfig {
enabled: true,
strategies: vec!["cold_run".to_string()],
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig::default_synspec(),
..Default::default()
};
db.insert_task(&task1).await.unwrap();
let summary1 = common::models::ModelSummary {
name: name.clone(),
params: params.clone(),
stages: vec![common::models::StepSummary {
label: "nl".into(),
chmax: Some(0.001),
lte: "F".into(),
converged: true,
best_max_relc: Some(0.0005),
elapsed_sec: 300.0,
note: None,
last_iter: Some(17),
worst_depth: Some(1),
n_depths: Some(50),
itek_history: vec![],
conv_trace_check: None,
}],
result_valid: true,
final_max_relc: Some(0.0005),
final_chmax: Some(0.001),
seed: None,
atmosphere_has_nan: false,
synspec_rc: Some(0),
synspec_error: None,
synspec_sec: Some(0.3),
elapsed_sec: 300.3,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
let report1 = TaskReport {
task_id: task1.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "test-node".to_string(),
status: TaskStatus::Completed,
result_valid: true,
max_relc: Some(0.0005),
atmosphere_has_nan: false,
elapsed_sec: 300.3,
error_message: None,
summary_json: serde_json::to_string(&summary1).unwrap(),
failed_stage: None,
};
db.record_task_report(&report1, wf).await.unwrap();
// 验证第一轮:tlusty_success_method = cold_run, tlusty_status = converged
let row1 = read_grid_attrs(&db, &name, wf).await;
assert_eq!(row1.status, "completed");
assert_eq!(row1.tlusty_success_method.as_deref(), Some("cold_run"));
assert_eq!(row1.tlusty_status.as_deref(), Some("converged"));
assert_eq!(row1.synspec_success_method.as_deref(), Some("standard"));
assert_eq!(row1.synspec_status.as_deref(), Some("converged"));
// ── 模拟场景 Breset_terminal_points_for_recompute 翻回 pending ──
db.reset_terminal_points_for_recompute(wf).await.unwrap();
// ── 第二轮:TLUSTY 关闭 + SYNSPEC 启用(synspec-only),成功 ──
let task2 = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some(wf.to_string()),
wave: 0,
timeout_sec: 7200,
tlusty_config: PhaseConfig {
enabled: false,
strategies: vec!["cold_run".to_string()],
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig::default_synspec(),
atmosphere_ref: Some(name.clone()),
..Default::default()
};
db.insert_task(&task2).await.unwrap();
// synspec-only 的 summarystages 为空 → merge_point_summary 走字段级合并
let summary2 = common::models::ModelSummary {
name: name.clone(),
params: params.clone(),
stages: Vec::new(),
result_valid: true,
final_max_relc: None,
final_chmax: None,
seed: None,
atmosphere_has_nan: false,
synspec_rc: Some(0),
synspec_error: None,
synspec_sec: Some(0.25),
elapsed_sec: 0.25,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
let report2 = TaskReport {
task_id: task2.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "test-node".to_string(),
status: TaskStatus::Completed,
result_valid: true,
max_relc: None,
atmosphere_has_nan: false,
elapsed_sec: 0.25,
error_message: None,
summary_json: serde_json::to_string(&summary2).unwrap(),
failed_stage: None,
};
db.record_task_report(&report2, wf).await.unwrap();
// ── 核心断言:synspec-only 重跑后 tlusty 侧归因须保留 ──
let row2 = read_grid_attrs(&db, &name, wf).await;
assert_eq!(row2.status, "completed", "重跑成功后应为 completed");
// ★ 修复前:tsm2 = None(裸赋值覆写)。修复后:保留 "cold_run"。
assert_eq!(
row2.tlusty_success_method.as_deref(),
Some("cold_run"),
"synspec-only 重跑后 tlusty_success_method 须保留,不能覆写为 NULL"
);
assert_eq!(
row2.tlusty_status.as_deref(),
Some("converged"),
"synspec-only 重跑后 tlusty_status 须保留"
);
// synspec 侧应更新为新值
assert_eq!(
row2.synspec_success_method.as_deref(),
Some("standard"),
"synspec_success_method 应更新为 standard"
);
assert_eq!(
row2.synspec_status.as_deref(),
Some("converged"),
"synspec_status 应为 converged"
);
// summary_json 的 TLUSTY 诊断也须保留(merge_point_summary 字段级合并)
let merged = db.get_point_summary_json(wf, &name).await.unwrap().unwrap();
let ms: common::models::ModelSummary = serde_json::from_str(&merged).unwrap();
assert_eq!(ms.stages.len(), 1, "stages 须保留 prior 的 TLUSTY 链");
assert_eq!(ms.stages[0].label, "nl");
assert_eq!(ms.final_max_relc, Some(0.0005), "final_max_relc 须保留");
assert_eq!(ms.synspec_rc, Some(0), "synspec_rc 应为新值");
assert_eq!(ms.elapsed_sec, 0.25, "elapsed_sec 应为新值");
// last_elapsed_sec 语义:最近一次尝试耗时。synspec-only 重跑后为 0.25ssynspec 耗时),
// 原 TLUSTY 耗时保留在 stages[].elapsed_sec。ETA 不依赖此列(用 AVG(tasks.elapsed_sec))。
let last_elapsed = read_grid_last_elapsed(&db, &name, wf).await;
assert_eq!(
last_elapsed, Some(0.25),
"last_elapsed_sec 应为 synspec-only 耗时(最近一次尝试),非原 TLUSTY 总耗时"
);
}
/// 辅助:读取 grid_points 的阶段归因列。
async fn read_grid_attrs(db: &Database, name: &str, wf: &str) -> GridAttrs {
let pool = db.pool.clone();
let name = name.to_string();
let wf = wf.to_string();
tokio::task::spawn_blocking(move || -> GridAttrs {
let conn = pool.get().unwrap();
conn.query_row(
"SELECT status, tlusty_success_method, synspec_success_method, tlusty_status, synspec_status \
FROM grid_points WHERE name = ?1 AND workflow_name = ?2",
rusqlite::params![name, wf],
|r| {
Ok(GridAttrs {
status: r.get(0)?,
tlusty_success_method: r.get(1)?,
synspec_success_method: r.get(2)?,
tlusty_status: r.get(3)?,
synspec_status: r.get(4)?,
})
},
)
.unwrap()
})
.await
.unwrap()
}
struct GridAttrs {
status: String,
tlusty_success_method: Option<String>,
synspec_success_method: Option<String>,
tlusty_status: Option<String>,
synspec_status: Option<String>,
}
/// synspec-only 重跑**失败**后 tlusty_success_method / tlusty_status 仍须保留。
///
/// 失败分支的 UPDATE 不写 success_method 列,但 tlusty_status / synspec_status
/// 有 CASE 守卫。验证失败报告不会清空 prior 的 TLUSTY 归因。
#[tokio::test]
async fn test_synspec_only_rerun_failure_preserves_tlusty_attribution() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("synfail.db");
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let wf = "wf_synfail";
let params = GridPointParams {
teff: 25000.0.into(),
logg: 5.0.into(),
loghe: 2.0.into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let name = params.model_name();
db.upsert_grid_point(&params, 0, wf).await.unwrap();
// 第一轮:TLUSTY 启用 + cold_run,成功。
let task1 = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some(wf.to_string()),
wave: 0,
timeout_sec: 7200,
tlusty_config: PhaseConfig {
enabled: true,
strategies: vec!["cold_run".to_string()],
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig::default_synspec(),
..Default::default()
};
db.insert_task(&task1).await.unwrap();
let summary1 = common::models::ModelSummary {
name: name.clone(),
params: params.clone(),
stages: vec![common::models::StepSummary {
label: "nl".into(),
chmax: Some(0.001),
lte: "F".into(),
converged: true,
best_max_relc: Some(0.0005),
elapsed_sec: 300.0,
note: None,
last_iter: Some(17),
worst_depth: Some(1),
n_depths: Some(50),
itek_history: vec![],
conv_trace_check: None,
}],
result_valid: true,
final_max_relc: Some(0.0005),
final_chmax: Some(0.001),
seed: None,
atmosphere_has_nan: false,
synspec_rc: Some(0),
synspec_error: None,
synspec_sec: Some(0.3),
elapsed_sec: 300.3,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
let report1 = TaskReport {
task_id: task1.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "test-node".to_string(),
status: TaskStatus::Completed,
result_valid: true,
max_relc: Some(0.0005),
atmosphere_has_nan: false,
elapsed_sec: 300.3,
error_message: None,
summary_json: serde_json::to_string(&summary1).unwrap(),
failed_stage: None,
};
db.record_task_report(&report1, wf).await.unwrap();
assert_eq!(read_grid_attrs(&db, &name, wf).await.tlusty_success_method.as_deref(), Some("cold_run"));
// 翻回 pending 模拟场景 B 重跑。
db.reset_terminal_points_for_recompute(wf).await.unwrap();
// 第二轮:synspec-only,失败(synspec 产出脏谱)。
let task2 = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some(wf.to_string()),
wave: 0,
timeout_sec: 7200,
tlusty_config: PhaseConfig {
enabled: false,
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig::default_synspec(),
atmosphere_ref: Some(name.clone()),
..Default::default()
};
db.insert_task(&task2).await.unwrap();
// synspec 失败:result_valid=false, stages 为空(synspec-only),synspec_rc=1。
let summary2 = common::models::ModelSummary {
name: name.clone(),
params: params.clone(),
stages: Vec::new(),
result_valid: false,
final_max_relc: None,
final_chmax: None,
seed: None,
atmosphere_has_nan: false,
synspec_rc: Some(1),
synspec_error: Some("spec 含 NaN".into()),
synspec_sec: Some(0.2),
elapsed_sec: 0.2,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: Some("synspec 失败".into()),
};
let report2 = TaskReport {
task_id: task2.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "test-node".to_string(),
status: TaskStatus::Failed,
result_valid: false,
max_relc: None,
atmosphere_has_nan: false,
elapsed_sec: 0.2,
error_message: Some("synspec 失败".to_string()),
summary_json: serde_json::to_string(&summary2).unwrap(),
failed_stage: Some("synspec".to_string()),
};
db.record_task_report(&report2, wf).await.unwrap();
// 失败后 tlusty 侧归因仍须保留。
let row = read_grid_attrs(&db, &name, wf).await;
assert_eq!(row.status, "failed", "失败后状态应为 failed");
assert_eq!(
row.tlusty_success_method.as_deref(),
Some("cold_run"),
"synspec-only 失败后 tlusty_success_method 须保留"
);
assert_eq!(
row.tlusty_status.as_deref(),
Some("converged"),
"synspec-only 失败后 tlusty_status 须保留(CASE 守卫)"
);
// synspec 侧应反映失败。
assert_eq!(row.synspec_status.as_deref(), Some("failed"));
}
/// 辅助:读取 grid_points.last_elapsed_sec。
async fn read_grid_last_elapsed(db: &Database, name: &str, wf: &str) -> Option<f64> {
let pool = db.pool.clone();
let name = name.to_string();
let wf = wf.to_string();
tokio::task::spawn_blocking(move || -> Option<f64> {
let conn = pool.get().unwrap();
conn.query_row(
"SELECT last_elapsed_sec FROM grid_points WHERE name = ?1 AND workflow_name = ?2",
rusqlite::params![name, wf],
|r| r.get(0),
)
.ok()
})
.await
.unwrap()
}
/// TLUSTY-only 重跑成功后 synspec 归因列应被显式清空(clear_synspec=true),
/// summary_json 保留 prior synspec 字段(merge_point_summary TLUSTY-only 路径)。
///
/// 场景:先正常管线(TLUSTY+SYNSPEC)成功 → 再 TLUSTY-onlysynspec 关闭)重跑成功
/// → 新大气使旧光谱失效 → synspec_success_method/synspec_status 清 NULL
/// summary_json 中 synspec_rc/synspec_sec 保留自 prior。
#[tokio::test]
async fn test_tlusty_only_rerun_clears_synspec_attribution() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("tlonly.db");
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let wf = "wf_tlonly";
let params = GridPointParams {
teff: 25000.0.into(),
logg: 5.0.into(),
loghe: 2.0.into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let name = params.model_name();
db.upsert_grid_point(&params, 0, wf).await.unwrap();
// ── 第一轮:正常管线(TLUSTY + SYNSPEC 双开),成功 ──
let task1 = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some(wf.to_string()),
wave: 0,
timeout_sec: 7200,
tlusty_config: PhaseConfig {
enabled: true,
strategies: vec!["cold_run".to_string()],
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig::default_synspec(),
..Default::default()
};
db.insert_task(&task1).await.unwrap();
let summary1 = common::models::ModelSummary {
name: name.clone(),
params: params.clone(),
stages: vec![common::models::StepSummary {
label: "nl".into(),
chmax: Some(0.001),
lte: "F".into(),
converged: true,
best_max_relc: Some(0.0005),
elapsed_sec: 300.0,
note: None,
last_iter: Some(17),
worst_depth: Some(1),
n_depths: Some(50),
itek_history: vec![],
conv_trace_check: None,
}],
result_valid: true,
final_max_relc: Some(0.0005),
final_chmax: Some(0.001),
seed: None,
atmosphere_has_nan: false,
synspec_rc: Some(0),
synspec_error: None,
synspec_sec: Some(0.3),
elapsed_sec: 300.3,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
let report1 = TaskReport {
task_id: task1.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "test-node".to_string(),
status: TaskStatus::Completed,
result_valid: true,
max_relc: Some(0.0005),
atmosphere_has_nan: false,
elapsed_sec: 300.3,
error_message: None,
summary_json: serde_json::to_string(&summary1).unwrap(),
failed_stage: None,
};
db.record_task_report(&report1, wf).await.unwrap();
let row1 = read_grid_attrs(&db, &name, wf).await;
assert_eq!(row1.synspec_success_method.as_deref(), Some("standard"));
assert_eq!(row1.synspec_status.as_deref(), Some("converged"));
// ── 翻回 pending 模拟 TLUSTY-only 重跑 ──
db.reset_terminal_points_for_recompute(wf).await.unwrap();
// ── 第二轮:TLUSTY-onlysynspec 关闭),成功 ──
let task2 = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some(wf.to_string()),
wave: 0,
timeout_sec: 7200,
tlusty_config: PhaseConfig {
enabled: true,
strategies: vec!["cold_run".to_string()],
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig {
enabled: false,
..PhaseConfig::default_synspec()
},
..Default::default()
};
db.insert_task(&task2).await.unwrap();
// TLUSTY-only summarystages 非空(TLUSTY 跑了),synspec_rc=Nonesynspec 没跑)
let summary2 = common::models::ModelSummary {
name: name.clone(),
params: params.clone(),
stages: vec![common::models::StepSummary {
label: "nl".into(),
chmax: Some(0.0008),
lte: "F".into(),
converged: true,
best_max_relc: Some(0.0003),
elapsed_sec: 280.0,
note: None,
last_iter: Some(15),
worst_depth: Some(1),
n_depths: Some(50),
itek_history: vec![],
conv_trace_check: None,
}],
result_valid: true,
final_max_relc: Some(0.0003),
final_chmax: Some(0.0008),
seed: None,
atmosphere_has_nan: false,
synspec_rc: None, // synspec 未运行
synspec_error: None,
synspec_sec: None,
elapsed_sec: 280.0,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
let report2 = TaskReport {
task_id: task2.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "test-node".to_string(),
status: TaskStatus::Completed,
result_valid: true,
max_relc: Some(0.0003),
atmosphere_has_nan: false,
elapsed_sec: 280.0,
error_message: None,
summary_json: serde_json::to_string(&summary2).unwrap(),
failed_stage: None,
};
db.record_task_report(&report2, wf).await.unwrap();
// ── 核心断言:synspec 列应被显式清空(clear_synspec=true)──
let row2 = read_grid_attrs(&db, &name, wf).await;
assert_eq!(row2.status, "completed");
assert_eq!(
row2.tlusty_success_method.as_deref(),
Some("cold_run"),
"tlusty_success_method 应更新为 cold_run"
);
assert_eq!(
row2.tlusty_status.as_deref(),
Some("converged"),
"tlusty_status 应为 converged"
);
// ★ synspec 列被 clear_synspec 显式置 NULL(新大气使旧光谱失效)
assert_eq!(
row2.synspec_success_method,
None,
"TLUSTY-only 重跑后 synspec_success_method 须清 NULLclear_synspec"
);
assert_eq!(
row2.synspec_status,
None,
"TLUSTY-only 重跑后 synspec_status 须清 NULLclear_synspec"
);
// summary_jsonTLUSTY 诊断来自 incomingsynspec 字段保留自 prior
let merged = db.get_point_summary_json(wf, &name).await.unwrap().unwrap();
let ms: common::models::ModelSummary = serde_json::from_str(&merged).unwrap();
assert_eq!(ms.stages.len(), 1, "stages 来自 incoming");
assert_eq!(ms.final_max_relc, Some(0.0003), "final_max_relc 来自 incoming");
assert_eq!(
ms.synspec_rc,
Some(0),
"synspec_rc 保留 prior 值(merge_point_summary TLUSTY-only 路径)"
);
assert_eq!(ms.synspec_sec, Some(0.3), "synspec_sec 保留 prior 值");
}
/// `get_task_tlusty_enabled`:正常任务返回 truesynspec-only 任务返回 false
/// 不存在的 task_id 返回 None。
#[tokio::test]
async fn test_get_task_tlusty_enabled() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("tlusty_enabled.db");
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let params = GridPointParams {
teff: 20000.0.into(),
logg: 5.0.into(),
loghe: 2.0.into(),
logc: (-2.0).into(),
logn: (-4.0).into(),
logo: (-4.0).into(),
};
let name = params.model_name();
db.upsert_grid_point(&params, 0, "wf_tle").await.unwrap();
// 正常任务(tlusty_enabled=true
let task_normal = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some("wf_tle".to_string()),
tlusty_config: PhaseConfig::default_tlusty(),
synspec_config: PhaseConfig::default_synspec(),
..Default::default()
};
db.insert_task(&task_normal).await.unwrap();
let enabled = db.get_task_tlusty_enabled(&task_normal.task_id).await.unwrap();
assert_eq!(enabled, Some(true), "正常任务 tlusty_enabled 应为 true");
// synspec-only 任务(tlusty_enabled=false
let task_synonly = common::models::TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
workflow_name: Some("wf_tle".to_string()),
tlusty_config: PhaseConfig {
enabled: false,
..PhaseConfig::default_tlusty()
},
synspec_config: PhaseConfig::default_synspec(),
atmosphere_ref: Some(name.clone()),
..Default::default()
};
db.insert_task(&task_synonly).await.unwrap();
let enabled = db.get_task_tlusty_enabled(&task_synonly.task_id).await.unwrap();
assert_eq!(enabled, Some(false), "synspec-only 任务 tlusty_enabled 应为 false");
// 不存在的 task_id → None
let fake_id = Uuid::new_v4();
let enabled = db.get_task_tlusty_enabled(&fake_id).await.unwrap();
assert_eq!(enabled, None, "不存在的 task_id 应返回 None");
}
}
+93 -7
View File
@@ -254,6 +254,33 @@ impl Database {
Ok(())
}
/// 查询某 task 的 `tlusty_enabled` 标志。
///
/// 用于 `report_task` API 在写 `.7` 种子文件前判断本次任务是否为 synspec-only
/// `tlusty_enabled=false`)。synspec-only 任务未重算大气,节点上传的 `.7` 是从
/// 服务端下载的原样字节 → 服务端跳过写入,避免冗余 I/O 和潜在的误覆盖。
///
/// 返回 `None` 表示 task 行不存在(已被清理或 task_id 无效),调用方应 fallback 为
/// `true`(保守,不阻断种子写入主流程)。tasks 行在 queue 清理后仍保留(仅 task_queue
/// 被清),故在首次上报与幂等重放两条路径均有效。
pub async fn get_task_tlusty_enabled(&self, task_id: &uuid::Uuid) -> Result<Option<bool>> {
let pool = self.pool.clone();
let id = task_id.to_string();
tokio::task::spawn_blocking(move || -> Result<Option<bool>> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare("SELECT tlusty_enabled FROM tasks WHERE task_id = ?1")?;
let res = stmt.query_row(params![id], |r| r.get::<_, bool>(0));
match res {
Ok(enabled) => Ok(Some(enabled)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await?
}
/// 列出指定网格点在当前工作流内仍处于 `pending` 的 tasks 行 task_id。
///
/// tasks 行的 pending 意为"已创建但从未上报",可能是真在途(队列行仍 pending/
@@ -486,10 +513,13 @@ impl Database {
} else if converged {
// 半失败:大气收敛 + 光谱失败。tlusty 阶段确已收敛(tlusty_enabled 防御性守卫——
// 该分支在 synspec-only 下不可达,但避免未来代码路径把禁用阶段误标为 converged)。
(tlusty_enabled.then_some("converged"), Some("failed"))
// synspec_status gate on synspec_enabledsynspec 禁用时为 None → CASE 守卫保留 prior
// 不把禁用阶段误标为 "failed"TLUSTY-only 重跑失败不应清空既有 synspec 状态)。
(tlusty_enabled.then_some("converged"), synspec_enabled.then_some("failed"))
} else if tlusty_enabled {
// 大气未收敛(TLUSTY 启用)→ TLUSTY 失败、SYNSPEC 未运行。
(Some("failed"), Some("pending"))
// synspec_status gate on synspec_enabled:同上,禁用时保留 prior。
(Some("failed"), synspec_enabled.then_some("pending"))
} else {
// synspec-only 失败(converged 由 synspec_rc 决定)。
(None, Some("failed"))
@@ -506,11 +536,60 @@ impl Database {
// - 阶段列(5b):tlusty_status 用 CASE 守卫保留既有终态——半失败重试的 synspec-only
// 任务(tlusty 关闭)不覆盖已收敛的 tlusty_status(设计 §7.3 打开项 #2:仅 synspec
// 侧流转);synspec_status 自由流转。
// clear_synspecTLUSTY-only 重跑成功(tlusty_enabled && !synspec_enabled)时,
// 新大气使旧光谱失效 → 显式清空 synspec 归因列为 NULL(而非保留旧值)。
// 与 synspec-only 重跑(tlusty_enabled=false → 大气未变 → 保留 tlusty 列)对称:
// 另一阶段"跑了且产生了新产物"时,当前阶段的旧归因应作废。
let clear_synspec = tlusty_enabled && !synspec_enabled;
// summary_json 增量合并:synspec-only 重跑(tlusty_enabled=false)时只覆盖 synspec
// 字段,保留 grid_points 既有 summary 中的 TLUSTY 诊断(stages/final_max_relc/各 check)。
// 合并逻辑共享 common::summary_merge,与离线导入工具保持一致。
let merged_summary_json: Option<String> = {
let prior_json: Option<String> = tx
.query_row(
"SELECT summary_json FROM grid_points WHERE name = ?1 AND workflow_name = ?2",
params![point_name, wf],
|r| r.get::<_, Option<String>>(0),
)
.ok()
.flatten();
let prior = prior_json
.as_deref()
.and_then(|s| serde_json::from_str::<common::models::ModelSummary>(s).ok());
let incoming = serde_json::from_str::<common::models::ModelSummary>(
&report_cloned.summary_json,
)
.ok();
// incoming 解析失败(错误路径 {"error":...} 或空字符串)时跳过 summary 写入,
// 只更新原有列——与此前「server 只透传不解析 summary_json」的容错语义一致。
incoming.map(|inc| {
let merged = common::summary_merge::merge_point_summary(prior.as_ref(), &inc);
serde_json::to_string(&merged).unwrap_or_default()
})
};
let grid_changed = if pipe_ok {
tx.execute(
"UPDATE grid_points SET status = 'completed', last_elapsed_sec = ?1, tlusty_success_method = ?2, synspec_success_method = ?3,
// 阶段列 CASE 守卫(对称修复):
// - synspec-only 重跑(tlusty_enabled=0)→ tlusty 列为 NULL → CASE 守卫保留 prior。
// - TLUSTY-only 重跑(synspec_enabled=0, clear_synspec=true)→ synspec 列显式置 NULL
// (新大气使旧光谱失效),summary_merge 同步保留 prior synspec 字段。
// - 正常管线(双开)→ 全部 CASE 的 THEN 分支命中,写入新值。
// 此前 tlusty_success_method/synspec_success_method/synspec_status 为裸赋值,
// synspec-only 成功会把 tlusty_success_method 从 "cold_run" 覆写为 NULL
// 污染 stats 统计(workflows.rs:99 / grid.rs:579 按 tlusty_success_method 归因)。
//
// last_elapsed_sec 语义:最近一次尝试的墙钟耗时。synspec-only 重跑后该列反映
// synspec-only 耗时(~0.25s),TLUSTY 分项耗时保留在 summary_json.stages[].elapsed_sec。
// ETA 不依赖此列(用 AVG(tasks.elapsed_sec)),conv 面板用 summary_json.elapsed_sec。
"UPDATE grid_points SET status = 'completed', last_elapsed_sec = ?1,
tlusty_success_method = CASE WHEN ?2 IS NOT NULL THEN ?2 ELSE tlusty_success_method END,
synspec_success_method = CASE WHEN ?9 THEN NULL WHEN ?3 IS NOT NULL THEN ?3 ELSE synspec_success_method END,
tlusty_status = CASE WHEN ?6 IS NOT NULL THEN ?6 ELSE tlusty_status END,
synspec_status = ?7
synspec_status = CASE WHEN ?9 THEN NULL WHEN ?7 IS NOT NULL THEN ?7 ELSE synspec_status END,
summary_json = CASE WHEN ?8 IS NOT NULL THEN ?8 ELSE summary_json END
WHERE name = ?4 AND workflow_name = ?5 AND status != 'completed'",
params![
report_cloned.elapsed_sec,
@@ -519,16 +598,23 @@ impl Database {
point_name,
wf,
tlusty_status,
synspec_status
synspec_status,
merged_summary_json,
clear_synspec,
],
)?
} else {
tx.execute(
// synspec_status 同步加 CASE 守卫(对称修复):synspec_enabled=0 时
// synspec_status 为 None(上方 if-else 已 gate)→ 保留 prior。
// 与 success 分支、tlusty_status 守卫口径一致。
// last_elapsed_sec 语义见 success 分支注释。
"UPDATE grid_points SET status = 'failed', last_elapsed_sec = ?1,
tlusty_status = CASE WHEN ?4 IS NOT NULL THEN ?4 ELSE tlusty_status END,
synspec_status = ?5
synspec_status = CASE WHEN ?5 IS NOT NULL THEN ?5 ELSE synspec_status END,
summary_json = CASE WHEN ?6 IS NOT NULL THEN ?6 ELSE summary_json END
WHERE name = ?2 AND workflow_name = ?3 AND status NOT IN ('completed', 'failed')",
params![report_cloned.elapsed_sec, point_name, wf, tlusty_status, synspec_status],
params![report_cloned.elapsed_sec, point_name, wf, tlusty_status, synspec_status, merged_summary_json],
)?
};
+3 -2
View File
@@ -325,8 +325,6 @@ async fn main() -> Result<()> {
let report_router = Router::new()
.route("/task/report", post(api::task::report_task))
// 历史种子导入同样上传 .7 大气文件,并入宽松 body limit / 并发限流组。
.route("/admin/import_seed", post(api::task::import_seed))
.layer(DefaultBodyLimit::max(REPORT_BODY_LIMIT))
.layer(tower::ServiceBuilder::new().concurrency_limit(REPORT_MAX_CONCURRENCY));
@@ -426,6 +424,9 @@ async fn main() -> Result<()> {
"/admin/nodes/:node_id/quota",
post(api::admin::set_node_quota),
)
// 临时迁移端点:扫 seeds_dir 的 conv.json → 写 grid_points.summary_json。
// 迁移完成后删除本路由 + api/migrate.rs 即可。
.route("/admin/migrate_conv", post(api::migrate::migrate_conv))
.layer(DefaultBodyLimit::max(DEFAULT_BODY_LIMIT));
// 合并两个子 router:各自携带自己的 body limit,互不覆盖。
+129 -1
View File
@@ -261,7 +261,7 @@ impl GridScheduler {
/// 读取指定工作流的 TLUSTY 物理迭代步进链(`config::GridConfig.tlusty_chain`),
/// 序列化为 JSON Value 供 TaskSpec 携带。节点 executor 反序列化为 `Vec<ChainStep>`
/// 后透传给 runner 的 custom_chain 参数,使用户在 YAML 配置的 niter/chmax/metals
/// 后透传给 runner 的 custom_chain 参数,使用户在 YAML 配置的 niter/chmax
/// 等阶段参数真正生效(此前 executor 硬编码用 default 链,忽略用户配置)。
/// 工作流未配置 tlusty_chain(空数组)→ Noneexecutor 用 default 链兜底)。
async fn get_workflow_tlusty_chain(&self, workflow_name: &str) -> Option<serde_json::Value> {
@@ -273,6 +273,19 @@ impl GridScheduler {
serde_json::to_value(&cfg.tlusty_chain).ok()
}
/// 读取指定工作流的种子热启动链(`config::GridConfig.seed_chain`),序列化为 JSON
/// Value 供 TaskSpec 携带。仅 seed_step 策略下由 executor 读取。
/// 与 `get_workflow_tlusty_chain` 对称。工作流未配置 seed_chain(空数组)→ None
///executor 用 `default_seed_chain()` 兜底)。
async fn get_workflow_seed_chain(&self, workflow_name: &str) -> Option<serde_json::Value> {
let wf = self.db.get_workflow(workflow_name).await.ok()??;
let cfg = parse_grid_config_or_warn(&wf.config_yaml, workflow_name, "seed_chain")?;
if cfg.seed_chain.is_empty() {
return None;
}
serde_json::to_value(&cfg.seed_chain).ok()
}
/// 读取指定工作流的 TLUSTY 输入文件全局参数(`config::GridConfig.tlusty_input`),
/// 序列化为 JSON Value 供 TaskSpec 携带。包含 NFREAD 频率网格、ions 能级表、
/// nst extra_keys 等不随阶段变化的参数。节点 executor 反序列化为 `TlustyInput`
@@ -286,6 +299,38 @@ impl GridScheduler {
.and_then(|t| serde_json::to_value(t).ok())
}
/// 一次性读取工作流的全部物理校验阈值(能量守恒 / 温度结构 / emflux)。
/// 统一读取避免对同一 YAML 多次解析。返回 8 元组,对应 TaskSpec 的 8 个标量字段:
/// (energy_tolerance, temp_max_factor, temp_floor, temp_ceiling, emflux_tolerance,
/// convergence_min_ratio, bfac_max, bfac_min)。
async fn get_workflow_validation_thresholds(
&self,
workflow_name: &str,
) -> Option<(
Option<f64>,
Option<f64>,
Option<f64>,
Option<f64>,
Option<f64>,
Option<f64>,
Option<f64>,
Option<f64>,
)> {
let wf = self.db.get_workflow(workflow_name).await.ok()??;
let cfg = parse_grid_config_or_warn(&wf.config_yaml, workflow_name, "validation_thresholds")?;
Some((
cfg.energy_tolerance,
cfg.temp_max_factor,
cfg.temp_floor,
cfg.temp_ceiling,
cfg.emflux_tolerance,
cfg.convergence_min_ratio,
cfg.bfac_max,
cfg.bfac_min,
))
}
/// 从策略链解析出「首个可派发」的顺位(见 docs/task_engine_decoupling_design.md §4.2)。
///
/// 判定:
@@ -404,7 +449,21 @@ impl GridScheduler {
let (tlusty_cfg, synspec_cfg) = self.get_workflow_stage_configs(workflow_name).await;
let synspec_params = self.get_workflow_synspec_params(workflow_name).await;
let tlusty_chain = self.get_workflow_tlusty_chain(workflow_name).await;
let seed_chain = self.get_workflow_seed_chain(workflow_name).await;
let tlusty_input = self.get_workflow_tlusty_input(workflow_name).await;
let (
energy_tolerance,
temp_max_factor,
temp_floor,
temp_ceiling,
emflux_tolerance,
convergence_min_ratio,
bfac_max,
bfac_min,
) = self
.get_workflow_validation_thresholds(workflow_name)
.await
.unwrap_or((None, None, None, None, None, None, None, None));
// 双阶段全关是退化配置(save_workflow 已拦截,此处兜底防御):无可执行阶段,
// 整工作流跳过派发(修复审查 #5)。
@@ -538,6 +597,7 @@ impl GridScheduler {
synspec_config: synspec_cfg.clone(),
synspec_params: synspec_params.clone(),
tlusty_chain_params: tlusty_chain.clone(),
seed_chain_params: seed_chain.clone(),
tlusty_input_params: tlusty_input.clone(),
// 显式绑定大气来源(设计 §5.2,修复审查 #3):仅 SYNSPEC-onlyTLUSTY 关闭)
// 场景需要外部大气——节点凭 atmosphere_ref(或 point_name 兜底)从本地归档
@@ -547,6 +607,14 @@ impl GridScheduler {
} else {
Some(name.clone())
},
energy_tolerance,
temp_max_factor,
temp_floor,
temp_ceiling,
emflux_tolerance,
convergence_min_ratio,
bfac_max,
bfac_min,
};
self.db.insert_task(&task_spec).await?;
@@ -834,7 +902,21 @@ impl GridScheduler {
let timeout_sec = self.get_workflow_timeout_sec(workflow_name).await;
let synspec_params = self.get_workflow_synspec_params(workflow_name).await;
let tlusty_chain = self.get_workflow_tlusty_chain(workflow_name).await;
let seed_chain = self.get_workflow_seed_chain(workflow_name).await;
let tlusty_input = self.get_workflow_tlusty_input(workflow_name).await;
let (
energy_tolerance,
temp_max_factor,
temp_floor,
temp_ceiling,
emflux_tolerance,
convergence_min_ratio,
bfac_max,
bfac_min,
) = self
.get_workflow_validation_thresholds(workflow_name)
.await
.unwrap_or((None, None, None, None, None, None, None, None));
// SYNSPEC 链回退:重试光谱合成。无邻居种子门控(大气来自目标点自身既有产物,
// 见 docs/task_engine_decoupling_design.md §5)——旧实现把 synspec 失败误归因到
@@ -874,8 +956,18 @@ impl GridScheduler {
synspec_params,
// TLUSTY 已关闭(半失败重试只重跑光谱),不执行 chain/input → None。
tlusty_chain_params: None,
seed_chain_params: None,
tlusty_input_params: None,
atmosphere_ref: Some(name.to_string()),
// 不重算大气 → 不做物理正确性校验。
energy_tolerance: None,
temp_max_factor: None,
temp_floor: None,
temp_ceiling: None,
emflux_tolerance: None,
convergence_min_ratio: None,
bfac_max: None,
bfac_min: None,
};
self.db.insert_task(&task_spec).await?;
self.db
@@ -959,8 +1051,17 @@ impl GridScheduler {
synspec_config: synspec_cfg.clone(),
synspec_params,
tlusty_chain_params: tlusty_chain.clone(),
seed_chain_params: seed_chain.clone(),
tlusty_input_params: tlusty_input.clone(),
atmosphere_ref: None,
energy_tolerance,
temp_max_factor,
temp_floor,
temp_ceiling,
emflux_tolerance,
convergence_min_ratio,
bfac_max,
bfac_min,
};
self.db.insert_task(&task_spec).await?;
@@ -1018,6 +1119,7 @@ mod tests {
logo: vec![(-2.0).into()],
},
tlusty_chain: vec![],
seed_chain: vec![],
tlusty_input: None,
synspec_input: None,
nworkers: 4,
@@ -1030,6 +1132,14 @@ mod tests {
linelist: None,
tlusty_stage: None,
synspec_stage: None,
energy_tolerance: None,
temp_max_factor: None,
temp_floor: None,
temp_ceiling: None,
emflux_tolerance: None,
convergence_min_ratio: None,
bfac_max: None,
bfac_min: None,
};
scheduler.initialize_grid(&cfg, "test_wf").await.unwrap();
@@ -1075,6 +1185,7 @@ mod tests {
logo: vec![(-2.0).into()],
},
tlusty_chain: vec![],
seed_chain: vec![],
tlusty_input: None,
synspec_input: None,
nworkers: 4,
@@ -1087,6 +1198,14 @@ mod tests {
linelist: None,
tlusty_stage: None,
synspec_stage: None,
energy_tolerance: None,
temp_max_factor: None,
temp_floor: None,
temp_ceiling: None,
emflux_tolerance: None,
convergence_min_ratio: None,
bfac_max: None,
bfac_min: None,
};
// wf_a 初始化并推入队列
@@ -1195,6 +1314,7 @@ mod tests {
logo: vec![(-2.0).into()],
},
tlusty_chain: vec![],
seed_chain: vec![],
tlusty_input: None,
synspec_input: None,
nworkers: 4,
@@ -1207,6 +1327,14 @@ mod tests {
linelist: None,
tlusty_stage: None,
synspec_stage: None,
energy_tolerance: None,
temp_max_factor: None,
temp_floor: None,
temp_ceiling: None,
emflux_tolerance: None,
convergence_min_ratio: None,
bfac_max: None,
bfac_min: None,
};
cfg
}
+66 -357
View File
@@ -2,11 +2,39 @@ use axum::{
body::Body,
http::{Request, StatusCode},
};
use common::models::{GridPointParams, ModelSummary};
use mq::sqlite_queue::SqliteTaskQueue;
use server::{api::AppState, db::Database, scheduler::GridScheduler};
use std::sync::Arc;
use tower::ServiceExt; // for oneshot
/// 测试辅助:把一个已 upsert 的点标记为导入收敛(写 summary_json + status=completed)。
/// 等价旧 mark_grid_point_imported。
async fn mark_imported(db: &Database, name: &str, wf: &str, params: &GridPointParams, method: &str) {
let summary = ModelSummary {
name: name.to_string(),
params: params.clone(),
stages: Vec::new(),
result_valid: true,
final_max_relc: Some(0.001),
final_chmax: Some(0.001),
seed: None,
atmosphere_has_nan: false,
synspec_rc: None,
synspec_error: None,
synspec_sec: None,
elapsed_sec: 0.0,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
db.upsert_point_summary(name, wf, &summary, method)
.await
.unwrap();
}
#[tokio::test]
async fn test_server_api_flow() {
let temp_dir = tempfile::tempdir().unwrap();
@@ -1256,343 +1284,6 @@ async fn test_cors_same_origin_and_local_policy() {
assert!(res.headers().get("access-control-allow-origin").is_none());
}
/// 构造一个合法的旧版 conv.json (ModelSummary) 文本,用于 import_seed 测试。
/// 关键:name 用源精度真名(`g5.0`),验证服务端逐字符保真落库。
fn make_legacy_conv_json(name: &str, converged: bool) -> String {
let summary = serde_json::json!({
"name": name,
"params": {"teff": 20000.0, "logg": 5.0, "loghe": -2.0, "logc": -4.0, "logn": -4.0, "logo": -4.0},
"stages": [],
"converged": converged,
"final_max_relc": 0.000321,
"final_chmax": null,
"seed": null,
"atmosphere_has_nan": false,
"synspec_rc": 0,
"synspec_error": null,
"synspec_sec": 1.5,
"elapsed_sec": 42.0,
"note": null,
});
serde_json::to_string(&summary).unwrap()
}
/// 构造**旧版 Python run_one.py 形态**的 conv.jsonstage 含 itek_attempts/final 嵌套,
/// 顶层含 elapsed_sec/synspec_*)——验证 import_seed 对真实历史数据的解析兼容性。
fn make_python_legacy_conv_json(name: &str) -> String {
serde_json::json!({
"name": name,
"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}],
"converged": true,
"final": {"itek": null, "rc": 0, "converged": true, "max_relc": 0.0},
"best_max_relc": 0.0, "elapsed_sec": 2.1},
{"label": "nl", "chmax": null, "lte": "F",
"itek_attempts": [{"itek": null, "rc": 0, "converged": true, "max_relc": 0.000321,
"worst_depth": 1, "last_iter": 17, "n_depths": 50}],
"converged": true,
"final": {"itek": null, "rc": 0, "converged": true, "max_relc": 0.000321,
"worst_depth": 1, "last_iter": 17, "n_depths": 50},
"best_max_relc": 0.000321, "elapsed_sec": 640.0}
],
"converged": true,
"final_max_relc": 0.000321,
"final_chmax": null,
"seed": null,
"atmosphere_has_nan": false,
"synspec_rc": 0,
"synspec_sec": 3.1,
"elapsed_sec": 715.0,
})
.to_string()
}
/// 构造一个 multipart/form-data body,含 report(JSON 文本) + seed_file(二进制)。
fn make_import_multipart(
boundary: &str,
report_json: &str,
seed_bytes: &[u8],
seed_name: &str,
success_method: &str,
) -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
body.extend_from_slice(b"Content-Disposition: form-data; name=\"report\"\r\n");
body.extend_from_slice(b"Content-Type: application/json\r\n\r\n");
body.extend_from_slice(report_json.as_bytes());
body.extend_from_slice(b"\r\n");
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
body.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"seed_file\"; filename=\"{}\"\r\n",
seed_name
)
.as_bytes(),
);
body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
body.extend_from_slice(seed_bytes);
body.extend_from_slice(b"\r\n");
// 大气收敛途径字段(cold_run/seed_step):模拟 import_results 工具判定后透传的途径。
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
body.extend_from_slice(b"Content-Disposition: form-data; name=\"tlusty_success_method\"\r\n");
body.extend_from_slice(b"Content-Type: text/plain\r\n\r\n");
body.extend_from_slice(success_method.as_bytes());
body.extend_from_slice(b"\r\n");
body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
body
}
#[tokio::test]
async fn test_import_seed_admin_endpoint() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("import_db.db");
let queue_db_path = temp_dir.path().join("import_queue.db");
let seeds_dir = temp_dir.path().join("results");
std::fs::create_dir_all(&seeds_dir).unwrap();
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let queue = Arc::new(
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
.await
.unwrap(),
);
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
let state = AppState {
db: db.clone(),
queue,
scheduler,
seeds_dir: seeds_dir.to_string_lossy().to_string(),
rate_limiter: server::api::rate_limit::RateLimiter::new(
5,
std::time::Duration::from_secs(300),
),
admin_token: Some("admin-secret".to_string()),
auth_disabled: false,
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
};
let api_router = axum::Router::new().route(
"/admin/import_seed",
axum::routing::post(server::api::task::import_seed),
);
let auth_layer =
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
let app = axum::Router::new()
.nest("/api", api_router.layer(auth_layer))
.with_state(state);
// 1. 无 admin token → 401
let conv = make_legacy_conv_json("t20000_g5.0_he-2_c-4_n-4_o-4", true);
let body_bytes = make_import_multipart(
"boundary1",
&conv,
b"FAKE_ATMOS_7",
"t20000_g5.0_he-2_c-4_n-4_o-4.7",
"cold_run",
);
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/import_seed?workflow=wf_import")
.header("content-type", "multipart/form-data; boundary=boundary1")
.body(Body::from(body_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
// 2. admin token + 收敛点 → 200conv.json + .7 落地,grid_points=converged
let body_bytes = make_import_multipart(
"boundary2",
&conv,
b"FAKE_ATMOS_7",
"t20000_g5.0_he-2_c-4_n-4_o-4.7",
"cold_run",
);
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/import_seed?workflow=wf_import")
.header("authorization", "Bearer admin-secret")
.header("content-type", "multipart/form-data; boundary=boundary2")
.body(Body::from(body_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
// 关键断言:磁盘目录名用旧 conv.json 的源精度真名(g5.0),conv.json 与 .7 均落地。
let point_dir = seeds_dir.join("t20000_g5.0_he-2_c-4_n-4_o-4");
assert!(point_dir.join("conv.json").is_file(), "conv.json 应落地");
assert!(
point_dir.join("t20000_g5.0_he-2_c-4_n-4_o-4.7").is_file(),
".7 种子文件应落地"
);
// grid_points 应被幂等 upsert 且标记为 converged。
let gp = db
.get_grid_point_status("t20000_g5.0_he-2_c-4_n-4_o-4", "wf_import")
.await
.unwrap()
.expect("grid_points 应存在");
assert_eq!(gp.0, "completed", "导入的收敛点应为 converged 状态");
// 3. 幂等:重复导入同名点不应报错,状态仍 converged。
let body_bytes = make_import_multipart(
"boundary3",
&conv,
b"FAKE_ATMOS_7_AGAIN",
"t20000_g5.0_he-2_c-4_n-4_o-4.7",
"cold_run",
);
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/import_seed?workflow=wf_import")
.header("authorization", "Bearer admin-secret")
.header("content-type", "multipart/form-data; boundary=boundary3")
.body(Body::from(body_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let gp = db
.get_grid_point_status("t20000_g5.0_he-2_c-4_n-4_o-4", "wf_import")
.await
.unwrap()
.unwrap();
assert_eq!(gp.0, "completed");
// 4. 未收敛点 → 200,但不写 .7、grid_points 维持 pending(未建 converged)。
let conv_fail = make_legacy_conv_json("t20000_g5.0_he-2_c-4_n-4_o-4_fail", false);
let body_bytes =
make_import_multipart("boundary4", &conv_fail, b"WONT_BE_USED", "x.7", "cold_run");
let res = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/import_seed?workflow=wf_import")
.header("authorization", "Bearer admin-secret")
.header("content-type", "multipart/form-data; boundary=boundary4")
.body(Body::from(body_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let fail_dir = seeds_dir.join("t20000_g5.0_he-2_c-4_n-4_o-4_fail");
assert!(
fail_dir.join("conv.json").is_file(),
"未收敛点仍应记录 conv.json"
);
assert!(
!fail_dir
.join("t20000_g5.0_he-2_c-4_n-4_o-4_fail.7")
.exists(),
"未收敛点不应写 .7 种子"
);
}
/// 旧版 Python run_one.py conv.json 的完整导入链路(import_results 工具的服务端侧):
/// 嵌套 stagesitek_attempts/final)应被正常解析,收敛标记落地,且旧版的
/// elapsed_sec 经 P3 迁移链路进入 grid_points.last_elapsed_sec(迁移完整性)。
#[tokio::test]
async fn test_import_seed_python_legacy_conv_json() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("import_legacy_db.db");
let queue_db_path = temp_dir.path().join("import_legacy_queue.db");
let seeds_dir = temp_dir.path().join("results");
std::fs::create_dir_all(&seeds_dir).unwrap();
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let queue = Arc::new(
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
.await
.unwrap(),
);
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
let state = AppState {
db: db.clone(),
queue,
scheduler,
seeds_dir: seeds_dir.to_string_lossy().to_string(),
rate_limiter: server::api::rate_limit::RateLimiter::new(
5,
std::time::Duration::from_secs(300),
),
admin_token: Some("admin-secret".to_string()),
auth_disabled: false,
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
};
let api_router = axum::Router::new().route(
"/admin/import_seed",
axum::routing::post(server::api::task::import_seed),
);
let auth_layer =
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
let app = axum::Router::new()
.nest("/api", api_router.layer(auth_layer))
.with_state(state);
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
let conv = make_python_legacy_conv_json(name);
let body_bytes = make_import_multipart(
"boundaryL",
&conv,
b"FAKE_ATMOS_7",
&format!("{name}.7"),
"cold_run",
);
let res = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/import_seed?workflow=wf_legacy")
.header("authorization", "Bearer admin-secret")
.header("content-type", "multipart/form-data; boundary=boundaryL")
.body(Body::from(body_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::OK,
"旧版嵌套 stages 的 conv.json 应被接受"
);
// grid_pointsconverged + cold_run 手段(旧版 conv.json 无 seed_nc 阶段)+ 旧版 elapsed_sec 已落库
let row = db
.get_workflow_point_row("wf_legacy", name)
.await
.unwrap()
.expect("grid_points 应存在");
assert_eq!(row.status, "completed");
assert_eq!(row.tlusty_success_method.as_deref(), Some("cold_run"));
assert_eq!(
row.last_elapsed_sec,
Some(715.0),
"旧版 conv.json 的单点耗时应完整迁移到 last_elapsed_sec"
);
// conv.json 原文落盘(嵌套诊断数据 itek_attempts/final 不丢失,详情页解析走兼容路径)
assert!(seeds_dir.join(name).join("conv.json").is_file());
assert!(seeds_dir.join(name).join(format!("{name}.7")).is_file());
}
#[tokio::test]
async fn test_node_disable_enable_flow() {
@@ -2145,7 +1836,7 @@ async fn dispatch_and_report(
task_id,
point_name: p.model_name(),
params: p.clone(),
seed_point_name: seed,
seed_point_name: seed.clone(),
timeout_sec: 7200,
workflow_name: Some(wf.to_string()),
wave: 0,
@@ -2157,6 +1848,26 @@ async fn dispatch_and_report(
..Default::default()
};
db.insert_task(&spec).await.unwrap();
// 构造合法 ModelSummary 作为 summary_json(让 record_task_report 能解析合并写入 grid_points)。
let summary = common::models::ModelSummary {
name: p.model_name(),
params: p.clone(),
stages: Vec::new(),
result_valid: converged,
final_max_relc: if converged { Some(0.0005) } else { Some(9.5e5) },
final_chmax: Some(0.001),
seed,
atmosphere_has_nan: false,
synspec_rc: None,
synspec_error: None,
synspec_sec: None,
elapsed_sec: 120.0,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
};
let report = common::models::TaskReport {
task_id,
point_name: p.model_name(),
@@ -2176,7 +1887,7 @@ async fn dispatch_and_report(
} else {
Some("nl stage diverged".to_string())
},
summary_json: "{}".to_string(),
summary_json: serde_json::to_string(&summary).unwrap(),
failed_stage: None,
};
db.record_task_report(&report, wf).await.unwrap();
@@ -2292,9 +2003,7 @@ async fn test_wf_stats_endpoint() {
)
.await;
dispatch_and_report(&db, "wf_stats", &p_failed, "cold_run", None, false).await;
db.mark_grid_point_imported(&p_imported.model_name(), "wf_stats", None, "cold_run")
.await
.unwrap();
mark_imported(&db, &p_imported.model_name(), "wf_stats", &p_imported, "cold_run").await;
// 回拨任务创建时间:测试内 insert/report 同秒完成,墙钟差为 0 会被 ETA 估算
// 过滤(avg 必须 > 0);造 120s 的真实感样本,使 avg_point_sec/eta_sec 非空。
@@ -2456,9 +2165,7 @@ async fn seed_obs_fixture(db: &Database, db_path: &std::path::Path, wf: &str) ->
true,
)
.await;
db.mark_grid_point_imported(&p_imported.model_name(), wf, None, "cold_run")
.await
.unwrap();
mark_imported(&db, &p_imported.model_name(), wf, &p_imported, "cold_run").await;
let conn = rusqlite::Connection::open(db_path).unwrap();
conn.execute(
@@ -2709,14 +2416,9 @@ async fn test_point_detail_endpoint() {
let n = seed_obs_fixture(&db, &db_path, "wf_pd").await;
// 给 cold 点写一份合法 conv.jsonrescued 刻意不写,验证 null 降级)
let cold_dir = seeds_dir.join(&n.cold);
std::fs::create_dir_all(&cold_dir).unwrap();
std::fs::write(
cold_dir.join("conv.json"),
make_legacy_conv_json(&n.cold, true),
)
.unwrap();
// conv 诊断面板数据源已改为 grid_points.summary_jsondispatch_and_report 写入),
// 不再需要磁盘 conv.json。rescued 点最终 seed_step 成功 → 也有 summary_json
// 但此处验证 null 降级用 pending 点(未 report,无 summary)。
async fn get_detail(app: &axum::Router, uri: &str) -> (StatusCode, serde_json::Value) {
let res = app
@@ -2757,10 +2459,11 @@ async fn test_point_detail_endpoint() {
assert_eq!(attempts.len(), 1);
assert!(attempts[0]["seed_point_name"].is_null(), "冷启动无种子来源");
assert_eq!(attempts[0]["status"], "completed");
assert_eq!(data["conv"]["result_valid"], true, "conv.json 应被解析");
assert_eq!(data["conv"]["final_max_relc"], 0.000321);
assert_eq!(data["conv"]["result_valid"], true, "summary_json 应被解析");
assert_eq!(data["conv"]["final_max_relc"], 0.0005);
// ---- 3. rescued 点:2 次尝试按时间升序(冷启失败 → 种子步进救回)conv 为 null ----
// ---- 3. rescued 点:2 次尝试按时间升序(冷启失败 → 种子步进救回)----
// 最终 seed_step 成功 → record_task_report 写入 summary_jsonconv 非 null。
let uri = format!("/api/workflows/wf_pd/points/{}", n.rescued);
let (st, data) = get_detail(&app, &uri).await;
assert_eq!(st, StatusCode::OK);
@@ -2775,7 +2478,13 @@ async fn test_point_detail_endpoint() {
attempts[1]["seed_point_name"], n.cold,
"种子来源应为 cold 点"
);
assert!(data["conv"].is_null(), "无 conv.json 应返回 null 而非报错");
assert_eq!(data["conv"]["result_valid"], true, "rescued 最终成功应有 summary");
// ---- 3b. pending 点:从未 report → 无 summary_json → conv 为 null(降级不报错)----
let uri = format!("/api/workflows/wf_pd/points/{}", n.pending);
let (st, data) = get_detail(&app, &uri).await;
assert_eq!(st, StatusCode::OK);
assert!(data["conv"].is_null(), "未结算点无 summary 应返回 null");
// ---- 4. 路径穿越 / 非法字符 → 400 ----
for bad in ["..%2Fevil", "a%2Fb", ".hidden", ".."] {
+51 -30
View File
@@ -1,10 +1,10 @@
//! 验证「历史种子导入的工作流名」与「正式工作流名」的隔离关系。
//!
//! 用户意图:import_results 把旧 Python 计算结果导入,标记为已完成,避免重算。
//! 用户意图:离线导入工具把旧计算结果导入,标记为已完成,避免重算。
//! 关键问题:导入到工作流 A,之后正式启动工作流 B(同名/异名),B 能否看到 A 标记的 converged
use common::config::GridConfig;
use common::models::GridPointParams;
use common::models::{GridPointParams, ModelSummary};
use mq::sqlite_queue::SqliteTaskQueue;
use server::{db::Database, scheduler::GridScheduler};
use std::sync::Arc;
@@ -21,6 +21,47 @@ fn make_params() -> GridPointParams {
}
}
/// 构造一个收敛的 ModelSummaryresult_valid=true, atmosphere_has_nan=false),
/// 用 point_name 作权威名。供测试模拟离线导入写入 summary_json。
fn make_converged_summary(name: &str, params: &GridPointParams) -> ModelSummary {
ModelSummary {
name: name.to_string(),
params: params.clone(),
stages: Vec::new(),
result_valid: true,
final_max_relc: Some(0.001),
final_chmax: Some(0.001),
seed: None,
atmosphere_has_nan: false,
synspec_rc: None,
synspec_error: None,
synspec_sec: None,
elapsed_sec: 0.0,
energy_check: None,
temp_check: None,
emflux_check: None,
bfac_check: None,
note: None,
}
}
/// 测试辅助:模拟离线导入——upsert 点 + 写收敛 summary(等价旧 mark_grid_point_imported)。
async fn mark_imported(
db: &Database,
name: &str,
workflow: &str,
params: &GridPointParams,
method: &str,
) {
db.upsert_grid_point_named(name, params, 0, workflow)
.await
.unwrap();
let summary = make_converged_summary(name, params);
db.upsert_point_summary(name, workflow, &summary, method)
.await
.unwrap();
}
/// 构造只含一个网格点(t20000_g5.0_he-2_c-4_n-4_o-4)的 config。
fn make_grid_cfg() -> GridConfig {
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
@@ -49,13 +90,8 @@ async fn test_same_workflow_name_preserves_converged() {
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
let p = make_params();
// 模拟 import_seedupsert + mark_imported,工作流名 = sdB_cno
db.upsert_grid_point_named(name, &p, 0, "sdB_cno")
.await
.unwrap();
db.mark_grid_point_imported(name, "sdB_cno", None, "cold_run")
.await
.unwrap();
// 模拟离线导入:upsert + 写收敛 summary,工作流名 = sdB_cno
mark_imported(&db, name, "sdB_cno", &p, "cold_run").await;
// 之后正式启动同名工作流:initialize_grid(sdB_cno)
sched
@@ -80,13 +116,8 @@ async fn test_different_workflow_name_causes_recompute() {
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
let p = make_params();
// 模拟 import_seed:导入到 "imported" 工作流
db.upsert_grid_point_named(name, &p, 0, "imported")
.await
.unwrap();
db.mark_grid_point_imported(name, "imported", None, "cold_run")
.await
.unwrap();
// 模拟离线导入:导入到 "imported" 工作流
mark_imported(&db, name, "imported", &p, "cold_run").await;
// 之后正式启动 "sdB_cno" 工作流
sched
@@ -118,13 +149,8 @@ async fn test_mixed_grid_import_then_init_avoids_recompute() {
let (db, sched) = setup().await;
let p_old = make_params(); // t20000_g5.0_...
// 模拟 import_seed:旧网格里这个点已收敛,导入到 sdB_cno
db.upsert_grid_point_named("t20000_g5.0_he-2_c-4_n-4_o-4", &p_old, 0, "sdB_cno")
.await
.unwrap();
db.mark_grid_point_imported("t20000_g5.0_he-2_c-4_n-4_o-4", "sdB_cno", None, "cold_run")
.await
.unwrap();
// 模拟离线导入:旧网格里这个点已收敛,导入到 sdB_cno
mark_imported(&db, "t20000_g5.0_he-2_c-4_n-4_o-4", "sdB_cno", &p_old, "cold_run").await;
// 正式启动 sdB_cno,config 比旧网格多了一个新点(t25000)
let yaml = "grid:\n teff: [20000, 25000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
@@ -172,13 +198,8 @@ async fn test_precision_diff_import_then_init_preserves_converged() {
logo: GridAxisValue::from_value(-4.0),
};
// 模拟 import_results 重写 name 后入库:grid_points.name = canonical(g5.0)
db.upsert_grid_point_named(canonical, &p, 0, "sdB_cno")
.await
.unwrap();
db.mark_grid_point_imported(canonical, "sdB_cno", None, "cold_run")
.await
.unwrap();
// 模拟离线导入重写 name 后入库:grid_points.name = canonical(g5.0)
mark_imported(&db, canonical, "sdB_cno", &p, "cold_run").await;
// 启动同名工作流:initialize_grid 用配置 model_name()(=g5.0) 插入
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";