/// FIST-Mbt engine: DAG 扩展模块。
/// 提供:critical_path(最长依赖链)、parallelism(当前可并行度)、dag_ascii(ASCII 依赖图)。
/// 仅依赖既有 FistEngine 能力(list_all / get_task / deps_satisfied),不改原有行为。
/// ==================== 内部工具 ====================
///|
/// 生成 n 层两空格缩进
fn dag_indent(n : Int32) -> String {
let mut s = ""
for i = 0; i < n; i = i + 1 {
s = s + " "
}
s
}
///|
/// 用逗号拼接依赖 id 列表
fn dag_join_deps(deps : Array[String]) -> String {
let mut s = ""
for i = 0; i < deps.length(); i = i + 1 {
if i > 0 {
s = s + ","
}
s = s + deps[i]
}
s
}
/// ==================== 关键路径 ====================
///|
/// 求以 id 结尾的最长依赖链(沿 depends_on 边回溯;memo 化 DFS,含环保护)。
/// 返回序列按依赖先后排列(最底层依赖在前,id 自身在后)。
/// **BUG-215**:环保护从「局部重入检查」升级为「环成员标记」——返回 `None` 表示
/// 「本路径已成环」,调用方(critical_path)不得把环成员计入关键路径。
/// 旧实现返回 `[]`,与「无依赖(合法空链)」不可区分,导致 X→Y→X 环上
/// `critical_path` 返回 `[Y, X]`(实跑验证)。现在环成员一律 `None`。
fn FistEngine::longest_chain_ending(
self : FistEngine,
id : String,
memo : Map[String, Array[String]?],
visiting : Map[String, Bool],
) -> Array[String]? {
match memo.get(id) {
Some(c) => return c
None => ()
}
// 环保护:当前 DFS 路径上已出现过则返回 None(不再沿环回溯,并把环成员标记为 None)
match visiting.get(id) {
Some(_) => {
memo.set(id, None)
return None
}
None => ()
}
visiting.set(id, true)
let deps = match self.get_task(id) {
Some(t) => t.depends_on
None => []
}
let mut best : Array[String] = []
let mut in_cycle = false
for d in deps {
match self.longest_chain_ending(d, memo, visiting) {
Some(chain) => if chain.length() > best.length() { best = chain }
None => in_cycle = true
}
}
visiting.remove(id)
if in_cycle {
// 有后继返回 None(环成员),本节点也是环成员——向上传播
memo.set(id, None)
return None
}
let out = best.copy()
out.push(id)
memo.set(id, Some(out))
Some(out)
}
///|
/// 关键路径:全图中按 depends_on 建图后的最长依赖链(任务 id 序列)。
/// 多条等长链取先遇到的一条;空仓库返回空数组。
/// **BUG-215**:环成员被 `longest_chain_ending` 标记为 None,此处不再计入。
pub fn FistEngine::critical_path(self : FistEngine) -> Array[String] {
let memo : Map[String, Array[String]?] = Map([])
let visiting : Map[String, Bool] = Map([])
let mut best : Array[String] = []
for t in self.list_all() {
match self.longest_chain_ending(t.id, memo, visiting) {
Some(chain) => if chain.length() > best.length() { best = chain }
None => ()
}
}
best
}
/// ==================== 并行度 ====================
///|
/// 当前可并行执行的任务数:状态为待领取且依赖已满足的任务数。
pub fn FistEngine::parallelism(self : FistEngine) -> Int32 {
// R12-R7 性能:与 ready_tasks 同款——deps_satisfied 逐条 DB 读,
// 这里一次遍历建 completed 映射,O(1) 判依赖。
let tasks = self.list_all()
let completed : Map[String, Bool] = Map([])
for t in tasks {
completed.set(t.get_id(), t.get_status().is_completed())
}
let mut n = 0
for t in tasks {
if t.get_status().is_pending() && deps_ready_memo(t.depends_on, completed) {
n = n + 1
}
}
n
}
/// ==================== ASCII 依赖图 ====================
///|
/// 计算任务在 DAG 中的层级(缩进深度):
/// 取「parent_id 父链深度」与「depends_on 依赖链深度」的较大者。
/// 父/依赖不在本命名空间内则不计入;环保护下返回 0。
/// **BUG-215**:环保护从「重入点返回 0」升级为「环成员一律 -1 哨兵」——
/// 让调用方(dag_ascii)能区分「无依赖(0)」与「成环(-1)」,并在渲染面把 -1 收敛为 0。
/// 旧实现只在重入点返回 0,触发重入的外层节点仍累加 +1,X→Y→X 环被渲染成
/// X 深 2、Y 深 1(顺序依赖)。现在环成员统一 -1,渲染面钳到 0(顶层)。
fn FistEngine::dag_level(
self : FistEngine,
id : String,
members : Map[String, Bool],
memo : Map[String, Int32],
visiting : Map[String, Bool],
) -> Int32 {
match memo.get(id) {
Some(l) => return l
None => ()
}
match visiting.get(id) {
Some(_) => {
// 环保护:重入点返回 -1 哨兵并 memoize(标记该节点为环成员)
memo.set(id, -1)
return -1
}
None => ()
}
visiting.set(id, true)
let mut level = 0
let mut in_cycle = false
match self.get_task(id) {
Some(t) => {
// 子任务按 parent_id 归组:父任务存在则至少缩进一层
match t.parent_id {
Some(p) =>
if members.contains(p) && p != id {
let pl = self.dag_level(p, members, memo, visiting)
if pl < 0 {
in_cycle = true
} else if pl + 1 > level {
level = pl + 1
}
}
None => ()
}
// depends_on 同样加深层级(依赖必须先完成,视觉上位于上层)
for d in t.depends_on {
if d != id && members.contains(d) {
let dl = self.dag_level(d, members, memo, visiting)
if dl < 0 {
in_cycle = true
} else if dl + 1 > level {
level = dl + 1
}
}
}
}
None => ()
}
visiting.remove(id)
let final_level = if in_cycle { -1 } else { level }
memo.set(id, final_level)
final_level
}
///|
/// ASCII 树/缩进图:展示某命名空间下的任务依赖结构。
/// 层级由 parent_id(子任务归组)与 depends_on(依赖深度)共同决定,
/// 同层任务按 id 字典序稳定输出;跨命名空间的父/依赖不计入缩进。
/// 每行格式:<缩进> [<状态>](有依赖时追加 " (依赖: a,b)"),行尾换行。
/// **BUG-215**:`dag_level` 对环成员返回 -1 哨兵,此处钳到 0(顶层),
/// 让环成员不被渲染成「看似有合法深度」的中间层。
pub fn FistEngine::dag_ascii(self : FistEngine, ns~ : String) -> String {
let target = if ns == "" { "default" } else { ns }
let tasks = self.store.list_tasks_in(target)
let members : Map[String, Bool] = Map([])
for t in tasks {
members.set(t.id, true)
}
let memo : Map[String, Int32] = Map([])
let visiting : Map[String, Bool] = Map([])
let levels : Map[String, Int32] = Map([])
for t in tasks {
levels.set(t.id, self.dag_level(t.id, members, memo, visiting))
}
let sorted = tasks.copy()
sorted.sort_by(fn(a, b) {
let la = match levels.get(a.id) {
Some(l) => if l < 0 { 0 } else { l }
None => 0
}
let lb = match levels.get(b.id) {
Some(l) => if l < 0 { 0 } else { l }
None => 0
}
if la != lb {
la - lb
} else {
a.id.compare(b.id)
}
})
let mut out = ""
for t in sorted {
let lvl = match levels.get(t.id) {
Some(l) => if l < 0 { 0 } else { l }
None => 0
}
let mut line = dag_indent(lvl) + t.id + " [" + t.status_to_string() + "]"
if t.depends_on.length() > 0 {
line = line + " (依赖: " + dag_join_deps(t.depends_on) + ")"
}
out = out + line + "\n"
}
out
}
///|
/// BUG-213:从 `from` 出发沿 depends_on 链 BFS,判断 `target` 是否可达。
/// 用于 dag_depend 建边前的传递环检测:若 `dep_id` 已(传递)依赖 `task_id`,
/// 再给 `task_id` 加一条 `dep_id` 依赖就成环。
/// 纯读 store;已访问集合用 Map[String,Bool] 兜底(不做 Set 依赖)。
fn FistEngine::dag_reaches(
self : FistEngine,
from : String,
target : String,
) -> Bool {
if from == target {
return true
}
let visited : Map[String, Bool] = Map([])
let mut queue : Array[String] = [from]
while queue.length() > 0 {
let cur = queue[0]
let rest : Array[String] = []
for i = 1; i < queue.length(); i = i + 1 {
rest.push(queue[i])
}
queue = rest
// BUG-253: visited 不变量——纯本函数写入(仅 visited.set(cur, true)),
// 单线程模型下无并发污染;首次访问时 get 返回 None → unwrap_or(false) 语义安全
// (对应昨日 unsafe-scan P3 建议:保留现状,注释说明不变量)。
if visited.get(cur).unwrap_or(false) {
continue
}
visited.set(cur, true)
match self.store.get_task(cur) {
Some(t) =>
for d in t.depends_on {
if d == target {
return true
}
if !visited.get(d).unwrap_or(false) {
queue = queue + [d]
}
}
None => ()
}
}
false
}
///|
/// 显式给任务追加一条前置依赖(构建 DAG 依赖边,不只靠 plan_deep 隐式父子)。
/// 校验:task 与 dep 均存在、非同一任务、且不会形成直接自环;去重追加并落库。
/// 返回追加后的任务 JSON。失败返回 Err(含人语原因)。
pub fn FistEngine::dag_depend(
self : FistEngine,
task_id~ : String,
dep_id~ : String,
now~ : String,
) -> Result[@core.Task, String] {
let task = match self.store.get_task(task_id) {
None => return Err("task not found: \{task_id}")
Some(t) => t
}
if dep_id == task_id {
return Err("不能依赖自身: \{task_id}")
}
let _dep = match self.store.get_task(dep_id) {
None => return Err("依赖任务 \{dep_id} 不存在")
Some(d) => d
}
// BUG-213:传递环检测——若 task_id 是 dep_id 的(传递)依赖,加这条边即成环
if self.dag_reaches(dep_id, task_id) {
return Err(
"拒绝成环依赖: \{task_id} 依赖 \{dep_id} 会形成传递环(\{dep_id} 已传递依赖 \{task_id})",
)
}
// 已存在则幂等返回现任务
if task.depends_on.contains(dep_id) {
return Ok(task)
}
let c = task.with_depends_on(dep_id, now)
match self.store.update_task(c) {
Ok(_) => Ok(c)
Err(e) => Err("依赖落库失败: \{e}")
}
}
/// ==================== 瓶颈与松弛分析(PERT/CPM,R71) ====================
///|
/// 拓扑序(依赖先于被依赖;带环保护——成环任务归入 cycle,不阻塞计算)。
fn FistEngine::dag_topo_order(
self : FistEngine,
) -> (Array[String], Array[String]) {
let ids : Array[String] = []
// R12-R9 性能:旧代码把 `list_all()` 调了两次(一次取 ids、一次建边),
// SqliteStore 下每次都是全库读。改一次遍历:把 Task 暂存,两次循环共用。
let tasks_all = self.list_all()
for t in tasks_all {
ids.push(t.get_id())
}
let indeg : Map[String, Int32] = Map([])
let deps_of : Map[String, Array[String]] = Map([])
for id in ids {
indeg.set(id, 0)
deps_of.set(id, [])
}
// 建前置→后继边:任务 t 依赖 d(d 是 t 的前置)→ 边 d→t;indeg[t]++(t 的前置数)
for t in tasks_all {
for d in t.depends_on {
deps_of.set(d, deps_of.get(d).unwrap_or([]) + [t.get_id()])
let cur = indeg.get(t.get_id()).unwrap_or(0)
indeg.set(t.get_id(), cur + 1)
}
}
let order : Array[String] = []
let mut ready_ids : Array[String] = ids.filter(fn(id) {
indeg.get(id).unwrap_or(0) == 0
})
while ready_ids.length() > 0 {
let cur = ready_ids[0]
// 移除队首(保持顺序)
let rest : Array[String] = []
for i = 1; i < ready_ids.length(); i = i + 1 {
rest.push(ready_ids[i])
}
ready_ids = rest
order.push(cur)
// cur 的后继(以 cur 为前置的任务):前置完成 -> 后继入度-1
// BUG-253:Kahn invariant —— deps_of/indeg 已对全部 ids 显式初始化(见上方 L334-337),
// 循环内 get 不应为 None;旧 unwrap_or 默认值会静默吞掉漏初始化的后继导致拓扑序不完整,
// 改为显式 crash 暴露 invariant 违背(对应昨夜 unsafe-scan P2 建议)。
let cur_deps = match deps_of.get(cur) {
Some(v) => v
None => abort("Kahn: 未初始化的后继 \{cur}")
}
for nxt in cur_deps {
let nd = match indeg.get(nxt) {
Some(v) => v
None => abort("Kahn: 未初始化的入度 \{nxt}")
}
indeg.set(nxt, nd - 1)
if nd == 1 {
ready_ids = ready_ids + [nxt]
}
}
}
let cycle = ids.filter(fn(id) { indeg.get(id).unwrap_or(0) > 0 })
(order, cycle)
}
///|
/// 瓶颈与松弛分析(PERT/CPM 硬核调度能力,R71):对每个任务计算
/// earliest(依赖全部就绪的最早开始步数)、latest(不拖慢总工期的最晚开始步数)、
/// slack(latest-earliest;0=关键路径上,>0=可灵活并行安排)。
/// 返回 { critical:[关键路径任务id], slack_map:{id:{earliest,latest,slack}}, makespan, cycle:[成环任务] }。
/// ——"找出谁在关键路径上、谁有松弛可并行",直接支撑排程优化(不是视图叠加)。
pub fn FistEngine::dag_slack_analysis(self : FistEngine) -> Json {
let (order, cycle) = self.dag_topo_order()
let ids : Array[String] = []
// R12-R9 性能:与 dag_topo_order 同款——旧代码把 list_all() 调两次
//(一次取 ids、一次建 reverse 边)。一次读、两循环共用。
let tasks_all = self.list_all()
for t in tasks_all {
ids.push(t.get_id())
}
// earliest: 前向——依赖全就绪的最早开始
let earliest : Map[String, Int32] = Map([])
for id in ids {
earliest.set(id, 0)
}
for id in order {
match self.get_task(id) {
Some(t) =>
for d in t.depends_on {
let de = earliest.get(d).unwrap_or(0)
let ce = earliest.get(id).unwrap_or(0)
if de + 1 > ce {
earliest.set(id, de + 1)
}
}
None => ()
}
}
// makespan: 全图最长 earliest(总工期)
let mut makespan = 0
for id in ids {
let e = earliest.get(id).unwrap_or(0)
if e > makespan {
makespan = e
}
}
// latest: 反向——不拖慢总工期的最晚开始
let latest : Map[String, Int32] = Map([])
for id in ids {
latest.set(id, makespan)
}
let rev : Map[String, Array[String]] = Map([])
for id in ids {
rev.set(id, [])
}
for t in tasks_all {
for d in t.depends_on {
rev.set(d, rev.get(d).unwrap_or([]) + [t.get_id()])
}
}
for i = order.length() - 1; i >= 0; i = i - 1 {
let id = order[i]
let mut lt = makespan
for nxt in rev.get(id).unwrap_or([]) {
let ln = latest.get(nxt).unwrap_or(0)
if ln - 1 < lt {
lt = ln - 1
}
}
if lt < 0 {
lt = 0
}
latest.set(id, lt)
}
// slack = latest - earliest;0 → 关键路径
let slack_map : Map[String, Json] = Map([])
let critical : Array[String] = []
for id in ids {
let e = earliest.get(id).unwrap_or(0)
let l = latest.get(id).unwrap_or(0)
let slack = l - e
if slack == 0 {
critical.push(id)
}
slack_map.set(
id,
Json::object({
"earliest": Json::number(e.to_double()),
"latest": Json::number(l.to_double()),
"slack": Json::number(slack.to_double()),
}),
)
}
critical.sort()
Json::object({
"makespan": Json::number(makespan.to_double()),
"critical": Json::array(critical.map(fn(s) { Json::string(s) })),
"slack_map": Json::object(slack_map),
"cycle": Json::array(cycle.map(fn(s) { Json::string(s) })),
// R12-R10 性能:把 topo order 一并回传,省掉 `dag_cost_route` 里的
// 第二次 `dag_topo_order()`(那又是一次全库 read + Kahn 遍历)。
"order": Json::array(order.map(fn(s) { Json::string(s) })),
})
}
/// ==================== 排程视图(R74,基于 slack 落成可执行排程) ====================
///|
/// 排程视图(R75 增强):在 dag_slack_analysis 基础上把任务落成可执行排程——
/// 按 slack 分两层批:critical 批(slack=0,关键路径瓶颈,须串行盯紧)与
/// flexible 批(slack>0,可按最早开始排序并行的弹性任务),每批附 assignee 当前占用,
/// 并对 flexible 批中未认领任务给出「负载感知建议派单」(suggest:活跃负载最低的已注册执行者)。
/// 复用 dag_slack_analysis(不重复算图)。——"谁在瓶颈、谁可并行派单"一目了然,排程优化闭环。
pub fn FistEngine::dag_schedule(self : FistEngine) -> Json {
let slack = self.dag_slack_analysis()
let ids : Array[String] = []
// R12-R9 性能:同款双 list_all()——一次取 ids、一次统计负载。一次读、两循环共用。
let tasks_all = self.list_all()
for t in tasks_all {
ids.push(t.get_id())
}
// 负载感知:统计各已注册执行者名下活跃(未完成未归档)任务数
let reg_names : Array[String] = []
for (name, _abl, _created) in self.executor_list() {
reg_names.push(name)
}
let load : Map[String, Int32] = Map([])
for n in reg_names {
load.set(n, 0)
}
for t in tasks_all {
match t.assignee {
Some(a) =>
if load.contains(a) {
let done = t.get_status().is_completed() ||
t.get_status().to_string() == "已归档"
if !done {
load.set(a, load.get(a).unwrap_or(0) + 1)
}
}
None => ()
}
}
// 从 slack.slack_map 取每个任务的 slack 与 earliest
let critical_batch : Array[Json] = []
let flexible_batch : Array[Json] = []
for id in ids {
let mut sl = -1
let mut ea = -1
match jget(slack, "slack_map") {
Some(Object(sm)) =>
match sm.get(id) {
Some(Object(o)) => {
sl = match o.get("slack") {
Some(Number(n, ..)) => n.to_int()
_ => -1
}
ea = match o.get("earliest") {
Some(Number(n, ..)) => n.to_int()
_ => -1
}
}
_ => ()
}
_ => ()
}
let assignee = match self.get_task(id) {
Some(t) =>
match t.assignee {
Some(a) => a
None => ""
}
None => ""
}
let row = Json::object({
"task_id": Json::string(id),
"earliest": Json::number(ea.to_double()),
"slack": Json::number(sl.to_double()),
"assignee": Json::string(assignee),
})
if sl == 0 {
critical_batch.push(row)
} else if sl > 0 {
flexible_batch.push(row)
}
}
// 弹性批按最早开始排序(可并行窗口)
flexible_batch.sort_by(fn(a : Json, b : Json) -> Int32 {
let ae = match jget(a, "earliest") {
Some(Number(n, ..)) => n
_ => 0.0
}
let be = match jget(b, "earliest") {
Some(Number(n, ..)) => n
_ => 0.0
}
if ae < be {
-1
} else if ae > be {
1
} else {
0
}
})
// R75 负载感知:flexible 批中未认领任务建议派给活跃负载最低的已注册执行者
let flexible_adv : Array[Json] = flexible_batch.map(fn(row) {
let asg = match jget(row, "assignee") {
Some(Json::String(s)) => s
_ => ""
}
if asg != "" {
return row
}
// 已注册执行者中选负载最低者(无注册则不建议)
let mut best = ""
let mut best_load = 2147483647
for n in reg_names {
let l = load.get(n).unwrap_or(0)
if l < best_load {
best_load = l
best = n
}
}
let m = match row {
Object(o) => o
_ => Map([])
}
m.set("suggest", Json::string(if best != "" { best } else { "" }))
Json::object(m)
})
Json::object({
"basis": Json::string("PERT/CPM slack(基于 dag_slack_analysis)"),
"critical_batch": Json::array(critical_batch),
"flexible_batch": Json::array(flexible_adv),
"note": Json::string(
"critical_batch=关键路径瓶颈(须串行盯紧);flexible_batch=有松弛可按最早开始并行的任务,未认领项附 suggest=活跃负载最低的已注册执行者(R75 负载感知)",
),
})
}
/// ==================== 成本路由(R77,STAR 式依赖图成本路由) ====================
///|
/// 依赖图成本路由(R77,STAR 蒸馏简化版):在 dag_schedule 排程视图之上补「成本」维度——
/// 对 flexible(slack>0)批中未认领任务,按拓扑序贪心给出建议执行者:在「能力覆盖的
/// 已注册执行者」里选 执行成本 + 切换成本 之和最小者。
/// - 执行成本:难度档映射(易/中/难 → 1/2/3,复用 extract_difficulty 单一来源);
/// - 能力约束:任务所需能力 = 描述命中的最长已注册能力标签(复用 auto_want),
/// 未命中标签的执行者被排除(want 空则全命中);
/// - 切换成本:任务依赖链上已路由执行者若与候选不同则 +1(STAR 的上下文切换税);
/// - 成本相等时取活跃负载最低者(与 R75 一致)。
/// 纯计算、无副作用(只给建议不 claim)。——排程优化闭环最终形态:
/// critical_path→slack→schedule→suggest→cost_route。
pub fn FistEngine::dag_cost_route(self : FistEngine) -> Json {
let slack = self.dag_slack_analysis()
// R12-R10 性能:`dag_slack_analysis` 现已在返回里带上 topo order(见 slack_analysis 结果),
// 直接复用,省掉一次 `dag_topo_order()`(那又是一次全库 read + Kahn 遍历)。
let order = match jget(slack, "order") {
Some(Json::Array(arr)) =>
arr.filter_map(fn(v) {
match v {
Json::String(s) => Some(s)
_ => None
}
})
_ => {
let (o, _c) = self.dag_topo_order()
o
}
}
// 执行者注册表:候选顺序按注册顺序(executor_list 稳定)
let reg_names : Array[String] = []
let reg_abl : Map[String, Array[String]] = Map([])
for (name, abl, _created) in self.executor_list() {
reg_names.push(name)
reg_abl.set(name, parse_abilities(abl))
}
let load = self.executor_loads(reg_names)
// 已路由执行者记录(切换成本基准):已认领任务先登记为固定占用
let routed : Map[String, String] = Map([])
for t in self.list_all() {
match t.assignee {
Some(a) => routed.set(t.get_id(), a)
None => ()
}
}
let rows : Array[Json] = []
let mut total_cost = 0
for id in order {
// 只处理 slack>0(flexible)且未认领的任务
let mut sl = -1
match jget(slack, "slack_map") {
Some(Object(sm)) =>
match sm.get(id) {
Some(Object(o)) =>
match o.get("slack") {
Some(Number(n, ..)) => sl = n.to_int()
_ => ()
}
_ => ()
}
_ => ()
}
if sl <= 0 {
continue
}
if routed.contains(id) {
continue
}
let desc = match self.get_task(id) {
Some(t) => t.description
None => ""
}
if desc == "" {
continue
}
// 任务所需能力:描述命中的最长已注册能力标签(无注册标签则空 = 全命中)
let want = self.auto_want(desc)
// 难度 → 执行成本(易/中/难 → 1/2/3,无难度标记按 1)
let lab = triage_label_of(desc)
let est = if lab == "难" { 3 } else if lab == "中" { 2 } else { 1 }
// 依赖任务的执行者(切换成本基准)
let deps_asg : Array[String] = []
match self.get_task(id) {
Some(t) =>
for d in t.depends_on {
match routed.get(d) {
Some(a) => deps_asg.push(a)
None => ()
}
}
None => ()
}
// 候选执行者:能力覆盖 want(want 空 → 全部);选 执行成本+切换成本 最小,相等取负载最低
let mut best = ""
let mut best_total = 2147483647
let mut best_switch = 0
for n in reg_names {
let abl = reg_abl.get(n).unwrap_or([])
if want != "" && !abl.contains(want) {
continue
}
let sw = if deps_asg.length() > 0 && !deps_asg.contains(n) {
1
} else {
0
}
let ttl = est + sw
if ttl < best_total {
best_total = ttl
best = n
best_switch = sw
} else if ttl == best_total {
let bl = load.get(best).unwrap_or(0)
let nl = load.get(n).unwrap_or(0)
if nl < bl {
best = n
best_switch = sw
}
}
}
if best == "" {
continue
}
routed.set(id, best)
// 负载感知(BUG-336):把本次分配计入累计负载,后续等成本平局任务才会转向其他执行者,
// 否则 load 只在循环前一次性快照(L657),整轮路由全堆给先注册执行者。
let prev_load = match load.get(best) {
Some(n) => n
None => 0
}
ignore(load.set(best, prev_load + 1))
total_cost = total_cost + best_total
rows.push(
Json::object({
"task_id": Json::string(id),
"assignee": Json::string(best),
"est_cost": Json::number(est.to_double()),
"switch_cost": Json::number(best_switch.to_double()),
"total": Json::number(best_total.to_double()),
}),
)
}
Json::object({
"basis": Json::string(
"STAR 式依赖图成本路由:执行成本(难度档易/中/难→1/2/3)+切换税(依赖执行者不同则+1),能力约束过滤,拓扑贪心",
),
"cost_route": Json::array(rows),
"total_est_cost": Json::number(total_cost.to_double()),
"note": Json::string(
"仅对 slack>0 未认领任务给出建议执行者(纯读不 claim);无注册执行者或能力不匹配项跳过",
),
})
}
/// ==================== 预算按依赖图阶段切分(R81,ZEBRA 水填充蒸馏简化版) ====================
///|
/// 预算切分的取数范围(BUG-20①):root 给定时取该任务子树,否则按 ns(空=default→全库按 ns="")。
fn FistEngine::budget_scope_ids(
self : FistEngine,
root : String,
ns : String,
) -> Array[String] {
if root != "" {
self.subtree_ids(root)
} else {
let pool = if ns == "" { self.list_all() } else { self.list_in_ns(ns) }
let out : Array[String] = []
for t in pool {
out.push(t.get_id())
}
out
}
}
///|
/// 作用域自证字段(BUG-20③):这次切分覆盖的是哪些任务,写在返回体里而不是靠调用方回忆。
fn budget_scope_json(root : String, ns : String, count : Int32) -> Json {
Json::object({
"root_task_id": Json::string(root),
"namespace": Json::string(ns),
"scoped_task_count": Json::number(count.to_double()),
"scope_kind": Json::string(
if root != "" {
"subtree"
} else if ns != "" {
"namespace"
} else {
"whole-store"
},
),
})
}
///|
/// BUG-209:非 DAG(不在 slack_map、不参与依赖边)的任务按其 parent_id 父链深归 stage,
/// 而不是一律归 level 0——level 0 是真正根任务层,孤立任务混入会虚增该层权重与份额。
fn FistEngine::parent_chain_depth(self : FistEngine, id : String) -> Int32 {
let mut depth = 0
let mut cur = id
let mut hops = 0
while hops < 10000 {
match self.get_task(cur) {
None => break
Some(t) =>
match t.parent_id {
Some(p) if p != cur && p != "" => {
cur = p
depth = depth + 1
}
_ => break
}
}
hops = hops + 1
}
depth
}
///|
/// 预算按依赖图阶段切分(R81,ZEBRA 背包水填充蒸馏简化版):给定总预算,
/// 按任务 DAG 阶段(slack 分析的 earliest 层级 0..makespan)切分——每阶段基础份额 =
/// 该阶段任务数 × 难度权重(难3/中2/易1,复用难度档单一来源;无难度标记按 1),
/// 总权重归一化后乘总预算(向下取整,余数补给最大权重阶段)。
/// 返回 { verdict, stages:[{level, tasks, difficulty_sum, share}], total_budget, makespan, scope, note }。
/// ——"预算在依赖图上按阶段切分:瓶颈阶段(关键路径层)占额可见,超支先预警",机制设计叙事。
///
/// BUG-20 改的三件事:
/// ① 过去**只吃 budget**,task_id/namespace 传了也不生效,算的永远是整个 store(实测
/// 某轮 T0r292 子树 7 个任务,它报 tasks=1152=全库数)——现在 root/ns 真的参与取数,
/// 与同族 dag_slack/dag_mc 的作用域口径一致;
/// ② budget<=0(含负数)过去直接产出负份额(-50 → -42/-5/-2),既不拒也不警——现在拒绝;
/// ③ 切了哪些任务写进返回体的 `scope` 自证。root/ns 都不给=全库(旧行为,零回归),
/// 但这一点从此写在返回体里,而不是靠调用方记得自己没传。
pub fn FistEngine::budget_split_by_dag(
self : FistEngine,
budget : Int32,
root~ : String = "",
ns~ : String = "",
) -> Json {
if budget <= 0 {
// BUG-180:rejected 分支此前不回 makespan/scope,与 ok/insufficient 分支不同构
//(文档承诺所有分支都带这两个字段)。rejected 因未取数而无统计,故 makespan:0、
// scoped_task_count:0 是诚实的回显,不是硬编码假值。
let reject_note = if budget < 0 {
"负预算只会切出负份额,那不是预算而是赤字"
} else {
"0 预算无法按比例切分份额(份额=比例×总预算,分母为零不成立)"
}
return Json::object({
"stages": Json::array([]),
"total_budget": Json::number(budget.to_double()),
"makespan": Json::number(0.0),
"verdict": Json::string("rejected"),
"scope": budget_scope_json(root, ns, 0),
"note": Json::string(
"budget 必须是正整数(收到 " +
budget.to_string() +
"):" +
reject_note,
),
})
}
let slack = self.dag_slack_analysis()
// 每任务 earliest 作为阶段
let level_of : Map[String, Int32] = Map([])
match jget(slack, "slack_map") {
Some(Object(sm)) =>
for id in sm.keys() {
match sm.get(id) {
Some(Object(o)) =>
match o.get("earliest") {
Some(Number(n, ..)) => level_of.set(id, n.to_int())
_ => ()
}
_ => ()
}
}
_ => ()
}
// ---- 作用域取数(BUG-20①)----
let scope_ids = self.budget_scope_ids(root, ns)
let root_missing = match self.get_task(root) {
None => true
Some(_) => false
}
if root != "" && root_missing {
return Json::object({
"stages": Json::array([]),
"total_budget": Json::number(budget.to_double()),
// BUG-184:未取数(根任务不存在),makespan=0 是诚实的"无数据"回显,
// 与 scope_ids 为空的 insufficient 分支(L827-838)同构。
"makespan": Json::number(0.0),
"verdict": Json::string("insufficient"),
"scope": budget_scope_json(root, ns, 0),
"note": Json::string(
"作用域内无任务:根任务不存在 " +
root +
"(与 dag_mc 同口径——不退回全库,退回全库正是本条缺陷的形状)",
),
})
}
// 阶段聚合:难度权重(难3/中2/易1,无难度 1)+ 任务数
let stage_weight : Map[Int32, Int32] = Map([])
let stage_count : Map[Int32, Int32] = Map([])
let in_scope : Map[String, Bool] = Map([])
for id in scope_ids {
in_scope.set(id, true)
}
for t in self.list_all() {
// BUG-253:明确区分「未登记(false)」与「显式排除(false)」——scope_ids 之外返回 None,
// 用 `!= Some(true)` 语义等价于旧 unwrap_or(false) 且不会因未来追加 false 条目静默反转
//(对应昨夜 unsafe-scan P1 建议)。
if in_scope.get(t.get_id()) != Some(true) {
continue
}
let lv = match level_of.get(t.get_id()) {
// BUG-209:slack_map 之外(无依赖边)的任务旧行为一律归 level 0,
// 与真正根任务挤同 stage;改按 parent_id 父链深归 stage(parent_chain_depth)。
Some(l) => l
None => self.parent_chain_depth(t.get_id())
}
let lab = triage_label_of(t.description)
let w = if lab == "难" { 3 } else if lab == "中" { 2 } else { 1 }
stage_weight.set(lv, stage_weight.get(lv).unwrap_or(0) + w)
stage_count.set(lv, stage_count.get(lv).unwrap_or(0) + 1)
}
let mut total_w = 0
let mut max_lv = 0
let mut max_w = 0
for lv in stage_weight.keys() {
let w = stage_weight.get(lv).unwrap_or(0)
total_w = total_w + w
if w > max_w {
max_w = w
max_lv = lv
}
}
// 归一化切分:向下取整,余数补给最大权重阶段
let mut used = 0
let shares : Map[Int32, Int32] = Map([])
for lv in stage_weight.keys() {
let w = stage_weight.get(lv).unwrap_or(0)
let share = if total_w <= 0 { 0 } else { budget * w / total_w }
shares.set(lv, share)
used = used + share
}
if total_w > 0 && used < budget {
let cur = shares.get(max_lv).unwrap_or(0)
shares.set(max_lv, cur + (budget - used))
}
// 输出阶段行(按层级升序)
let stages : Array[Json] = []
let lvs : Array[Int32] = stage_weight.keys().to_array()
lvs.sort()
for lv in lvs {
stages.push(
Json::object({
"level": Json::number(lv.to_double()),
"tasks": Json::number(stage_count.get(lv).unwrap_or(0).to_double()),
"difficulty_sum": Json::number(
stage_weight.get(lv).unwrap_or(0).to_double(),
),
"share": Json::number(shares.get(lv).unwrap_or(0).to_double()),
}),
)
}
let mut makespan = 0
match jget(slack, "makespan") {
Some(Number(n, ..)) => makespan = n.to_int()
_ => ()
}
if scope_ids.is_empty() {
// 空作用域不出"三阶段全 0"的像模像样的切分(BUG-20①的另一半)
return Json::object({
"stages": Json::array([]),
"total_budget": Json::number(budget.to_double()),
"makespan": Json::number(0.0),
"verdict": Json::string("insufficient"),
"scope": budget_scope_json(root, ns, 0),
"note": Json::string(
"作用域内无任务,未做切分(不是预算问题,是取数范围为空)",
),
})
}
Json::object({
"stages": Json::array(stages),
"total_budget": Json::number(budget.to_double()),
"makespan": Json::number(makespan.to_double()),
"verdict": Json::string("ok"),
"scope": budget_scope_json(root, ns, scope_ids.length()),
"note": Json::string(
"阶段=slack 分析的 earliest 层级;份额=阶段难度权重占总量比例×总预算(余数补最大权重阶段)——瓶颈阶段占额可见,超支先预警",
),
})
}
/// ==================== 进度预算路由门控(R88,PROGROUTER 蒸馏) ====================
///|
/// 收集 task_id 的子树 id(自身 + 全部 parent_id 后代,BFS;复用 children_of)。
fn FistEngine::subtree_ids(self : FistEngine, root : String) -> Array[String] {
// R12-R8 性能:旧实现每个节点调 `children_of(pid)`(每查一次全库 list_tasks),
// 子树 N 节点 = O(N×M)。这里一次建 children 索引,遍历中全部走 Map。
let idx = self.children_index()
let out : Array[String] = [root]
let mut i = 0
while i < out.length() {
let pid = out[i]
let kids = idx.get(pid).unwrap_or([])
for kid in kids {
out.push(kid)
}
i = i + 1
}
out
}
///|
/// 进度预算路由门控(R88,PROGROUTER arXiv 2608.25992 蒸馏简化版):在线进度引导——
/// 对任务子树按「已消耗预算(难度权重 易/中/难→1/2/3,复用 extract_difficulty 单一来源)
/// 与 完成进度(已完成+已归档 / 子树任务数)」做双路径剩余成本预测:
/// 线性 = 燃尽率(burn_rate=spent/progress) × 剩余工作量(1-progress);
/// 保守 = 线性 × 1.2(PROGROUTER 双路径,缓冲 20%)。
/// 元门控给决策:OK(预算充足继续)/ CAUTION(线性可行但缓冲不足 → 建议降档/缩范围)/
/// ESCALATE(线性已超支 → 建议追加预算或暂停)。纯计算、无副作用。
/// spent_override>0 可覆盖自动估算(如按真实 token/成本统计注入);0 或缺省=自动。
pub fn FistEngine::progress_gate(
self : FistEngine,
task_id : String,
budget : Int32,
spent_override : Int32,
) -> Json {
match self.get_task(task_id) {
None =>
// BUG-183:任务不存在分支须与文档承诺的字段集(11 字段)同构,不能只回 3 字段。
// 无子树可统计 → spent/progress/burn_rate 为 0,linear=0、conservative=0(复用正常分支公式
// 线性=燃尽率×剩余工作量、保守=1.2×线性);verdict="rejected" + basis="task not found"
// 明确区分「任务不存在」与「预算问题」(后者 verdict 同为 rejected 但 basis/action 不同)。
return Json::object({
"ok": Json::boolean(false),
"task_id": Json::string(task_id),
"budget": Json::number(budget.to_double()),
"spent": Json::number(0.0),
"remaining": Json::number(budget.to_double()),
"progress": Json::number(0.0),
"burn_rate": Json::number(0.0),
// BUG-186:BUG-183 补丁只对齐了文档 11 字段,漏了对齐 sibling 分支——L956-979 rejected 与
// L1015-1033 正常分支都带 remaining_work(=1-progress),None 分支是函数内唯一缺失者。
// 无子树可测 → progress 视为 0 → remaining_work 无意义,诚实回 0.0(与 note 分支同构)。
"remaining_work": Json::number(0.0),
"projection": Json::object({
"linear": Json::number(0.0),
"conservative": Json::number(0.0),
}),
"verdict": Json::string("rejected"),
"action": Json::string(
"任务不存在,无法做进度预算路由门控",
),
"basis": Json::string("task not found"),
"note": Json::string("任务不存在: " + task_id),
})
Some(_) => ()
}
let ids = self.subtree_ids(task_id)
let mut total = 0
let mut done = 0
let mut started_weight = 0
for id in ids {
match self.get_task(id) {
Some(x) => {
total = total + 1
let st = x.get_status()
if st.is_completed() || st.is_archived() {
done = done + 1
}
// 已开工(非待领取)的任务计入已消耗预算(难度权重)
if !st.is_pending() {
let lab = triage_label_of(x.get_description())
let w = if lab == "难" { 3 } else if lab == "中" { 2 } else { 1 }
started_weight = started_weight + w
}
}
None => ()
}
}
let progress = if total <= 0 {
0.0
} else {
done.to_double() / total.to_double()
}
let spent = if spent_override > 0 { spent_override } else { started_weight }
let remaining = (budget - spent).to_double()
// 燃尽率:每单位进度消耗的预算。progress=0 且 spent>0 时燃尽率不可计算(未定义)→
// 用 sentinel -1.0 表示,绝不静默归零(BUG-165:归零会让 verdict 恒 OK,掩盖已消耗预算)。
let burn = if progress > 0.0 {
spent.to_double() / progress
} else if spent > 0 {
-1.0
} else {
0.0
}
let remaining_work = 1.0 - progress
let linear = burn * remaining_work
let conservative = linear * 1.2
if budget <= 0 {
return Json::object({
"ok": Json::boolean(false),
"task_id": Json::string(task_id),
"budget": Json::number(budget.to_double()),
"spent": Json::number(spent.to_double()),
"remaining": Json::number(remaining),
"progress": Json::number(progress),
"burn_rate": Json::number(burn),
"remaining_work": Json::number(remaining_work),
"projection": Json::object({
"linear": Json::number(linear),
"conservative": Json::number(conservative),
}),
"verdict": Json::string("rejected"),
"action": Json::string(
"非法预算:budget 必须为正(实收 " +
budget.to_string() +
")",
),
"note": Json::string("budget 必须为正"),
"basis": Json::string(
"进度预算路由门控(PROGROUTER arXiv2608.25992 蒸馏):预算须为正,非法输入整笔拒绝、状态回稳。",
),
})
}
// BUG-165:progress=0 且有消耗 → 燃尽率不可计算,不得静默归零判 OK,须预警(CAUTION/ESCALATE)。
// BUG-169:progress>=1 且已超支 → verdict 不得为 OK,须与 action(负为超支)一致。
let (verdict, action) = if progress <= 0.0 && spent > 0 {
(
"ESCALATE",
"进度 0% 但已消耗预算 " +
spent.to_string() +
",燃尽率不可计算(建议追加预算或暂停复核)",
)
} else if progress >= 1.0 {
if remaining < 0.0 {
(
"ESCALATE",
"已完成但预算超支,结余 " +
(budget - spent).to_string() +
"(负为超支)",
)
} else {
(
"OK",
"已完成,预算结余 " +
(budget - spent).to_string() +
"(负为超支)",
)
}
} else if linear <= remaining && conservative <= remaining {
("OK", "继续推进(按当前燃尽率预算充足)")
} else if linear <= remaining {
(
"CAUTION", "缩减范围/降档(如选更简单变体或减少拆分数)后继续",
)
} else {
("ESCALATE", "追加预算或暂停复核(按当前燃尽率将超支)")
}
Json::object({
"ok": Json::boolean(true),
"task_id": Json::string(task_id),
"budget": Json::number(budget.to_double()),
"spent": Json::number(spent.to_double()),
"remaining": Json::number(remaining),
"progress": Json::number(progress),
"burn_rate": Json::number(burn),
"remaining_work": Json::number(remaining_work),
"projection": Json::object({
"linear": Json::number(linear),
"conservative": Json::number(conservative),
}),
"verdict": Json::string(verdict),
"action": Json::string(action),
"basis": Json::string(
"进度预算路由门控(PROGROUTER arXiv2608.25992 蒸馏):双路径预测 线性=燃尽率×剩余工作量 / 保守=1.2×线性; 元门控 OK/CAUTION/ESCALATE(R88)",
),
})
}
///|
/// 反馈驱动的计划修订(R98,ReAct arXiv 2210.03629 / CoPAL arXiv 2310.07263 蒸馏落地):
/// 把"拆完即弃"升级为"执行中持续修订"——给定根任务及子任务执行反馈,计算计划三分:
/// keep(已证有效、承诺保留)/ rework(失败或其依赖链受牵连,需返工/重做)/ ready
/// (依赖全部有效且未执行 = 下一步可做)。feedback 缺省时读真实状态(已完成/已归档
/// =ok;已打回/已暂停=否;其余=未证)。级联:失败任务的下游依赖者一并 rework
/// (plan commitment 控涟漪——与 saga_repair 依赖者闭包同构)。纯计算、只读不写库
/// (决策建议,执行权在 agent/指挥官,与 saga 返回补偿序列同一抽象层)。
pub fn FistEngine::plan_revise(
self : FistEngine,
root_task_id : String,
feedback~ : Array[Json] = [],
ns~ : String = "default",
) -> Json {
if self.get_task(root_task_id) is None {
return Json::object({
"ok": Json::boolean(false),
"root_task_id": Json::string(root_task_id),
"note": Json::string("根任务不存在: " + root_task_id),
})
}
let sub = self.subtree_ids(root_task_id)
// 显式反馈表:task_id -> ok(覆盖读状态)
let fb : Map[String, Bool] = Map([])
// R137:被丢弃的反馈必须计数——旧实现静默 `_ => ()`,可 basis 只看 feedback.is_empty(),
// 于是「一条都没生效」也会回 basis="feedback",把状态读数冒称成反馈依据。
let mut fb_ignored = 0
let parsed_ids : Array[String] = []
for f in feedback {
let tid_opt = match jget(f, "task_id") {
Some(Json::String(s)) => Some(s)
_ => None
}
let ok_opt = match jget(f, "ok") {
Some(j) =>
match j {
Json::True => Some(true)
Json::False => Some(false)
_ => None
}
_ => None
}
match (tid_opt, ok_opt) {
(Some(tid), Some(okv)) =>
if tid == "" {
fb_ignored = fb_ignored + 1
} else {
fb.set(tid, okv)
parsed_ids.push(tid)
}
_ => fb_ignored = fb_ignored + 1
}
}
let mut fb_unmatched = 0
for tid in parsed_ids {
if !sub.contains(tid) {
fb_unmatched = fb_unmatched + 1
}
}
let fb_applied = parsed_ids.length() - fb_unmatched
let flags : Array[String] = []
if fb_ignored > 0 {
flags.push("feedback_unparsed:" + fb_ignored.to_string())
}
if fb_unmatched > 0 {
flags.push("feedback_unmatched:" + fb_unmatched.to_string())
}
let basis = if feedback.is_empty() || fb_applied == 0 {
"status"
} else {
"feedback"
}
// 证据判定:ok / no / ?
let ev : Map[String, String] = Map([])
for id in sub {
match self.get_task(id) {
None => ()
Some(t) => {
let v = match fb.get(id) {
Some(b) => if b { "ok" } else { "no" }
None => {
let st = t.get_status()
if st.is_completed() || st.is_archived() {
"ok"
} else if st.is_rejected() || st.is_paused() {
"no"
} else {
"?"
}
}
}
ev.set(id, v)
}
}
}
// 类别:keep / rework / ready(依赖先分类,迭代至稳定;根任务是计划容器,不入桶)
let cat : Map[String, String] = Map([])
let mut changed = true
while changed {
changed = false
for id in sub {
if id == root_task_id {
continue
}
if cat.get(id) is Some(_) {
continue
}
match self.get_task(id) {
None => ()
Some(t) => {
let mut dep_rework = false
let mut dep_undone = false
for d in t.depends_on {
if sub.contains(d) {
match cat.get(d) {
Some("rework") => dep_rework = true
Some("keep") => ()
// BUG-206:d 是根任务(计划容器)时不入 cat 桶,cat.get 恒 None;
// 旧 `_ => dep_undone = true` 把"根已完成"误判成"依赖未做",
// 让所有挂根的子任务恒落 ""(待再跑)。根任务能到这里说明它已完成,
// 故直接视为 done。
_ => if d == root_task_id { () } else { dep_undone = true }
}
} else {
// 子树外依赖:按真实状态判定是否完成
match self.get_task(d) {
Some(dt) =>
if !dt.get_status().is_completed() {
dep_undone = true
}
None => dep_undone = true
}
}
}
let tag = match ev.get(id) {
Some("no") => "rework"
Some("ok") => if dep_rework { "rework" } else { "keep" }
_ =>
if dep_rework {
"rework"
} else if dep_undone {
""
} else {
"ready"
}
}
if tag != "" {
cat.set(id, tag)
changed = true
}
}
}
}
}
let keep : Array[String] = []
let rework : Array[String] = []
let ready : Array[String] = []
for id in sub {
match cat.get(id) {
Some("keep") => keep.push(id)
Some("rework") => rework.push(id)
Some("ready") => ready.push(id)
_ => ()
}
}
Json::object({
"ok": Json::boolean(true),
"root_task_id": Json::string(root_task_id),
"basis": Json::string(basis),
"feedback": Json::object({
"total": Json::number(feedback.length().to_double()),
"applied": Json::number(fb_applied.to_double()),
"ignored": Json::number(fb_ignored.to_double()),
"unmatched": Json::number(fb_unmatched.to_double()),
}),
"flags": Json::array(flags.map(fn(s) { Json::string(s) })),
"keep_count": Json::number(keep.length().to_double()),
"rework_count": Json::number(rework.length().to_double()),
"ready_count": Json::number(ready.length().to_double()),
"keep": Json::array(keep.map(fn(s) { Json::string(s) })),
"rework": Json::array(rework.map(fn(s) { Json::string(s) })),
"ready": Json::array(ready.map(fn(s) { Json::string(s) })),
"note": Json::string(
"反馈驱动的计划修订(ReAct 交错 + CoPAL 分级纠正蒸馏):keep=已证有效承诺保留 / rework=失败或其依赖链受牵连需返工(控涟漪)/ ready=依赖全部有效且未执行,下一步可做。feedback 条目须为 {task_id: 非空字符串, ok: 布尔}:类型不符计入 feedback.ignored(flags=feedback_unparsed:N),任务不在子树计入 feedback.unmatched(flags=feedback_unmatched:N);只有至少一条真正作用到本子树时才回 basis=\"feedback\",否则按真实状态出计划并回 basis=\"status\"。",
),
})
}
///|
/// 全局目标校验(R100,goal drift arXiv 2505.02709 / Repetitiveness Rate 2603.12710 /
/// IntentCUA 2602.17049 / HiMAP ICML2026 蒸馏落地)——**advisory 判据,不是硬门**(BUG-25)。
///
/// 词法口径(单真源 @evolve.tokens):similarity = max(jaccard, coverage),drift = 1 - similarity。
/// · coverage 非对称(|根∩子|/|根|):只问"根目标的词还在不在",子任务多写模板不被长度惩罚;
/// · jaccard 保留给兄弟冗余(对称量,语义正确)。
///
/// 为什么必须降级成 advisory(标定实测,scripts/calibrate_goal_drift.py):
/// 真实父子对全集 1222 条 drift_suspect=1.9%,随机错配对照 400 条=81.3%(有分离);
/// 但**改写型**父子对(去掉前缀拼接桩与模板桩后剩 39 条,如 T0r61→T0r61.1「依赖图恒空」
/// 被写成「增量编译链与依赖图修复:cypyc/incremental/...」)假阳性 56~59%,
/// 3 个可测家族上绝对阈值 0.7 命中 15/15——合法拆解与真漂移在词面上不可分。
/// 所以:判据照旧发红(离群时最有信息量),但 verdict 一律带 advisory=true,
/// 下游按它打回就是把 15/15 的正常子任务反复打回(BUG-25 影响 2 的病灶)。
/// subtask_id 缺省校验根下全部后代。纯计算、只读不写库(决策建议,执行权在 agent/指挥官)。
pub fn FistEngine::goal_drift_check(
self : FistEngine,
root_task_id : String,
subtask_id~ : String = "",
ns~ : String = "default",
) -> Json {
match self.get_task(root_task_id) {
None =>
return Json::object({
"ok": Json::boolean(false),
"root_task_id": Json::string(root_task_id),
"note": Json::string("根任务不存在: " + root_task_id),
})
Some(rt) => {
let goal_tokens = @evolve.tokens(rt.get_description())
let targets : Array[String] = if subtask_id != "" {
[subtask_id]
} else {
self.subtree_ids(root_task_id).filter(fn(id) { id != root_task_id })
}
let checks : Array[Json] = []
let mut aligned = 0
let mut drift_suspect = 0
let mut redundant_suspect = 0
let mut insufficient = 0
for id in targets {
match self.get_task(id) {
None => ()
Some(t) => {
let tk = @evolve.tokens(t.get_description())
let red = self.sibling_max_similarity(t, tk)
// 信息量不足(空描述/纯模板桩)判 insufficient,不判漂移——
// 恒阳性正是本条缺陷的形状(BUG-25:6/6 全误报里就混着这一类)
let low_info = goal_tokens.length() < drift_min_tokens ||
tk.length() < drift_min_tokens
let drift = if low_info {
1.0
} else {
1.0 - drift_similarity(goal_tokens, tk)
}
let verdict = if low_info {
"insufficient"
} else if drift > drift_threshold {
"drift_suspect"
} else if red >= redundancy_threshold {
"redundant_suspect"
} else {
"aligned"
}
if verdict == "drift_suspect" {
drift_suspect = drift_suspect + 1
} else if verdict == "redundant_suspect" {
redundant_suspect = redundant_suspect + 1
} else if verdict == "insufficient" {
insufficient = insufficient + 1
} else {
aligned = aligned + 1
}
let cm = Map([])
cm.set("subtask_id", Json::string(id))
cm.set("drift_score", Json::number(drift))
cm.set("redundancy_score", Json::number(red))
cm.set("verdict", Json::string(verdict))
// advisory 恒真:本判据在中文改写型语料上假阳性 15/15,不可作打回硬门(BUG-25)
cm.set("advisory", Json::boolean(true))
cm.set("re_anchor", Json::boolean(verdict == "drift_suspect"))
checks.push(Json::object(cm))
}
}
}
Json::object({
"ok": Json::boolean(true),
"root_task_id": Json::string(root_task_id),
"basis": Json::string(
"max(jaccard, coverage)@evolve(词法相似度纯计算,零 LLM 自评;coverage 非对称、不罚长度)",
),
"hard_gate": Json::boolean(false),
"threshold": Json::object({
"drift": Json::number(drift_threshold),
"redundancy": Json::number(redundancy_threshold),
"min_tokens": Json::number(drift_min_tokens.to_double()),
}),
"calibration": Json::object({
"corpus": Json::string(
"fist-mbt.db 只读实测(2026-09-27),复跑:python scripts/calibrate_goal_drift.py",
),
"real_pairs_suspect": Json::number(0.019),
"real_pairs_n": Json::number(1222.0),
"random_control_suspect": Json::number(0.813),
"random_control_n": Json::number(400.0),
"reworded_pairs_suspect": Json::number(0.590),
"reworded_pairs_n": Json::number(39.0),
"conclusion": Json::string(
"全量分离成立(1.9% vs 81.3%),但改写型父子对 59% 假阳性 ⇒ 只可 advisory,硬用会把正常拆解反复打回",
),
}),
"counts": Json::object({
"aligned": Json::number(aligned.to_double()),
"drift_suspect": Json::number(drift_suspect.to_double()),
"redundant_suspect": Json::number(redundant_suspect.to_double()),
"insufficient": Json::number(insufficient.to_double()),
}),
"checks": Json::array(checks),
"note": Json::string(
"全局目标校验(advisory,非硬门):drift=1-max(jaccard,coverage)(根目标,子任务) >0.7 记 drift_suspect(附 re_anchor 提示);与兄弟 jaccard ≥0.7 记 redundant_suspect;任一侧词数 <6 记 insufficient(不判漂移)。打回决策需人工/指挥官复核——中文改写型子任务上本判据假阳性 15/15(见 calibration)。",
),
})
}
}
}
///|
/// 判据常量(与标定脚本同源,改这里=改判据,必须重跑 scripts/calibrate_goal_drift.py)
let drift_threshold : Double = 0.7
///|
let redundancy_threshold : Double = 0.7
///|
let drift_min_tokens : Int32 = 6
///|
fn drift_similarity(a : Array[String], b : Array[String]) -> Double {
max_of(@evolve.jaccard(a, b), @evolve.coverage(a, b))
}
///|
/// 同父兄弟里与本任务的最大词法相似度(对称量)。
/// 取两个口径的最大值:二字组词集抓"用词相同",可见字符集抓"词序换位"
/// (`数据采集模块` ↔ `采集数据模块` 在前者只有 0.556,后者 1.0);
/// 标定实测该分量在真实非模板家族上与单口径同为 34.8%,不额外增误报。
fn FistEngine::sibling_max_similarity(
self : FistEngine,
t : @core.Task,
tk : Array[String],
) -> Double {
let mut red = 0.0
match t.get_parent() {
Some(p) =>
for sib in self.children_of(p) {
if sib.get_id() != t.get_id() {
let sd = sib.get_description()
let s = max_of(
@evolve.jaccard(tk, @evolve.tokens(sd)),
@evolve.jaccard(
@evolve.unigrams(t.get_description()),
@evolve.unigrams(sd),
),
)
if s > red {
red = s
}
}
}
None => ()
}
red
}
///|
fn max_of(a : Double, b : Double) -> Double {
if a > b {
a
} else {
b
}
}