feat(all): seed_step_stab 稳定化种子链与同族种子轮换、ladder/自适应 Teff/丰度轴延拓三级回退与梯级种子入库复用、热启动首末比豁免、workflow 完成翻转回退链阻塞修复、大气收敛权威统计与 M14 回填

收敛攻坚(81/400 失败点根因与实测,见 docs/failed81_cno_seed_popzer_dpsilg_2026_08_18.md):
- runner: 新增 seed_step_stab 策略链——同物理族(同 Teff/logg/logHe)CNO 邻居种子
  + DPSILG=3.0 λ 算子欠松弛 + POPZER=1E-10 微布居置零(联动 POPZR2/RADZER 同值
  + NITZER=1),针对 20kK He 富大气 He I/II 电离前沿布居极限环
- runner: 三级链内延拓回退(主链与 nl_direct 全败后自动触发):
  · 固定 ladder 步进——plan_ladder_steps 按归一化间隔选轴,Δlogg≤0.25/ΔTeff≤2.5kK,≤4 步
  · 自适应 Teff 延拓——步长 1250K 起、成功 ×1.5 恢复、失败二分至 25K 折叠墙,≤48 步
  · C/N 丰度轴延拓——高温域(Teff>30kK)专用,严格同族种子沿 C(优先)/N 轴 0.2 dex 起步
  延拓阶段 NITER 下限提至 300(慢收敛 waypoint 迭代饥饿误判修复)
- runner: 稳定化多档回退(DPSILG/POPZER 三档互补,联合回收 41/65);
  域门控 Teff≤30kK——高温高金属域实测旋钮致散(17 拍爆至 4e16),域外跳过
- 梯级种子持久化: 收敛中间模型登记 ladder_seeds 随上报落 server seeds 表
  (任务失败也上传,合成名 _ladder 与真实网格点零冲突),簇内相邻失败点自动复用

调度与执行:
- scheduler: 策略解析新增 seed_step_stab 臂——find_exact_family_seed_from_db 严格
  同物理族判定(不做 global 退化,防 ladder 中间种子 ΔTeff≤5000K 误命中),
  排除本点历史已用种子实现重试轮换;链在 stab 耗尽时轮换未试过同族邻居重派
- executor: seed_step_stab 补种子下载(漏列曾致 78 任务假失败,seed_nc 无 fort.8 崩溃);
  Teff>30kK 域外自动降级普通种子链

收敛判据:
- conv_check: 热启动豁免——首拍 max_relc<1(种子已近解)时首末比 1e3 判据数学上
  不可达,豁免后交五重物理硬门槛裁决(修复 nl_ladder 0.038→6e-4 物理全过被误杀);
  冷启动仍受判据门控

workflow 生命周期:
- workflows/tasks: 完成 flip 增加「未消费回退链」阻塞子句——failed 点策略链未耗尽
  或链尾 seed_step_stab 尚有未试过同族种子时不得置 completed(修复最后活跃点 cold
  失败上报抢先 flip、still_running 守卫拦截后续策略永不派发);按 failed_stage 归因
  (synspec 失败行只看 synspec 链,防 stale 审计副本永久卡死)+ json_valid 脏行防护

统计与前端:
- 统计新增权威口径 tlusty_converged(不按策略拆)与 seed_step_stab_converged 分项,
  前端详情页色带/概览卡消费权威总数并新增稳定化青色段(修复 stab 收敛点漏计,
  生产 9137/9216 差额);M14 迁移回填历史 completed 点的 tlusty_status

文档:
- 新增 failed81 POPZER/DPSILG 制胜配方根因分析、Windows 节点经跳板 RDP 运维手册;
  failed400 增补 ladder 生产化实现与第二轮 121 残点实测矩阵
This commit is contained in:
fmq
2026-09-02 19:37:25 +08:00
parent b058e66722
commit f2700031ce
30 changed files with 3045 additions and 49 deletions
+11
View File
@@ -1147,6 +1147,15 @@ pub struct ChainStep {
pub idlte: Option<i32>,
pub iacc: Option<i32>,
pub orelax: Option<f64>,
/// DPSILG(λ 算子欠松弛上限,默认不生效)。难收敛角落(如 20kK He 富大气的
/// He I/II 电离前沿布居极限环)用 3.0 抑制——2026-08-18 实测,见
/// docs/failed81_cno_seed_popzer_dpsilg_2026_08_18.md。
#[serde(default)]
pub dpsilg: Option<f64>,
/// POPZER(微布居置零阈值)。设置时同时写 POPZR2/RADZER=同值 + NITZER=1
/// 把可忽略微布居从方程中剔除(官方标准输入恒设 1E-20;难收敛角落用 1E-10)。
#[serde(default)]
pub popzer: Option<f64>,
}
fn default_false_str() -> String {
@@ -1991,6 +2000,8 @@ mod tests {
idlte: None,
iacc: None,
orelax: None,
dpsilg: None,
popzer: None,
};
let input5 = make_input5(&grid, &chain, &yaml_loaded);
// 兜底后应含完整 ions 能级数据(而非只有终止行)
+44 -2
View File
@@ -719,7 +719,14 @@ pub fn check_convergence_trace(
.windows(2)
.all(|w| w[0] >= w[1] * 0.5); // 容忍 Ng 加速的局部反弹(×2 内)
let valid = ratio >= min_ratio;
// 热启动豁免(2026-08-18 修复):首拍 max_relc < 1 说明种子已接近收敛解,
// 首末比在此情形下数学上不可能达到 1e3 量级(首拍被钳在低位),判据失效。
// 误杀实例:t55000_g5.0_he-4_c-3_n-4_o-1 的 nl_ladder 阶段(首 0.038→末 6e-4
// 四项物理硬门全过)被否决为未收敛。真伪收敛的最终裁决交给五重物理硬门槛。
const HOT_START_FIRST_MAX_RELC: f64 = 1.0;
let hot_start_exempt = first < HOT_START_FIRST_MAX_RELC;
let valid = ratio >= min_ratio || hot_start_exempt;
Some(ConvergenceTraceCheckResult {
valid,
first_max_relc: first,
@@ -729,7 +736,13 @@ pub fn check_convergence_trace(
n_iters: itek.len(),
min_ratio,
error: if valid {
None
if hot_start_exempt && ratio < min_ratio {
Some(format!(
"热启动豁免:首拍 max_relc {first:.1e} < {HOT_START_FIRST_MAX_RELC},首末比判据不适用"
))
} else {
None
}
} else {
Some(format!(
"假收敛排查失败:首末 max_relc 比 {:.1e} < {:.0e}(可能 Ng 加速伪收敛)",
@@ -1509,6 +1522,35 @@ mod tests {
assert!(res.error.is_some());
}
#[test]
fn test_convergence_trace_hot_start_exempt() {
// 热启动豁免(2026-08-18):首拍 < 1(种子已接近解)时首末比数学上达不到 1e3,
// 旧判据误杀(实例 t55000_g5.0_he-4_c-3_n-4_o-1 nl_ladder0.038→6e-4,物理硬门全过)。
let itek: Vec<IterCheck> = (0..7)
.map(|i| IterCheck {
iter: i + 1,
max_relc: 0.038 / (1.0 + i as f64),
n_depths: 50,
})
.collect();
let res = check_convergence_trace(&itek, 1000.0).expect("≥3拍应判定");
assert!(res.valid, "热启动首拍 <1 应回退到物理硬门槛裁决,不应判假收敛");
assert!(res.ratio < 1000.0);
assert!(res.error.is_some(), "豁免时应带豁免说明");
}
#[test]
fn test_convergence_trace_cold_start_still_gated() {
// 冷启动(首拍 >= 1)不受豁免影响:比值不足仍判假收敛嫌疑。
let itek: Vec<IterCheck> = vec![
IterCheck { iter: 1, max_relc: 5.0, n_depths: 50 },
IterCheck { iter: 2, max_relc: 2.0, n_depths: 50 },
IterCheck { iter: 3, max_relc: 0.05, n_depths: 50 },
];
let res = check_convergence_trace(&itek, 1000.0).expect("≥3拍应判定");
assert!(!res.valid, "冷启动首拍 ≥1 且比值 <1000 仍应失败");
}
#[test]
fn test_convergence_trace_too_short() {
// 2拍:无法判定 → None
+2
View File
@@ -232,6 +232,8 @@ mod tests {
idlte: None,
iacc: None,
orelax: None,
dpsilg: None,
popzer: None,
}
}
+45
View File
@@ -223,6 +223,27 @@ impl GridPointParams {
)
}
/// 从规范网格点名解析参数:`t60000_g5.5_he-2_c-4_n-4_o-4` → GridPointParams。
/// 接受可选的 `_ladder` 等后缀(ladder 合成种子名)。解析失败返回 None。
/// 用途:node 端从 seed_point_name 取种子参数(ladder 步进规划)、
/// server 端从上传文件名取中间种子参数。
pub fn parse_point_name(name: &str) -> Option<Self> {
let re = regex::Regex::new(
r"^t(\d+(?:\.\d+)?)_g(-?\d+(?:\.\d+)?)_he(-?\d+(?:\.\d+)?)_c(-?\d+(?:\.\d+)?)_n(-?\d+(?:\.\d+)?)_o(-?\d+(?:\.\d+)?)(?:_.*)?$",
)
.ok()?;
let caps = re.captures(name)?;
let v = |i: usize| -> Option<f64> { caps.get(i)?.as_str().parse::<f64>().ok() };
Some(GridPointParams {
teff: GridAxisValue::from_value(v(1)?),
logg: GridAxisValue::from_value(v(2)?),
loghe: GridAxisValue::from_value(v(3)?),
logc: GridAxisValue::from_value(v(4)?),
logn: GridAxisValue::from_value(v(5)?),
logo: GridAxisValue::from_value(v(6)?),
})
}
/// CNO 对数丰度之和 (`logc + logn + logo`)。
///
/// 注:此数值专门用于网格调度中的 Wave 难度分级与保序分组(对数和越小代表重元素丰度越低,
@@ -832,9 +853,33 @@ pub struct ModelSummary {
/// `.bfac` b 因子合理性校验结果。None = 未做校验。
#[serde(default)]
pub bfac_check: Option<BfacCheckResult>,
/// ladder 步进链的中间收敛模型(node→server 种子持久化清单)。空 = 未触发 ladder
/// 或无合格中间步。中间模型已过收敛 + NaN 否决,可安全入种子库供相邻失败点复用。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ladder_seeds: Vec<LadderSeedInfo>,
pub note: Option<String>,
}
/// ladder 步进链中间种子信息。
///
/// `point_name` 是合成种子名(中间参数 model_name + `_ladder` 后缀),与任何真实
/// 网格点名不冲突;server 端按其中的参数落 seeds 表(bucket 查找按 teff/logg/loghe
/// 数值列匹配,天然覆盖中间参数点)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LadderSeedInfo {
/// 产生该模型的阶段标签(如 `ladder_g6.25`)。
pub label: String,
/// 合成种子名(server 端 seeds.point_name / 磁盘文件名,不含 .7 后缀)。
pub point_name: String,
/// 中间步大气参数(teff/logg 为中间值,丰度同目标点)。
pub teff: f64,
pub logg: f64,
pub loghe: f64,
pub logc: f64,
pub logn: f64,
pub logo: f64,
}
#[cfg(test)]
mod tests {
use super::*;
+46
View File
@@ -82,6 +82,21 @@ pub fn generate_nst_content(chain: &ChainStep, global: &TlustyInput) -> String {
line2.push(format!("ICHANG={}", ichang));
written_keys.insert("ICHANG".to_string());
}
if let Some(dpsilg) = chain.dpsilg {
line2.push(format!("DPSILG={}", dpsilg));
written_keys.insert("DPSILG".to_string());
}
if let Some(popzer) = chain.popzer {
// POPZER 联动组:官方标准输入恒 POPZER=POPZR2=RADZER + NITZER=1
// tests/tlusty/*/.6 回显),缺一则置零机制不完整。
line2.push(format!("POPZER={:.E}", popzer));
line2.push(format!("POPZR2={:.E}", popzer));
line2.push(format!("RADZER={:.E}", popzer));
line2.push("NITZER=1".to_string());
for k in ["POPZER", "POPZR2", "RADZER", "NITZER"] {
written_keys.insert(k.to_string());
}
}
line2.push(format!("IELCOR={}", n.physics.ielcor));
written_keys.insert("IELCOR".to_string());
push_wrapped(&mut out, &line2);
@@ -224,6 +239,8 @@ mod tests {
idlte: None,
iacc: None,
orelax: None,
dpsilg: None,
popzer: None,
}
}
@@ -331,6 +348,35 @@ mod tests {
assert!(!content.contains("FRLMIN"), "FRLMIN=0 不应写出");
}
/// DPSILG/POPZER 稳定化旋钮(2026-08-18seed_step_stab 策略):
/// dpsilg → DPSILG=<val>popzer → POPZER/POPZR2/RADZER 三键同值 + NITZER=1
/// (官方标准输入联动组),且行宽仍受 75 字符保护(自动换行)。
#[test]
fn test_nst_dpsilg_popzer_stabilization() {
let mut chain = chain_nc();
chain.dpsilg = Some(3.0);
chain.popzer = Some(1e-10);
let content = generate_nst_content(&chain, &TlustyInput::default());
assert!(content.contains("DPSILG=3"), "DPSILG 应写出: {}", content);
assert!(content.contains("POPZER=1E-10"), "POPZER 应以 Fortran E 格式写出");
assert!(content.contains("POPZR2=1E-10"));
assert!(content.contains("RADZER=1E-10"));
assert!(content.contains("NITZER=1"), "POPZER 联动 NITZER=1");
for (i, line) in content.lines().enumerate() {
assert!(
line.chars().count() <= 75,
"nst 第 {} 行超宽({} 字符): {}",
i + 1,
line.chars().count(),
line
);
}
// 默认(None)不写出,保持字节级兼容
let plain = generate_nst_content(&chain_nc(), &TlustyInput::default());
assert!(!plain.contains("DPSILG"));
assert!(!plain.contains("POPZER"));
}
/// fmt_real 边界:-0.0 归一为 "0."(审查 Nit-1)。
#[test]
fn test_fmt_real_negative_zero() {
File diff suppressed because it is too large Load Diff
+1
View File
@@ -109,6 +109,7 @@ mod tests {
temp_check: None,
emflux_check: None,
bfac_check: None,
ladder_seeds: Vec::new(),
note: None,
}
}
+84 -7
View File
@@ -20,7 +20,7 @@ pub async fn execute_task(
result_dir: &Path,
task: &TaskSpec,
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Result<(ModelSummary, Option<Vec<u8>>)> {
) -> Result<(ModelSummary, Option<Vec<u8>>, Vec<(String, Vec<u8>)>)> {
info!(
"开始执行计算任务 {} (网格点: {})",
task.task_id, task.point_name
@@ -68,7 +68,11 @@ pub async fn execute_task(
// 全新 slot 内不可能已有目标大气;重试任务的 slot 亦全新(task_id 唯一)。
let tlusty_enabled = task.tlusty_config.enabled;
let current_strategy = task.tlusty_config.current_strategy("cold_run");
let needs_seed_download = (tlusty_enabled && current_strategy == "seed_step")
// seed_step_stab2026-08-18 稳定化种子步进)同样是种子热启动链,必须下载种子;
// 2026-08-19 生产事故:漏列该策略导致种子未下载,seed_nc 无 fort.8 直接
// EOF 崩溃(rc=2),78 个任务全部假失败。
let needs_seed_download = (tlusty_enabled
&& matches!(current_strategy, "seed_step" | "seed_step_stab"))
|| (!tlusty_enabled && task.synspec_config.enabled);
if needs_seed_download {
@@ -223,6 +227,7 @@ pub async fn execute_task(
&task.tlusty_chain_params,
&task.seed_chain_params,
&task.task_id.to_string(),
task.params.teff.value(),
);
// TLUSTY 输入文件全局参数(NFREAD/ions 表/nst extra_keys 等)。
// None → runner 用代码内硬编码默认(向后兼容)。
@@ -239,6 +244,12 @@ pub async fn execute_task(
}
}
});
// 种子参数(ladder 步进规划需要种子与目标的参数差):从权威 seed_point_name
// 解析。解析失败(命名不符)→ None → ladder 回退不触发,安全降级。
let seed_params = task
.seed_point_name
.as_deref()
.and_then(common::models::GridPointParams::parse_point_name);
let summary = runner
.run_model_with_timeout(
&task.params,
@@ -248,6 +259,7 @@ pub async fn execute_task(
current_strategy,
Some(chain),
seed_atmos_path.as_deref(),
seed_params.as_ref(),
synspec_cfg.as_ref(),
// 阶段独立配置开关(见 docs/task_engine_decoupling_design.md §5)。
task.tlusty_config.enabled,
@@ -303,7 +315,30 @@ pub async fn execute_task(
slot_work_dir.display()
);
Ok((summary, seed_bytes))
// ladder 中间梯级种子字节:即便最终失败也上报(簇内相邻失败点可复用已收敛梯级)。
// 阶段快照命名 <name>.<label>.7execute_tlusty_stage 写入 model_dir)。
let mut ladder_seed_files: Vec<(String, Vec<u8>)> = Vec::new();
if !summary.ladder_seeds.is_empty() {
let model_sub_dir = slot_work_dir.join(&summary.name);
for info in &summary.ladder_seeds {
let path = model_sub_dir.join(format!("{}.{}.7", summary.name, info.label));
if path.is_file() {
if let Ok(bytes) = tokio::fs::read(&path).await {
info!(
"读取 ladder 中间种子 {}{}{} 字节)",
info.point_name,
path.display(),
bytes.len()
);
ladder_seed_files.push((info.point_name.clone(), bytes));
}
} else {
warn!("ladder 种子快照缺失: {}", path.display());
}
}
}
Ok((summary, seed_bytes, ladder_seed_files))
}
/// 清理任务在 Node 端的沙盒目录
@@ -518,6 +553,7 @@ fn resolve_execution_chain(
tlusty_chain_params: &Option<serde_json::Value>,
seed_chain_params: &Option<serde_json::Value>,
task_id: &str,
teff: f64,
) -> Vec<ChainStep> {
if current_strategy == "seed_step" {
seed_chain_params
@@ -534,6 +570,27 @@ fn resolve_execution_chain(
})
.filter(|c| !c.is_empty())
.unwrap_or_else(common::runner::default_seed_chain)
} else if current_strategy == "seed_step_stab" {
// 稳定化种子步进:POPZER+DPSILG 稳定化参数即本策略的实质——用户自定义链
// tlusty_chain/seed_chain)不带这些旋钮,热启动会重演发散,故不采纳、
// 恒用 default_seed_stab_chain。
// 2026-08-21 域门控(60k 攻坚实测,docs/failed81_...md §十一):DPSILG/POPZER
// 族旋钮仅在低温 He 富域(Teff≤30kK)有效;高温高金属域(60k/g5.0 o-1 角)
// 实测自复现收敛种子加档后 17 拍爆到 4e16——致散。域外改用普通种子链,
// 交给 runner 的自适应 Teff 延拓处理。
if teff <= 30000.0 {
common::runner::default_seed_stab_chain()
} else {
info!(
"任务 {}Teff={}>30kK 域外,seed_step_stab 降级为普通种子链(稳定化旋钮高温致散)",
task_id, teff
);
seed_chain_params
.as_ref()
.and_then(|v| serde_json::from_value::<Vec<ChainStep>>(v.clone()).ok())
.filter(|c| !c.is_empty())
.unwrap_or_else(common::runner::default_seed_chain)
}
} else {
tlusty_chain_params
.as_ref()
@@ -585,6 +642,7 @@ mod tests {
temp_check: None,
emflux_check: None,
bfac_check: None,
ladder_seeds: Vec::new(),
note: None,
}
}
@@ -604,7 +662,7 @@ mod tests {
]);
// seed_chain_params=None → 走 default_seed_chaintlusty_chain_params 被忽略。
let chain = resolve_execution_chain("seed_step", &Some(custom_cold), &None, "t1");
let chain = resolve_execution_chain("seed_step", &Some(custom_cold), &None, "t1", 20000.0);
assert_eq!(labels(&chain), vec!["seed_nc", "nl"]);
// 首步必须是非灰 LTEltgray=F),否则会删 fort.8 丢弃种子。
assert_eq!(chain[0].ltgray, "F");
@@ -618,13 +676,32 @@ mod tests {
{"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100, "orelax": 0.5},
]);
let chain = resolve_execution_chain("seed_step", &None, &Some(custom_seed), "t1b");
let chain = resolve_execution_chain("seed_step", &None, &Some(custom_seed), "t1b", 20000.0);
assert_eq!(labels(&chain), vec!["seed_nc", "nl"]);
// 用户配置的 orelax 生效。
assert_eq!(chain[0].orelax, Some(0.3));
assert_eq!(chain[1].orelax, Some(0.5));
}
/// seed_step_stab 温度域门控(2026-08-2160k 攻坚):
/// Teff≤30kK 用稳定化链(全步带 DPSILG/POPZER);
/// 高温域(60k/g5.0 高金属角实测旋钮致散)降级为普通种子链。
#[test]
fn seed_step_stab_teff_domain_gating() {
let cold = resolve_execution_chain("seed_step_stab", &None, &None, "hot1", 20000.0);
assert!(
cold.iter().all(|s| s.dpsilg.is_some() && s.popzer.is_some()),
"低温域 seed_step_stab 应带稳定化参数"
);
let hot = resolve_execution_chain("seed_step_stab", &None, &None, "hot2", 60000.0);
assert!(
hot.iter().all(|s| s.dpsilg.is_none() && s.popzer.is_none()),
"高温域 seed_step_stab 应降级为普通种子链(不带稳定化旋钮)"
);
assert_eq!(labels(&hot), vec!["seed_nc", "nl"]);
}
#[test]
fn cold_run_uses_custom_chain_when_provided() {
let custom = serde_json::json!([
@@ -633,13 +710,13 @@ mod tests {
{"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100},
]);
let chain = resolve_execution_chain("cold_run", &Some(custom), &None, "t2");
let chain = resolve_execution_chain("cold_run", &Some(custom), &None, "t2", 20000.0);
assert_eq!(labels(&chain), vec!["lte", "nc", "nl"]);
}
#[test]
fn cold_run_falls_back_to_default_when_no_custom_chain() {
let chain = resolve_execution_chain("cold_run", &None, &None, "t3");
let chain = resolve_execution_chain("cold_run", &None, &None, "t3", 20000.0);
assert_eq!(labels(&chain), vec!["lte", "nc", "nl"]);
}
+25 -2
View File
@@ -50,12 +50,22 @@ fn derive_report_status(s: &ModelSummary) -> TaskStatus {
}
}
/// executor 返回值:(summary, 最终种子字节, ladder 中间梯级种子字节列表)。
pub type ExecOutcome = Result<
(
ModelSummary,
Option<Vec<u8>>,
Vec<(String, Vec<u8>)>,
),
String,
>;
pub async fn report_result(
client: &Client,
server_url: &str,
node_id: &str,
task: &TaskSpec,
exec_res: Result<(ModelSummary, Option<Vec<u8>>), String>,
exec_res: ExecOutcome,
) -> Result<()> {
let report_url = format!("{}/api/task/report", server_url);
@@ -69,8 +79,9 @@ pub async fn report_result(
summary_json,
seed_bytes,
failed_stage,
ladder_seed_files,
) = match exec_res {
Ok((s, s_bytes)) => (
Ok((s, s_bytes, ladder_files)) => (
derive_report_status(&s),
s.result_valid,
s.final_max_relc,
@@ -80,6 +91,7 @@ pub async fn report_result(
serde_json::to_string(&s).unwrap_or_default(),
s_bytes,
infer_failed_stage(task, &s),
ladder_files,
),
Err(e) => (
TaskStatus::Failed,
@@ -91,6 +103,7 @@ pub async fn report_result(
serde_json::json!({"error": e}).to_string(),
None,
None,
Vec::new(),
),
};
@@ -128,6 +141,15 @@ pub async fn report_result(
}
}
// ladder 中间梯级种子:即便最终任务失败也上传(executor 只收集收敛+无 NaN
// 的中间模型),server 端落 seeds 表供相邻失败点复用。
for (seed_name, bytes) in &ladder_seed_files {
let part = Part::bytes(bytes.clone())
.file_name(format!("{}.7", seed_name))
.mime_str("application/octet-stream")?;
form = form.part("ladder_seed", part);
}
match client.post(&report_url).multipart(form).send().await {
Ok(resp) if resp.status().is_success() => {
info!(
@@ -239,6 +261,7 @@ mod tests {
temp_check: None,
emflux_check: None,
bfac_check: None,
ladder_seeds: Vec::new(),
note: None,
}
}
+1 -1
View File
@@ -542,7 +542,7 @@ impl NodeWorker {
// 严重失败(runner 抛 Err)时 summary_opt=None,此时回退到 task.point_name
// (二者均源自 params.model_name())作为归档目录名,确保失败任务的
// 排错日志也能落盘而非随沙盒删除丢失。
let summary_opt = res.as_ref().ok().map(|(s, _)| s.clone());
let summary_opt = res.as_ref().ok().map(|(s, _, _)| s.clone());
let result_name = summary_opt
.as_ref()
.map(|s| s.name.clone())
+54
View File
@@ -81,6 +81,8 @@ pub async fn report_task(
) -> Result<impl IntoResponse, crate::api::AppError> {
let mut report_json: Option<TaskReport> = None;
let mut seed_file_data: Option<Vec<u8>> = None;
// ladder 中间梯级种子(可重复字段):文件名(去 .7)即合成种子名,参数从名称解析。
let mut ladder_seed_files: Vec<(String, Vec<u8>)> = Vec::new();
let mut multipart_error = false;
// 遍历全部 multipart 字段。旧实现 `while let Ok(Some(field))` 在首个字段读取错误时
@@ -114,6 +116,24 @@ pub async fn report_task(
multipart_error = true;
}
}
} else if field_name == "ladder_seed" {
// 文件名即合成种子名(<model_name>_ladder.7
let file_name = field
.file_name()
.unwrap_or("")
.trim_end_matches(".7")
.to_string();
match field.bytes().await {
Ok(bytes) => {
if !file_name.is_empty() {
ladder_seed_files.push((file_name, bytes.to_vec()));
}
}
Err(e) => {
warn!("读取 ladder_seed 字段失败: {}", e);
multipart_error = true;
}
}
}
}
Ok(None) => break,
@@ -329,6 +349,40 @@ pub async fn report_task(
}
}
}
// ladder 中间梯级种子持久化(2026-08-17):node 端仅上报「收敛 + 无 NaN」的
// 中间模型;此处原子落盘 + 按 _ladder 合成名入 seeds 表(ON CONFLICT 仅刷新
// file_path,绝不触碰真实网格点种子行)。名称解析失败 → 丢弃并告警(防脏名)。
for (seed_name, bytes) in &ladder_seed_files {
let Some(lp) = common::models::GridPointParams::parse_point_name(seed_name) else {
warn!("ladder 种子名 {} 无法解析参数,丢弃", seed_name);
continue;
};
let ladder_dir = Path::new(&state.seeds_dir).join(seed_name);
if fs::create_dir_all(&ladder_dir).await.is_err() {
warn!("ladder 种子目录创建失败: {}", ladder_dir.display());
continue;
}
let tmp = ladder_dir.join(format!("{}.7.{}.tmp", seed_name, uuid::Uuid::new_v4().simple()));
let path = ladder_dir.join(format!("{}.7", seed_name));
if fs::write(&tmp, bytes).await.is_ok() && fs::rename(&tmp, &path).await.is_ok() {
if let Err(e) = state
.db
.insert_seed_named(seed_name, &lp, &path.to_string_lossy())
.await
{
warn!("ladder 种子 {} 入库失败: {}", seed_name, e);
} else {
info!(
"ladder 中间种子入库: {} (t{} g{} he{})",
seed_name,
lp.teff.value(),
lp.logg.value(),
lp.loghe.value()
);
}
}
}
}
if state_changed
+15 -2
View File
@@ -576,8 +576,10 @@ impl Database {
SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
SUM(CASE WHEN tlusty_status = 'converged' THEN 1 ELSE 0 END) AS tlusty_converged,
SUM(CASE WHEN tlusty_status = 'converged' AND tlusty_success_method = 'cold_run' THEN 1 ELSE 0 END) AS cold_run_converged,
SUM(CASE WHEN tlusty_status = 'converged' AND tlusty_success_method = 'seed_step' THEN 1 ELSE 0 END) AS seed_step_converged,
SUM(CASE WHEN tlusty_status = 'converged' AND tlusty_success_method = 'seed_step_stab' THEN 1 ELSE 0 END) AS seed_step_stab_converged,
SUM(CASE WHEN tlusty_status = 'failed' THEN 1 ELSE 0 END) AS tlusty_failed,
SUM(CASE WHEN synspec_status = 'converged' THEN 1 ELSE 0 END) AS synspec_converged,
SUM(CASE WHEN synspec_status = 'failed' THEN 1 ELSE 0 END) AS synspec_failed,
@@ -586,7 +588,7 @@ impl Database {
params![name],
|r| {
let n = |i: usize| -> i64 { r.get::<_, Option<i64>>(i).unwrap_or(None).unwrap_or(0) };
Ok((n(0), n(1), n(2), n(3), n(4), n(5), n(6), n(7), n(8), n(9), n(10), n(11)))
Ok((n(0), n(1), n(2), n(3), n(4), n(5), n(6), n(7), n(8), n(9), n(10), n(11), n(12), n(13)))
},
),
None => conn.query_row(
@@ -597,8 +599,10 @@ impl Database {
SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
SUM(CASE WHEN tlusty_status = 'converged' THEN 1 ELSE 0 END) AS tlusty_converged,
SUM(CASE WHEN tlusty_status = 'converged' AND tlusty_success_method = 'cold_run' THEN 1 ELSE 0 END) AS cold_run_converged,
SUM(CASE WHEN tlusty_status = 'converged' AND tlusty_success_method = 'seed_step' THEN 1 ELSE 0 END) AS seed_step_converged,
SUM(CASE WHEN tlusty_status = 'converged' AND tlusty_success_method = 'seed_step_stab' THEN 1 ELSE 0 END) AS seed_step_stab_converged,
SUM(CASE WHEN tlusty_status = 'failed' THEN 1 ELSE 0 END) AS tlusty_failed,
SUM(CASE WHEN synspec_status = 'converged' THEN 1 ELSE 0 END) AS synspec_converged,
SUM(CASE WHEN synspec_status = 'failed' THEN 1 ELSE 0 END) AS synspec_failed,
@@ -607,7 +611,7 @@ impl Database {
[],
|r| {
let n = |i: usize| -> i64 { r.get::<_, Option<i64>>(i).unwrap_or(None).unwrap_or(0) };
Ok((n(0), n(1), n(2), n(3), n(4), n(5), n(6), n(7), n(8), n(9), n(10), n(11)))
Ok((n(0), n(1), n(2), n(3), n(4), n(5), n(6), n(7), n(8), n(9), n(10), n(11), n(12), n(13)))
},
),
}?;
@@ -618,8 +622,10 @@ impl Database {
running,
completed,
failed,
tlusty_converged,
cold_run_converged,
seed_step_converged,
seed_step_stab_converged,
tlusty_failed,
synspec_converged,
synspec_failed,
@@ -633,8 +639,13 @@ impl Database {
"running": running,
"completed": completed,
"failed": failed,
// tlusty_converged2026-08-25):大气收敛的权威总数(不按 method 拆分)。
// 前端「N 大气收敛」此前用 cold+seed 之和——seed_step_stab2026-08-18 引入)
// 收敛的 70 点被漏计(生产 9137/9216 差额主因)。
"tlusty_converged": tlusty_converged,
"cold_run_converged": cold_run_converged,
"seed_step_converged": seed_step_converged,
"seed_step_stab_converged": seed_step_stab_converged,
"tlusty_failed": tlusty_failed,
"synspec_converged": synspec_converged,
"synspec_failed": synspec_failed,
@@ -733,6 +744,8 @@ impl Database {
failed,
cold_run_converged: g("cold_run_converged"),
seed_step_converged: g("seed_step_converged"),
tlusty_converged: g("tlusty_converged"),
seed_step_stab_converged: g("seed_step_stab_converged"),
tlusty_failed: g("tlusty_failed"),
synspec_converged: g("synspec_converged"),
synspec_failed: g("synspec_failed"),
+481
View File
@@ -710,6 +710,12 @@ pub struct WorkflowListStats {
pub running: i64,
pub cold_run_converged: i64,
pub seed_step_converged: i64,
/// TLUSTY 阶段收敛总数(tlusty_status='converged')——权威口径,语义同
/// WorkflowStats.tlusty_converged2026-08-25 补,见彼处注释)。
#[serde(default)]
pub tlusty_converged: i64,
#[serde(default)]
pub seed_step_stab_converged: i64,
/// TLUSTY 阶段发散点数(tlusty_status='failed')。
pub tlusty_failed: i64,
/// SYNSPEC 阶段收敛点数(synspec_status='converged')。
@@ -754,6 +760,15 @@ pub struct WorkflowStats {
pub failed: i64,
pub cold_run_converged: i64,
pub seed_step_converged: i64,
/// TLUSTY 阶段收敛总数(tlusty_status='converged',不按 method 拆分)——权威口径。
/// 前端「N 大气收敛」应消费此字段;cold/seed/stab 为策略细分。2026-08-25 补:
/// 此前前端用 cold+seed 之和,seed_step_stab2026-08-18 引入)收敛点被漏计
/// (生产 9137/9216 差额主因)。
#[serde(default)]
pub tlusty_converged: i64,
/// TLUSTY 阶段以 seed_step_stab 策略收敛的点数(稳定化/waypoint 种子链)。
#[serde(default)]
pub seed_step_stab_converged: i64,
/// TLUSTY 阶段发散点数(tlusty_status='failed')。
pub tlusty_failed: i64,
/// SYNSPEC 阶段收敛点数(synspec_status='converged')。
@@ -1199,6 +1214,7 @@ mod tests {
temp_check: None,
emflux_check: None,
bfac_check: None,
ladder_seeds: Vec::new(),
note: None,
};
db.upsert_point_summary(&summary.name, "wf_a", &summary, "seed_step")
@@ -1215,12 +1231,16 @@ mod tests {
assert_eq!(all["completed"], 1);
assert_eq!(all["cold_run_converged"], 0);
assert_eq!(all["seed_step_converged"], 1);
// tlusty_converged2026-08-25):不按 method 拆分的权威总数。
assert_eq!(all["tlusty_converged"], 1);
assert_eq!(all["seed_step_stab_converged"], 0);
// 单工作流 wf_a1 pending + 1 seed_step 收敛
let a = db.get_grid_summary_stats(Some("wf_a")).await.unwrap();
assert_eq!(a["total"], 2);
assert_eq!(a["pending"], 1);
assert_eq!(a["queued"], 0);
assert_eq!(a["seed_step_converged"], 1);
assert_eq!(a["tlusty_converged"], 1);
// 不存在的工作流:0
let none = db
.get_grid_summary_stats(Some("nonexistent"))
@@ -1264,6 +1284,120 @@ mod tests {
assert_eq!(m.unwrap().name, exact.model_name());
}
/// find_exact_family_seed_from_db 严格同物理族判定(2026-08-19 生产修复回归):
/// ladder 中间种子(如 teff=22500)即便 CNO 距离为 0,也因 Teff 不同被排除;
/// 同 Teff/logg/logHe 的 CNO 邻居正常命中;排除目标自身名。
#[tokio::test]
async fn test_find_exact_family_seed_strict_same_family() {
let temp_dir = tempfile::tempdir().unwrap();
let db = Database::new(&temp_dir.path().join("seed_stab_db.db").to_string_lossy())
.await
.unwrap();
let target = GridPointParams {
teff: 20000.0.into(),
logg: 6.5.into(),
loghe: 2.0.into(),
logc: (-4.0).into(),
logn: (-3.0).into(),
logo: (-4.0).into(),
};
// ladder 中间种子:同 logg/logHe、CNO 完全一致,但 teff=22500 —— 必须排除。
let ladder = GridPointParams {
teff: 22500.0.into(),
logg: 6.5.into(),
loghe: 2.0.into(),
logc: (-4.0).into(),
logn: (-3.0).into(),
logo: (-4.0).into(),
};
// 合法 CNO 邻居:同 Teff/logg/logHe,Δo=1。
let cno_neighbor = GridPointParams {
teff: 20000.0.into(),
logg: 6.5.into(),
loghe: 2.0.into(),
logc: (-4.0).into(),
logn: (-3.0).into(),
logo: (-3.0).into(),
};
db.insert_seed(&ladder, "/tmp/ladder.7").await.unwrap();
db.insert_seed(&cno_neighbor, "/tmp/neighbor.7").await.unwrap();
let m = db
.find_exact_family_seed_from_db(&target, &[target.model_name()])
.await
.unwrap();
assert!(m.is_some(), "应命中 CNO 邻居");
assert_eq!(
m.unwrap().name,
cno_neighbor.model_name(),
"不得选 teff 不同的 ladder 中间种子(即便其 CNO 距离为 0)"
);
// 无严格同族候选时返回 None(不退化到 global)。
let lonely = GridPointParams {
teff: 30000.0.into(),
logg: 5.0.into(),
loghe: (-2.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let none = db
.find_exact_family_seed_from_db(&lonely, &[lonely.model_name()])
.await
.unwrap();
assert!(none.is_none());
}
/// 种子轮换回归(2026-08-20 修复):find_exact_family_seed_from_db 排除列表
/// 应使调用方在重试间轮换到下一个未试过的同族 CNO 邻居;全部排除后返回 None。
#[tokio::test]
async fn test_find_exact_family_seed_rotation() {
let temp_dir = tempfile::tempdir().unwrap();
let db = Database::new(&temp_dir.path().join("seed_rot_db.db").to_string_lossy())
.await
.unwrap();
let base = |logc: f64, logn: f64, logo: f64| GridPointParams {
teff: 20000.0.into(),
logg: 6.0.into(),
loghe: 2.0.into(),
logc: logc.into(),
logn: logn.into(),
logo: logo.into(),
};
let target = base(-3.0, -3.0, -1.0);
let n1 = base(-3.0, -2.0, -1.0);
let n2 = base(-3.0, -3.0, -2.0);
db.insert_seed(&n1, "/tmp/rot_n1.7").await.unwrap();
db.insert_seed(&n2, "/tmp/rot_n2.7").await.unwrap();
let self_name = target.model_name();
// 首次:选距离最近的未排除邻居。
let first = db
.find_exact_family_seed_from_db(&target, &[self_name.clone()])
.await
.unwrap()
.expect("应命中 CNO 邻居");
// 排除首个后:轮换到另一个邻居。
let second = db
.find_exact_family_seed_from_db(&target, &[self_name, first.name.clone()])
.await
.unwrap()
.expect("排除首个邻居后应轮换到第二个邻居");
assert_ne!(first.name, second.name);
// 全部排除后:None(终态 failed,不死循环)。
let none = db
.find_exact_family_seed_from_db(
&target,
&[target.model_name(), first.name, second.name],
)
.await
.unwrap();
assert!(none.is_none(), "邻居全部试过后应返回 None");
}
/// "种子回退仅一次"守卫语义(2026-08-02 涡旋事故定稿):has_seed_step_attempt
/// 统计**一切** seed_step 行(含 pending)。pending 行在新架构下只有两种来源:
/// (a) 真在途(排队/已领用)——计数它正是对在途回退的去重,挡住救援途中迟到失败
@@ -3265,6 +3399,347 @@ mod tests {
assert_eq!(item.status, "completed");
}
/// 完成 flip 的「未消费回退链」阻塞回归(2026-08-22 修复):
/// 最后一个活跃点失败时,其最新 failed 行策略链弹掉失败首项后仍有顺位
/// (长度 >1)→ workflow 不得置 completed(否则 trigger_strategy_fallback
/// 的 still_running 守卫拦截,链上 seed_step/seed_step_stab 永不派发;
/// 生产实证 sdB_cno/t60000_g5.0_he-4_c-4_n-4_o-18-22 04:00:50)。
/// 链耗尽(长度 1)后放行翻转。
#[tokio::test]
async fn test_workflow_flip_blocked_by_unconsumed_fallback_chain() {
let temp_dir = tempfile::tempdir().unwrap();
let db = Database::new(
&temp_dir
.path()
.join("flip_block_db.db")
.to_string_lossy(),
)
.await
.unwrap();
let params = GridPointParams {
teff: 60000.0.into(),
logg: 5.0.into(),
loghe: (-4.0).into(),
logc: (-4.0).into(),
logn: (-4.0).into(),
logo: (-1.0).into(),
};
let name = params.model_name();
db.upsert_workflow("wf_fb2", None, "config", "idle")
.await
.unwrap();
db.update_workflow_status("wf_fb2", "running").await.unwrap();
db.upsert_grid_point(&params, 0, "wf_fb2").await.unwrap();
// 点终态 failed、无 pending/queued/running——旧条件已满足 flip。
db.update_grid_status(&name, GridPointStatus::Failed, "wf_fb2")
.await
.unwrap();
// 最新 failed 行携带 3 顺位链(cold_run 刚失败,尚余 seed_step/seed_step_stab)。
let t1 = uuid::Uuid::new_v4();
db.insert_task(&common::models::TaskSpec {
task_id: t1,
point_name: name.clone(),
params: params.clone(),
timeout_sec: 7200,
workflow_name: Some("wf_fb2".to_string()),
wave: 0,
tlusty_config: PhaseConfig {
strategies: vec![
"cold_run".to_string(),
"seed_step".to_string(),
"seed_step_stab".to_string(),
],
..PhaseConfig::default_tlusty()
},
..Default::default()
})
.await
.unwrap();
{
let pool = db.pool.clone();
let tid = t1.to_string();
tokio::task::spawn_blocking(move || {
let conn = pool.get().unwrap();
conn.execute(
"UPDATE tasks SET status = 'failed', completed_at = datetime('now') \
WHERE task_id = ?1",
params![tid],
)
.unwrap();
})
.await
.unwrap();
}
db.sync_all_running_workflows_completion().await.unwrap();
let wf = db.get_workflow("wf_fb2").await.unwrap().unwrap();
assert_eq!(
wf.status, "running",
"存在未消费回退链的 failed 点时,workflow 不应翻转 completed"
);
// 回退推进到链尾(更新的 failed 行仅剩单顺位)→ 阻塞解除,翻转放行。
let t2 = uuid::Uuid::new_v4();
db.insert_task(&common::models::TaskSpec {
task_id: t2,
point_name: name.clone(),
params: params.clone(),
timeout_sec: 7200,
workflow_name: Some("wf_fb2".to_string()),
wave: 0,
tlusty_config: PhaseConfig {
strategies: vec!["seed_step_stab".to_string()],
..PhaseConfig::default_tlusty()
},
..Default::default()
})
.await
.unwrap();
{
let pool = db.pool.clone();
let tid = t2.to_string();
tokio::task::spawn_blocking(move || {
let conn = pool.get().unwrap();
conn.execute(
"UPDATE tasks SET status = 'failed', completed_at = datetime('now'), \
created_at = datetime('now', '+1 hour') WHERE task_id = ?1",
params![tid],
)
.unwrap();
})
.await
.unwrap();
}
db.sync_all_running_workflows_completion().await.unwrap();
let wf2 = db.get_workflow("wf_fb2").await.unwrap().unwrap();
assert_eq!(
wf2.status, "completed",
"链耗尽(长度 1)后应恢复完成翻转"
);
}
/// 完成 flip 的「种子轮换待执行」阻塞回归(2026-08-22 审查补充):
/// 链尾 [seed_step_stab] 失败 + 存在未试过的同物理族干净种子 → 不翻转
/// (否则 rotation 臂被 still_running 守卫吞掉,waypoint 只有一次机会);
/// 同族种子全部用过后放行翻转。
#[tokio::test]
async fn test_workflow_flip_blocked_by_seed_rotation_pending() {
let temp_dir = tempfile::tempdir().unwrap();
let db = Database::new(
&temp_dir
.path()
.join("flip_rot_db.db")
.to_string_lossy(),
)
.await
.unwrap();
let params = GridPointParams {
teff: 60000.0.into(),
logg: 5.0.into(),
loghe: (-4.0).into(),
logc: (-4.0).into(),
logn: (-4.0).into(),
logo: (-1.0).into(),
};
let name = params.model_name();
db.upsert_workflow("wf_rot", None, "config", "idle")
.await
.unwrap();
db.update_workflow_status("wf_rot", "running").await.unwrap();
db.upsert_grid_point(&params, 0, "wf_rot").await.unwrap();
db.update_grid_status(&name, GridPointStatus::Failed, "wf_rot")
.await
.unwrap();
// 链尾 [seed_step_stab] 失败行。
let t1 = uuid::Uuid::new_v4();
db.insert_task(&common::models::TaskSpec {
task_id: t1,
point_name: name.clone(),
params: params.clone(),
timeout_sec: 7200,
workflow_name: Some("wf_rot".to_string()),
wave: 0,
tlusty_config: PhaseConfig {
strategies: vec!["seed_step_stab".to_string()],
..PhaseConfig::default_tlusty()
},
..Default::default()
})
.await
.unwrap();
{
let pool = db.pool.clone();
let tid = t1.to_string();
tokio::task::spawn_blocking(move || {
let conn = pool.get().unwrap();
conn.execute(
"UPDATE tasks SET status = 'failed', completed_at = datetime('now') \
WHERE task_id = ?1",
params![tid],
)
.unwrap();
})
.await
.unwrap();
}
// 无同族种子 → 无可轮换 → 放行翻转。
db.sync_all_running_workflows_completion().await.unwrap();
assert_eq!(
db.get_workflow("wf_rot").await.unwrap().unwrap().status,
"completed",
"无同族候选种子时链尾失败应放行翻转"
);
// 重启场景:workflow 回 running,注入一个未用过的同族干净种子。
db.update_workflow_status("wf_rot", "running").await.unwrap();
let neighbor = GridPointParams {
logn: (-3.489798).into(),
..params.clone()
};
db.insert_seed_named(
"t60000_g5_he-4_c-4_n-3.489798_o-1_ladder",
&neighbor,
"/tmp/seed.7",
)
.await
.unwrap();
db.sync_all_running_workflows_completion().await.unwrap();
assert_eq!(
db.get_workflow("wf_rot").await.unwrap().unwrap().status,
"running",
"存在未试过的同族种子时,链尾 seed_step_stab 失败应阻塞翻转(rotation 待执行)"
);
// 轮换消费该种子(任务行携带 seed_point_name)→ 候选耗尽 → 放行。
let t2 = uuid::Uuid::new_v4();
db.insert_task(&common::models::TaskSpec {
task_id: t2,
point_name: name.clone(),
params: params.clone(),
seed_point_name: Some("t60000_g5_he-4_c-4_n-3.489798_o-1_ladder".to_string()),
timeout_sec: 7200,
workflow_name: Some("wf_rot".to_string()),
wave: 0,
tlusty_config: PhaseConfig {
strategies: vec!["seed_step_stab".to_string()],
..PhaseConfig::default_tlusty()
},
..Default::default()
})
.await
.unwrap();
{
let pool = db.pool.clone();
let tid = t2.to_string();
tokio::task::spawn_blocking(move || {
let conn = pool.get().unwrap();
conn.execute(
"UPDATE tasks SET status = 'failed', completed_at = datetime('now'), \
created_at = datetime('now', '+1 hour') WHERE task_id = ?1",
params![tid],
)
.unwrap();
})
.await
.unwrap();
}
db.sync_all_running_workflows_completion().await.unwrap();
assert_eq!(
db.get_workflow("wf_rot").await.unwrap().unwrap().status,
"completed",
"同族种子全部用过(无新候选)后应放行翻转"
);
}
/// 完成 flip 的阶段归因回归(2026-08-22 审查补充):synspec 失败行的
/// tlusty_strategies 是完整审计副本(scheduler.rs synspec 回退刻意保留),
/// 阻塞判定必须按 failed_stage='synspec' 只看 synspec 链——否则 synspec
/// 工作流会因 stale 审计副本永久卡 running。
#[tokio::test]
async fn test_workflow_flip_stage_attribution_ignores_tlusty_audit_copy() {
let temp_dir = tempfile::tempdir().unwrap();
let db = Database::new(
&temp_dir
.path()
.join("flip_syn_db.db")
.to_string_lossy(),
)
.await
.unwrap();
let params = GridPointParams {
teff: 30000.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_workflow("wf_syn2", None, "config", "idle")
.await
.unwrap();
db.update_workflow_status("wf_syn2", "running").await.unwrap();
db.upsert_grid_point(&params, 0, "wf_syn2").await.unwrap();
db.update_grid_status(&name, GridPointStatus::Failed, "wf_syn2")
.await
.unwrap();
// synspec 失败行:synspec 链已耗尽(长度 1),但 tlusty 链是完整审计
// 副本(长度 3)——阶段归因下不应阻塞。
let t1 = uuid::Uuid::new_v4();
db.insert_task(&common::models::TaskSpec {
task_id: t1,
point_name: name.clone(),
params: params.clone(),
timeout_sec: 7200,
workflow_name: Some("wf_syn2".to_string()),
wave: 0,
tlusty_config: PhaseConfig {
strategies: vec![
"cold_run".to_string(),
"seed_step".to_string(),
"seed_step_stab".to_string(),
],
..PhaseConfig::default_tlusty()
},
..Default::default()
})
.await
.unwrap();
{
let pool = db.pool.clone();
let tid = t1.to_string();
tokio::task::spawn_blocking(move || {
let conn = pool.get().unwrap();
conn.execute(
"UPDATE tasks SET status = 'failed', failed_stage = 'synspec', \
synspec_strategies = '[\"standard\"]', completed_at = datetime('now') \
WHERE task_id = ?1",
params![tid],
)
.unwrap();
})
.await
.unwrap();
}
db.sync_all_running_workflows_completion().await.unwrap();
assert_eq!(
db.get_workflow("wf_syn2").await.unwrap().unwrap().status,
"completed",
"synspec 链已耗尽的失败行,其 tlusty 审计副本不应阻塞完成翻转"
);
}
#[tokio::test]
async fn test_take_pending_node_token_atomic() {
let temp_dir = tempfile::tempdir().unwrap();
@@ -3695,6 +4170,7 @@ mod tests {
temp_check: None,
emflux_check: None,
bfac_check: None,
ladder_seeds: Vec::new(),
note: None,
};
let report1 = TaskReport {
@@ -3761,6 +4237,7 @@ mod tests {
temp_check: None,
emflux_check: None,
bfac_check: None,
ladder_seeds: Vec::new(),
note: None,
};
let report2 = TaskReport {
@@ -3927,6 +4404,7 @@ mod tests {
temp_check: None,
emflux_check: None,
bfac_check: None,
ladder_seeds: Vec::new(),
note: None,
};
let report1 = TaskReport {
@@ -3984,6 +4462,7 @@ mod tests {
temp_check: None,
emflux_check: None,
bfac_check: None,
ladder_seeds: Vec::new(),
note: Some("synspec 失败".into()),
};
let report2 = TaskReport {
@@ -4108,6 +4587,7 @@ mod tests {
temp_check: None,
emflux_check: None,
bfac_check: None,
ladder_seeds: Vec::new(),
note: None,
};
let report1 = TaskReport {
@@ -4183,6 +4663,7 @@ mod tests {
temp_check: None,
emflux_check: None,
bfac_check: None,
ladder_seeds: Vec::new(),
note: None,
};
let report2 = TaskReport {
+56
View File
@@ -196,4 +196,60 @@ impl Database {
Ok(None)
}
}
/// 仅在 exact_family(同 Teff/logg/logHe、仅 CNO 不同)内找种子,供
/// `seed_step_stab` 稳定化策略使用(2026-08-18docs/failed81_cno_seed_popzer_dpsilg_2026_08_18.md)。
///
/// 与 `find_best_seed_from_db` 的区别:不做 global 退化——稳定化配方的验证前提
/// 是种子与目标仅差 CNO 丰度(微扰),跨 Teff/logg 的种子不在其适用域内。
/// `exclude` 排除种子名列表(至少含目标点自身名,防止误把自己旧产物当种子)。
/// 2026-08-20 种子轮换修复:稳定化配方对种子**逐点敏感**——同 CNO 距离的不同
/// 邻居(换 N 还是换 O)收敛性不同。调用方传入本点历史任务已用过的种子名,
/// 使每次 seed_step_stab 重试轮换到下一个未试过的同族邻居,而非重复同一组合。
pub async fn find_exact_family_seed_from_db(
&self,
target: &GridPointParams,
exclude: &[String],
) -> Result<Option<common::seed_finder::SeedMatch>> {
let exact_candidates: Vec<SeedCacheItem> = {
let idx_lock = self.seed_index.read().await;
let keys = SeedBucketKey::from_params(target);
let mut out = Vec::new();
for key in keys {
if let Some(bucket) = idx_lock.get(&key) {
out.extend(bucket.iter().cloned());
}
}
out
};
let mut best: Option<(String, std::path::PathBuf, f64)> = None;
for item in &exact_candidates {
if exclude.iter().any(|ex| &item.point_name == ex) {
continue;
}
// 严格同物理族(2026-08-19 生产修复):必须 Teff/logg/logHe 逐值相同、
// 仅 CNO 不同。不能用 calculate_seed_distance 的 is_exact——其判定
// 容忍 ΔTeff≤5000K(一个网格档),会把 ladder 中间种子(如
// t22500_g6.5_...)当成同族并因 CNO 距离 0 排最前,而稳定化配方
// POPZER+DPSILG)的验证前提是种子与目标仅差丰度微扰、同温同重力。
let strict_same_family = (item.params.teff.value() - target.teff.value()).abs()
< 1e-9
&& (item.params.logg.value() - target.logg.value()).abs() < 1e-9
&& (item.params.loghe.value() - target.loghe.value()).abs() < 1e-9;
if !strict_same_family {
continue;
}
let (_, d) = common::seed_finder::calculate_seed_distance(&item.params, target);
if best.is_none() || d < best.as_ref().unwrap().2 {
let path = std::path::PathBuf::from(&item.file_path);
best = Some((item.point_name.clone(), path, d));
}
}
Ok(best.map(|(name, path, distance)| common::seed_finder::SeedMatch {
name,
path,
distance,
}))
}
}
+38 -2
View File
@@ -292,6 +292,33 @@ impl Database {
/// `strategy` 传 Some("seed_step")/Some("cold_run") 时按当前策略过滤(Phase 6 起派生:
/// 比较 `json_extract(tlusty_strategies, '$[0]')`,不再依赖已删除的 task_type 列),
/// None 不过滤。生产调用仅传 None,Some 分支供测试断言用。
/// 本点历史任务已用过的全部种子名(DISTINCT、非 NULL)。
/// 2026-08-20 种子轮换修复:`seed_step_stab` 重试时排除这些种子,
/// 使 `find_exact_family_seed_from_db` 轮换到未试过的同族 CNO 邻居。
pub async fn list_used_seed_names(
&self,
point_name: &str,
workflow_name: &str,
) -> Result<Vec<String>> {
let pool = self.pool.clone();
let point = point_name.to_string();
let wf = workflow_name.to_string();
tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare(
"SELECT DISTINCT seed_point_name FROM tasks
WHERE point_name = ?1 AND workflow_name = ?2 AND seed_point_name IS NOT NULL",
)?;
let rows = stmt.query_map(params![point, wf], |r| r.get::<_, String>(0))?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
})
.await?
}
pub async fn has_pending_tasks_for_point(
&self,
point_name: &str,
@@ -621,8 +648,14 @@ impl Database {
// 审查修复 #S1workflow 完成 flip 并入同一事务(原实现在 tx.commit() 后单独 UPDATE
// 崩溃窗口期 task 已终态但 workflow 卡 running;且 `let _ =` 丢弃 I/O 错误)。
// 现在事务内更新,与 task/grid_points 结算原子提交,错误正常传播。
//
// 2026-08-22 修复:「无 pending/queued/running」之外还须无「未消费回退链」的
// failed 点(UNCONSUMED_FALLBACK_BLOCKER_SQL)——否则最后一个活跃点 cold_run
// 失败的这份上报会先把 workflow 置 completed,紧随其后的
// trigger_strategy_fallback 被 still_running 守卫拦截,链上后续策略永不派发。
tx.execute(
"UPDATE workflows
&format!(
"UPDATE workflows
SET status = 'completed', updated_at = datetime('now')
WHERE name = ?1
AND status = 'running'
@@ -631,7 +664,10 @@ impl Database {
SELECT 1 FROM grid_points
WHERE workflow_name = workflows.name
AND status IN ('pending', 'queued', 'running')
)",
)
{}",
super::workflows::UNCONSUMED_FALLBACK_BLOCKER_SQL
),
params![wf],
)?;
+88 -6
View File
@@ -2,6 +2,78 @@
//! `impl Database` 的 工作流 域方法。共享基础设施(Database struct、连接管理、类型、helper)见父模块 `super`crate::db)。
use super::*;
/// 工作流完成 flip 的「未消费回退」阻塞子句(两条 flip SQL 共用:
/// 本文件后台对账 + tasks.rs `record_task_report` 事务内 flip)。
///
/// 2026-08-22 修复:最后一批活跃点失败时,flip 只看「无 pending/queued/running」,
/// 而 failed 点的失败上报**先于**策略链回退被处理——workflow 被提前置 completed
/// `trigger_strategy_fallback` 的 still_running 守卫随即拦截,cold_run 之后的
/// seed_step/seed_step_stab 永不派发(生产实证:sdB_cno 仅剩
/// t60000_g5.0_he-4_c-4_n-4_o-1 时 cold 失败即终局)。
///
/// 阻塞语义(对每个 failed 点取最新 failed/timeout 任务行,行选择口径镜像
/// `pop_stage_strategy_for_fallback`ORDER BY created_at DESC, rowid DESC):
///
/// 1. **未消费策略链**:按该行 failed_stage 归因(镜像 pop 的 stage→列映射,
/// NULL 归因 tlusty)取对应策略链,json 长度 >1——弹掉刚失败首项后仍有
/// 顺位。链随每次失败上报严格变短,终会耗尽,不会永久阻塞。
/// 2. **种子轮换待执行**2026-08-22 审查补充):链尾 [`seed_step_stab`] 失败
/// 且仍存在未试过的同物理族干净种子(teff/logg/loghe 逐值相等、is_clean=1、
/// 非自身、不在本点历史 seed_point_name 集合内)——镜像 scheduler.rs
/// rotation 臂(trigger_strategy_fallback 内)的派发条件,否则链尾失败时
/// flip 仍会先于 rotation 吞掉换种重试。轮换每次消耗一个种子,used 集合
/// 单调增长,终会耗尽同族种子,不会永久阻塞。
///
/// JSON 防护:json_array_length/json_extract 对非法 JSON **抛错**而非返回 NULL
/// SQLite 3.45 实测)——若不加防护,一条脏行会让 record_task_report 整个
/// 结算事务失败。列 NOT NULL DEFAULT 且唯一写入方是 serde_json,正常不可达;
/// json_valid 守卫下脏行按长度 0 处理(保守放行翻转)。
pub(crate) const UNCONSUMED_FALLBACK_BLOCKER_SQL: &str = "AND NOT EXISTS (
SELECT 1 FROM grid_points gp
JOIN tasks t
ON t.point_name = gp.name
AND t.workflow_name = gp.workflow_name
AND t.status IN ('failed', 'timeout')
AND t.rowid = (
SELECT t2.rowid FROM tasks t2
WHERE t2.point_name = gp.name
AND t2.workflow_name = gp.workflow_name
AND t2.status IN ('failed', 'timeout')
ORDER BY t2.created_at DESC, t2.rowid DESC LIMIT 1
)
WHERE gp.workflow_name = workflows.name
AND gp.status = 'failed'
AND (
CASE WHEN t.failed_stage = 'synspec'
THEN CASE WHEN json_valid(t.synspec_strategies)
THEN json_array_length(t.synspec_strategies)
ELSE 0 END
ELSE CASE WHEN json_valid(t.tlusty_strategies)
THEN json_array_length(t.tlusty_strategies)
ELSE 0 END
END > 1
OR (
json_valid(t.tlusty_strategies)
AND json_array_length(t.tlusty_strategies) = 1
AND json_extract(t.tlusty_strategies, '$[0]') = 'seed_step_stab'
AND EXISTS (
SELECT 1 FROM seeds s
WHERE s.is_clean = 1
AND s.point_name != gp.name
AND s.teff = gp.teff
AND s.logg = gp.logg
AND s.loghe = gp.loghe
AND s.point_name NOT IN (
SELECT t3.seed_point_name FROM tasks t3
WHERE t3.point_name = gp.name
AND t3.workflow_name = gp.workflow_name
AND t3.seed_point_name IS NOT NULL
)
)
)
)
)";
impl Database {
pub async fn upsert_workflow(
&self,
@@ -47,8 +119,10 @@ impl Database {
// L6 修复:原 `let _ =` 丢弃 r2d2/rusqlite 错误——若该后台对账 UPDATE 失败,
// 工作流可持续卡在 running 无任何提示。现记录错误(仍返回 Ok 不中断主流程,
// 因为每份上报内的 workflow-completion flip 才是主路径,见 record_task_report)。
// 「未消费回退链」子句见 UNCONSUMED_FALLBACK_BLOCKER_SQL2026-08-22 修复)。
if let Err(e) = conn.execute(
"UPDATE workflows
&format!(
"UPDATE workflows
SET status = 'completed', updated_at = datetime('now')
WHERE status = 'running'
AND EXISTS (SELECT 1 FROM grid_points WHERE workflow_name = workflows.name)
@@ -56,7 +130,10 @@ impl Database {
SELECT 1 FROM grid_points
WHERE workflow_name = workflows.name
AND status IN ('pending', 'queued', 'running')
)",
)
{}",
UNCONSUMED_FALLBACK_BLOCKER_SQL
),
[],
) {
tracing::error!("后台对账:同步 running 工作流完成态失败: {}", e);
@@ -90,6 +167,7 @@ impl Database {
}
// 一次 GROUP BY 聚合全部工作流的网格计数并回填(不做逐工作流查询,无 N+1)。
// tlusty 总收敛与 stab 分项为 2026-08-25 补(口径见 WorkflowStats.tlusty_converged)。
let mut agg_stmt = conn.prepare(
"SELECT workflow_name,
COUNT(*) AS total,
@@ -98,6 +176,8 @@ impl Database {
SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running,
SUM(CASE WHEN tlusty_status = 'converged' AND tlusty_success_method = 'cold_run' THEN 1 ELSE 0 END) AS cold,
SUM(CASE WHEN tlusty_status = 'converged' AND tlusty_success_method = 'seed_step' THEN 1 ELSE 0 END) AS seed,
SUM(CASE WHEN tlusty_status = 'converged' THEN 1 ELSE 0 END) AS tlusty_converged,
SUM(CASE WHEN tlusty_status = 'converged' AND tlusty_success_method = 'seed_step_stab' THEN 1 ELSE 0 END) AS stab,
SUM(CASE WHEN tlusty_status = 'failed' THEN 1 ELSE 0 END) AS tlusty_failed,
SUM(CASE WHEN synspec_status = 'converged' THEN 1 ELSE 0 END) AS synspec_converged,
SUM(CASE WHEN synspec_status = 'failed' THEN 1 ELSE 0 END) AS synspec_failed,
@@ -114,10 +194,12 @@ impl Database {
running: r.get::<_, Option<i64>>(4)?.unwrap_or(0),
cold_run_converged: r.get::<_, Option<i64>>(5)?.unwrap_or(0),
seed_step_converged: r.get::<_, Option<i64>>(6)?.unwrap_or(0),
tlusty_failed: r.get::<_, Option<i64>>(7)?.unwrap_or(0),
synspec_converged: r.get::<_, Option<i64>>(8)?.unwrap_or(0),
synspec_failed: r.get::<_, Option<i64>>(9)?.unwrap_or(0),
synspec_pending: r.get::<_, Option<i64>>(10)?.unwrap_or(0),
tlusty_converged: r.get::<_, Option<i64>>(7)?.unwrap_or(0),
seed_step_stab_converged: r.get::<_, Option<i64>>(8)?.unwrap_or(0),
tlusty_failed: r.get::<_, Option<i64>>(9)?.unwrap_or(0),
synspec_converged: r.get::<_, Option<i64>>(10)?.unwrap_or(0),
synspec_failed: r.get::<_, Option<i64>>(11)?.unwrap_or(0),
synspec_pending: r.get::<_, Option<i64>>(12)?.unwrap_or(0),
},
))
})?;
+76
View File
@@ -176,6 +176,32 @@ pub const MIGRATIONS: &[Migration] = &[
detect: |c| Ok(!has_column(c, "grid_points", "success_method")?),
up: &["ALTER TABLE grid_points DROP COLUMN success_method"],
},
// M142026-08-25,大气收敛统计补漏):Phase 5b 阶段列(tlusty_status)上线前完成的历史
// 点该列为 NULL——但 tlusty_success_method 仅在整管线成功(tlusty_enabled=1 且收敛)时
// 写入(record_task_report 成功分支),故 completed + method 非空 ⟹ 大气必已收敛。
// 回填 'converged',否则 stats 的 CASE WHEN tlusty_status='converged' 漏计
// (生产实证:sdB_cno 9216 点中 9 个此形态,前端 9137/9216 差额的一部分)。
Migration {
version: 14,
name: "backfill-tlusty-status-for-completed",
detect: |c| {
let n: i64 = c.query_row(
"SELECT COUNT(*) FROM grid_points
WHERE status = 'completed'
AND COALESCE(tlusty_success_method, '') != ''
AND COALESCE(tlusty_status, '') = ''",
[],
|r| r.get(0),
)?;
Ok(n == 0)
},
up: &[
"UPDATE grid_points SET tlusty_status = 'converged' \
WHERE status = 'completed' \
AND COALESCE(tlusty_success_method, '') != '' \
AND COALESCE(tlusty_status, '') = ''",
],
},
];
/// 当前 schema 版本(`PRAGMA user_version`)。
@@ -562,4 +588,54 @@ mod tests {
);
assert_eq!(current_version(&conn).unwrap(), 13);
}
/// M142026-08-25):completed + tlusty_success_method 非空 + tlusty_status 空
/// 的历史点回填 'converged'method 仅在整管线成功时写入);其它形态不动。
#[test]
fn m14_backfills_tlusty_status_for_legacy_completed() {
let mut conn = mem_conn();
conn.execute_batch(
"CREATE TABLE grid_points (
name TEXT NOT NULL,
workflow_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
tlusty_success_method TEXT,
synspec_success_method TEXT,
tlusty_status TEXT,
synspec_status TEXT,
pending_strategies TEXT
);
INSERT INTO grid_points (name, workflow_name, status, tlusty_success_method, tlusty_status) VALUES
('legacy_ok', 'wf', 'completed', 'cold_run', NULL),
('legacy_seed', 'wf', 'completed', 'seed_step', ''),
('already_failed', 'wf', 'failed', 'cold_run', 'failed'),
('already_conv', 'wf', 'completed', 'cold_run', 'converged'),
('no_method', 'wf', 'completed', NULL, NULL);",
)
.unwrap();
// 仅跑 M14(全量 MIGRATIONS 含 M1 的 tasks 迁移,本夹具只建 grid_points)。
let m14 = MIGRATIONS.iter().find(|m| m.version == 14).unwrap();
apply_migrations_with(&mut conn, &[*m14]).unwrap();
let read = |c: &Connection, name: &str| -> Option<String> {
c.query_row(
"SELECT tlusty_status FROM grid_points WHERE name = ?1",
rusqlite::params![name],
|r| r.get(0),
)
.unwrap()
};
assert_eq!(read(&conn, "legacy_ok").as_deref(), Some("converged"));
assert_eq!(read(&conn, "legacy_seed").as_deref(), Some("converged"));
// failed 点 / 已有状态 / 无 method 的点不受影响。
assert_eq!(read(&conn, "already_failed").as_deref(), Some("failed"));
assert_eq!(read(&conn, "already_conv").as_deref(), Some("converged"));
assert_eq!(read(&conn, "no_method"), None);
assert_eq!(current_version(&conn).unwrap(), 14);
// 幂等:再跑一遍 detect 命中(无待回填行)→ 仅推进版本,不改数据。
apply_migrations_with(&mut conn, &[*m14]).unwrap();
assert_eq!(read(&conn, "no_method"), None);
}
}
+112 -9
View File
@@ -360,6 +360,7 @@ impl GridScheduler {
/// 「弹出再压回」保持原序而陷入死循环。
async fn resolve_dispatchable_chain(
db: &Database,
workflow_name: &str,
params: &GridPointParams,
mut chain: Vec<String>,
) -> Result<(Vec<String>, Option<String>)> {
@@ -394,6 +395,36 @@ impl GridScheduler {
}
}
}
// 稳定化种子步进(2026-08-18docs/failed81_cno_seed_popzer_dpsilg_2026_08_18.md):
// 仅在 exact_family(同 Teff/logg/logHe、仅 CNO 不同)内找种子——稳定化配方
// POPZER+DPSILG)的验证前提是种子与目标仅差丰度微扰,跨 Teff/logg 种子
// 不在适用域。排除目标点自身名(防止把自己旧产物当种子)。
// 2026-08-20 种子轮换:配方对种子逐点敏感(同距离换 N/换 O 邻居收敛性不同),
// 排除本点历史任务已用过的种子,重试时轮换到下一个未试过的同族邻居。
Some("seed_step_stab") => {
let mut exclude = vec![params.model_name()];
exclude.extend(db.list_used_seed_names(&params.model_name(), workflow_name).await?);
let found_seed = db
.find_exact_family_seed_from_db(params, &exclude)
.await?;
match found_seed {
Some(seed) => {
if !skipped.is_empty() {
chain.extend(skipped);
}
return Ok((chain, Some(seed.name.clone())));
}
None if chain.len() > 1 => {
let head = chain.remove(0);
skipped.push(head);
info!("策略解析:seed_step_stab 无同物理族 CNO 邻居种子,暂存顺位");
}
None => {
chain.clear();
return Ok((chain, None));
}
}
}
// 空链或非 seed_step 顺位:可直接派发。先前跳过的 seed_step 追加到链尾,
// 保留为后续回退顺位(空链由调用方判空处理)。
_ => {
@@ -561,7 +592,7 @@ impl GridScheduler {
};
let (dispatch_chain, seed_point_name) = if tlusty_cfg.enabled {
let (chain, seed) =
Self::resolve_dispatchable_chain(&self.db, &params, base_chain).await?;
Self::resolve_dispatchable_chain(&self.db, workflow_name, &params, base_chain).await?;
if chain.is_empty() {
// enabled 阶段无可派发顺位(基础链全为无种子的 seed_step)→ 打回 pending
// 待近邻种子出现后由下轮调度自愈派发(不是终态,不能被卡死在 queued)。
@@ -896,13 +927,6 @@ impl GridScheduler {
popped,
policy: row_policy,
} = snap;
if rest_strategies.is_empty() {
info!(
"策略回退:网格点 {} 的 {} 策略链已耗尽(弹出最后项 {}),保持 failed 终态",
name, stage_label, popped
);
return Ok(false);
}
// 2026-08-04 语义修正:策略链回退**只由策略链驱动**(弹出失败首项、派发下一顺位,
// 链耗尽保持 failed),不再受执行策略门控——策略只决定启动工作流时对历史终态点的
@@ -929,6 +953,83 @@ impl GridScheduler {
.await
.unwrap_or((None, None, None, None, None, None, None, None));
// 2026-08-20 种子轮换:seed_step_stab 常为链上最后一项,失败弹出后链空。
// 稳定化配方对种子逐点敏感(同 CNO 距离、换不同元素方向的邻居收敛性不同,
// 见 docs/failed81_cno_seed_popzer_dpsilg_2026_08_18.md §九)——首个选中
// 的邻居失败不代表配方无效。排除本点历史已用种子后若还有未试过的同族
// 邻居,则以 seed_step_stab 单顺位重链重派。轮换次数天然受同族邻居数约束:
// 全部试过后此处不再命中,落入下方 failed 终态,无死循环。
if rest_strategies.is_empty() && failed_stage != "synspec" && popped == "seed_step_stab" {
let mut exclude = vec![params.model_name()];
exclude.extend(self.db.list_used_seed_names(name, workflow_name).await?);
if let Some(fresh_seed) = self
.db
.find_exact_family_seed_from_db(params, &exclude)
.await?
{
info!(
"策略回退:工作流 {} 网格点 {} seed_step_stab 失败但存在未试过的同族邻居种子 {},轮换种子重派(已试过 {:?})",
workflow_name, name, fresh_seed.name, &exclude[1..]
);
// 轮换任务显式注入新种子(绕过 resolve 的种子查找,避免其再次
// 命中同一邻居)。
let mut stab_cfg = tlusty_cfg.clone();
stab_cfg.strategies = vec!["seed_step_stab".to_string()];
stab_cfg.policy = row_policy.clone();
let task_spec = TaskSpec {
task_id: Uuid::new_v4(),
point_name: name.to_string(),
params: params.clone(),
seed_point_name: Some(fresh_seed.name),
timeout_sec,
workflow_name: Some(workflow_name.to_string()),
wave: 0,
tlusty_config: stab_cfg,
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,
linelist: linelist.clone(),
};
self.db.insert_task(&task_spec).await?;
self.db
.update_grid_status(name, common::models::GridPointStatus::Queued, workflow_name)
.await?;
if let Err(e) = self.queue.push_task(&task_spec).await {
let _ = self
.db
.update_grid_status(
name,
common::models::GridPointStatus::Pending,
workflow_name,
)
.await;
let _ = self.queue.remove_task(&task_spec.task_id.to_string()).await;
let _ = self.db.delete_task(&task_spec.task_id).await;
return Err(e);
}
return Ok(true);
}
}
if rest_strategies.is_empty() {
info!(
"策略回退:网格点 {} 的 {} 策略链已耗尽(弹出最后项 {}),保持 failed 终态",
name, stage_label, popped
);
return Ok(false);
}
// SYNSPEC 链回退:重试光谱合成。无邻居种子门控(大气来自目标点自身既有产物,
// 见 docs/task_engine_decoupling_design.md §5)——旧实现把 synspec 失败误归因到
// TLUSTY 链并做无意义的种子搜索(修复审查 #2)。
@@ -1011,7 +1112,7 @@ impl GridScheduler {
// pending_restresolve 前的基础剩余链(含全部 seed_step 顺位),H1 空链分支据此记录标记。
let pending_rest = rest_strategies.clone();
let (rest_strategies, seed_point_name) =
Self::resolve_dispatchable_chain(&self.db, params, rest_strategies).await?;
Self::resolve_dispatchable_chain(&self.db, workflow_name, params, rest_strategies).await?;
if rest_strategies.is_empty() {
// H1 修复:resolve_dispatchable_chain 仅在「剩余顺位全为无近邻种子的 seed_step」
// 时返回空(调用方已在上方 line 790 排除了真耗尽)。此时**不能**保持 failed 终态:
@@ -2909,6 +3010,7 @@ tlusty_stage:\n enabled: true\n policy: skip_converged\n strategies: [seed_st
// 无种子 → 重复 seed_step 链应判空(不派发、不死循环)。
let (chain, seed) = GridScheduler::resolve_dispatchable_chain(
&db,
"wf",
&params,
vec!["seed_step".to_string(), "seed_step".to_string()],
)
@@ -2921,6 +3023,7 @@ tlusty_stage:\n enabled: true\n policy: skip_converged\n strategies: [seed_st
// 两个无种子 seed_step 保留为回退顺位且链首可派发。
let (chain2, seed2) = GridScheduler::resolve_dispatchable_chain(
&db,
"wf",
&params,
vec![
"seed_step".to_string(),
+2
View File
@@ -28,6 +28,7 @@ async fn mark_imported(db: &Database, name: &str, wf: &str, params: &GridPointPa
temp_check: None,
emflux_check: None,
bfac_check: None,
ladder_seeds: Vec::new(),
note: None,
};
db.upsert_point_summary(name, wf, &summary, method)
@@ -1866,6 +1867,7 @@ async fn dispatch_and_report(
temp_check: None,
emflux_check: None,
bfac_check: None,
ladder_seeds: Vec::new(),
note: None,
};
let report = common::models::TaskReport {
@@ -41,6 +41,7 @@ fn make_converged_summary(name: &str, params: &GridPointParams) -> ModelSummary
temp_check: None,
emflux_check: None,
bfac_check: None,
ladder_seeds: Vec::new(),
note: None,
}
}