Quellcode durchsuchen

refactor(ac15): 3c 收口补强 —— mustCover 三数闭合 / tripwire 改名 / 课酬域形态行 / 自伤第 7 条

四件来自 review,都不改判定,只让报告自解释。

## 一、mustCover 的 detail 打三个数(闭合,不留魔数)

    声明 3 行报课 / 扇出 0 行 / 物理期望 3 行(无扇出)| legacy 读到 3、target 读到 3
    声明 2 行报课 / 扇出 1 行 / 物理期望 3 行(扇出来自 RA-2 的同一 (coursesid,classid) 两条排班…)

上一版只打"声明 N 条、期望快照 M 行",6 ≠ 5 需要人去追。现在 6 = 5 + 1 显式闭合,
并且扇出的行**说明为什么扇出**。这和 3a 那次 12+2+1+2=17 是同一个病:
**差 1 本身不重要,差 1 没有出处才重要。**

## 二、`ArrearsLegacyNetDelta` → `LegacyObservedNetDelta`,并做成 tripwire

· 改名:带 `Observed`,一眼看出是"实测记录"而不是"期望值"。
· **它已经会自己叫**:`arrearsCompare` 每次跑都拿 legacy 实测逐行比本值,**不等即红** ——
  性质从"代码里的一份快照"变成"回归探针"。注释里补了"与本值不等即红"。
· 新增 `validateArrearsSeed` 完整性检查:**必须覆盖全部声明行**。
  理由:`map` 取缺失键得到零值 0 ⇒ 断言变成"期望这行核销 0",而实现若恰好也是 0
  (循环提前中断)就**静默通过** —— 与 §18.3 第 0 条同族。多出键也拒绝。

## 三、课酬域形态单列一行 + 跨域闭合

    活动域形态:15 条:legacy-defect 2 | intentional-strict 2 | zero-write 1 | equality 10
    课酬域形态:2 条:intentional-strict 1 | equality 1
    跨域合计:域内 case 17(活动 15 + 课酬 2)| 资金域 35 | 合计 52

`activityShapeLine` 泛化成 `shapeLineOf(results, pred)`,返回 `{line, n}` ——
`n` 单独返回而不是从字符串里抠,避免"改一次文案就悄悄错"。
**没有并进活动域那行**:合并会把"活动域 15 条"静默改成 17 条,而它本该是 15(`9e2b0b7` 口径)。

## 四、README §18.3 新增第 7 条(自伤清单 7 → 8 种)

> **需要读库的断言写进"通用断言块"** ⇒ 通用断言块跑在两侧各自 `Rollback()` **之后**,
> 种子行已随事务回滚 ⇒ 查回来 0 行、`SUM(...)` 返回 NULL(`converting NULL to int64`)。
> **红的是断言自己,而 case 是绿的。**
> 防法:只能放 per-side 快照或 `xxxCompare` 阶段。本批落地=在 `readArrears*` 多读两列原始精度
> (`RemainderRaw3/NetRaw3`,不参与比对口径),断言搬进 `arrearsCompare`。

现场也写进去了:第一版报数是 `legacy 非 2 位 0/0 行(err=...)`,
**看那个分母 —— 分母为 0 一定是断言自己坏了。** 判据写进 README 比写在提交信息里更可能被读到。

## 报数(干净树跑)

DefaultCases 52 / 实跑 52 / PASS 52 / FAIL 0 / 崩溃 0
Skipped 98 | 业务 310 | 基建 105 | 未分类 0
形态:活动 15(2/2/1/10)| 课酬 2(1/1)| 跨域 15+2+35=52
grep "本块比对了" = 2 行,N = 3 / 3,**声明 3+2=5 + 扇出 1 = 物理 6**
未 push
Q vor 45 Minuten
Ursprung
Commit
aa9989fa46

+ 24 - 3
internal/academic/dualrun/stage2/README.md

@@ -1225,7 +1225,7 @@ $sql = "SELECT SUM(t1.discount_amount-t1.discount_amount_used) as amount FROM ..
 反过来,三处**故意选严**的:`toleranceCents = 0`(容差永不放宽)、
 哨兵的 `nonzeroSamples > 0`(非空自检)、`未分类断言数 == 0`。
 
-### 18.3 「自伤清单」:新基建最容易把旧判据悄悄弄坏的七种方式
+### 18.3 「自伤清单」:新基建最容易把旧判据悄悄弄坏的八种方式
 
 每一条都**不产生明显症状**,只让断言变松或整块消失。加基建/加 case 时逐条对照。
 
@@ -1238,6 +1238,12 @@ $sql = "SELECT SUM(t1.discount_amount-t1.discount_amount_used) as amount FROM ..
 | 4 | **行集"靠键配对"** | 少写一条 + 多写一条落在同一键上 ⇒ 互相抵消,绿 | 多重集(`map[tuple]int`),并把自增 id / 时间戳排除在 tuple 外 |
 | 5 | **闸门只数"非 Skipped 断言"** | 基建断言(残留 / 基线)永远为真 ⇒ 闸恒过 | 断言分 `Business` / `Infrastructure`,闸门只认业务;`Kind == 0` 算非业务(§18.2) |
 | **6** | **一个对象挂多个分类标签,然后"按标签计数"**(批 3a 真踩:`activityChainVerdict` 把命中的标签用 `" + "` 拼起来 ⇒ SA-2 / SC-2 同时是 `intentional-strict` 与 `零写入守卫`) | **两份数各自都对,加起来不等于总数** —— 报出 `12 + 2 + 1 + 2 = 17`,而该批只有 **15** 条。没有任何一条断言会红,因为它错在**记账**不在**判定** | 分类**唯一化**:`Case.ActivityShape()` 按优先级(`legacy-defect > strict > 零写入 > equality`)取**第一个**命中;统计只走 `ActivityShapeTally`,并把 `Σ N == len(cases)` 与"判定文案恰好命中 1 个标签"写成单测 |
+| **7** | **需要读库的断言写进"通用断言块"**(批 3c 真踩:精度口径那条要在原始 3 位精度上读一次种子行) | **红的是断言自己,而 case 是绿的。** 通用断言块跑在两侧各自 `Rollback()` **之后** ⇒ 种子行已随事务回滚,查回来 **0 行**、`SUM(...)` 返回 **NULL**(`sql: Scan error: converting NULL to int64 is unsupported`)。报告上是一条红,看着像"实现错了",实际是**断言读了一个已经不存在的数据** | 需要读库的断言只能放在 **per-side 快照**(事务内)或 **`xxxCompare` 阶段**(它拿到的是 `SideResult.Before/After` 快照)。本批的落地方式:在 `readArrears*` 的 SELECT 里**多读两列原始精度**(`ArrearsRow.RemainderRaw3/NetRaw3`,**不参与比对口径**),断言搬到 `arrearsCompare` |
+
+第 7 条的现场(写在这儿免得下一个人再踩):第一版把精度断言放在 `run.go` 的通用断言块里,
+报数是 `legacy 非 2 位 0/0 行(err=...converting NULL to int64 is unsupported)`——
+`0/0` 就是信号:**它一行都没读到**。当时 case 的 11 条业务断言全绿,只有这一条红。
+判断"是断言坏了还是实现坏了"的捷径:看那个分母 —— **分母为 0 一定是断言自己的问题**。
 
 第 6 条和前面五条不是一类:前面五条让**断言变松**,第 6 条只让**报出来的数字变错**。
 但后果一样严重 —— 数字是决策输入。`12 + 2 + 1 + 2 = 17` 那份统计如果被拿去写周报,
@@ -1247,7 +1253,9 @@ $sql = "SELECT SUM(t1.discount_amount-t1.discount_amount_used) as amount FROM ..
 ### 18.5 活动域形态的真实分布(`ActivityShapeTally` 输出)
 
 ```
-活动域形态分布:15 条 = legacy-defect 2 + intentional-strict 2 + zero-write 1 + equality 10
+活动域形态:15 条:legacy-defect 2 | intentional-strict 2 | zero-write 1 | equality 10
+课酬域形态:2 条:intentional-strict 1 | equality 1
+跨域合计:域内 case 17(活动 15 + 课酬 2)| 资金域 35 | 合计 52
 ```
 
 读法:**15 条里只有 10 条在验"两侧一致"**,另有 4 条(`legacy-defect` 2 + `strict` 2)
@@ -1255,7 +1263,20 @@ $sql = "SELECT SUM(t1.discount_amount-t1.discount_amount_used) as amount FROM ..
 **不能翻译成"15 条都证明了实现正确"** —— 有 5 条证明的是别的事。
 这跟 §16 的 `Skipped` 分解是同一个道理:绿的总数不等于检出力。
 
-这一行现在由报告自动打印(`Summarize` → `activityShapeLine`),**不要手算**。
+**一个域一行,不许合并**:课酬域单列一行(`2 条:intentional-strict 1 | equality 1`),
+不并进活动域 —— 合并会把"活动域 15 条"静默改成 17 条,而它本该是 15(`9e2b0b7` 的口径)。
+两级恒等式都要成立:
+
+| 级 | 不变量 | 本次实测 |
+|---|---|---|
+| 域内(活动) | Σ 各形态数 = 该域 case 数 | 2 + 2 + 1 + 10 = **15** ✓ |
+| 域内(课酬) | 同上 | 1 + 1 = **2** ✓ |
+| 跨域 | 域内 case 之和 + 资金域 case = 总 case 数 | 15 + 2 + 35 = **52** ✓ |
+
+⚠ 上表是**本次的读数**,不是让人每次手算的公式。`ActivityShapeTally` 保证 `Σ N == len(cases)`,
+报告那一行已经把总数打在最前面(`15 条:…`),跨域那行也由程序算 —— 人只读,不加。
+
+这一行现在由报告自动打印(`Summarize` → `shapeLineOf`,活动/课酬两个谓词各一次),**不要手算**。
 
 ### 18.4 代价必须可读:SQL 执行次数 + 耗时
 

+ 13 - 8
internal/academic/dualrun/stage2/arrears_compare.go

@@ -113,9 +113,10 @@ func arrearsIndex(rows []ArrearsRow, label string) (map[string]arrearsAgg, error
 //	      net += remainder;rem -= remainder;remaining -= remainder
 //
 // `fanout=true` 表示"同一报课行因排班重复被游标取出多次"(legacy 的 JOIN 扇出)。
-// 那种情况下第二趟读到的是**游标物化时的旧值**还是**更新后的新值**,由存储引擎决定,
-// 读代码无法确定 ⇒ 本函数只对 `fanout=false`(target 折平)给期望,
-// 扇出形态的 legacy 期望由 case 显式声明(`ArrearsLegacyNetDelta`)。
+// 那种情况下第二趟读到的是**游标物化时的旧值**还是**更新后的新值**,由存储引擎决定;
+// 更可能的是直接因 `unsigned` 越界而中断(F23)。所以本函数只对 `fanout=false`
+// (target 折平)给期望;扇出形态的 legacy 期望由 case 显式声明
+// (`LegacyObservedNetDelta`,一次实测的**观测记录**,且**与本值不等即红**)。
 func arrearsExpectedDeltas(c Case) map[string]arrearsRowDelta {
 	out := make(map[string]arrearsRowDelta, arrearsDeclaredRows(c))
 	if c.Arrears == nil {
@@ -188,14 +189,18 @@ func arrearsCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 			fmt.Sprintf("%+d", l.RemDelta), fmt.Sprintf("%+d", t.RemDelta),
 			l.RemDelta == t.RemDelta || c.IntentionalStrict, "单位:分")
 
-		// legacy 侧:扇出形态要按 case 声明的期望,而不是 target 的折平期望。
-		// 判据是"有没有**显式声明** legacy 期望",不是"这一行有几条排班" ——
+		// legacy 侧:扇出形态要按 case 声明的**观测记录**,而不是 target 的折平期望。
+		// 判据是"有没有**显式记录** legacy 观测值",不是"这一行有几条排班" ——
 		// 因为扇出会**中断整个循环**:后面那些自己只有 1 条排班的行同样拿不到钱
-		// (RA-2 的 ac15-ra2-1 就是这种:排班 1 条,但期望是 0)。
+		//(RA-2 的 ac15-ra2-1 就是这种:排班 1 条,但观测值是 0)。
+		//
+		// ⚠ 这条断言就是 `LegacyObservedNetDelta` 的 **tripwire**:它把"代码里的一份快照"
+		// 变成"回归探针"—— 实测与记录不等**立刻红**,谁改了种子/legacy 就得回来解释一遍。
+		// 只记录不比对等于死数据,所以这一行不能省。
 		lWantNet, lWantRem := w.NetDelta, w.RemDelta
 		fanout := len(r.classStartTimes()) > 1
-		if c.ArrearsLegacyNetDelta != nil {
-			lWantNet = c.ArrearsLegacyNetDelta[r.BizNo]
+		if c.LegacyObservedNetDelta != nil {
+			lWantNet = c.LegacyObservedNetDelta[r.BizNo]
 			lWantRem = -lWantNet // SP 的净额与应收减额永远等额反向
 		}
 		asserts = append(asserts, Assertion{Kind: AssertionBusiness,

+ 20 - 0
internal/academic/dualrun/stage2/arrears_seed.go

@@ -178,6 +178,26 @@ func validateArrearsSeed(c Case) error {
 			seenStart[key] = fmt.Sprintf("第 %d 行 %s", i, r.BizNo)
 		}
 	}
+	// `LegacyObservedNetDelta` 是**回归探针**:必须有全部声明行的键。
+	//
+	// 为什么"缺一行"必须拒绝而不是当 0:`map` 取不存在的键得到零值 0,
+	// 于是断言会变成"期望这一行核销 0"—— 如果实现也恰好是 0(比如循环提前中断),
+	// 它就**静默通过**了,而实际上我们**从未记录过**这一行的观测值。
+	// 这正是 §18.3 第 0 条的同族:断言键写错 ⇒ 断言块整体"成立"但毫无意义。
+	if c.LegacyObservedNetDelta != nil {
+		for _, r := range c.Arrears.Rows {
+			if _, ok := c.LegacyObservedNetDelta[r.BizNo]; !ok {
+				return fmt.Errorf("case %q 的 LegacyObservedNetDelta 缺少 biz_no=%q:"+
+					"tripwire 必须覆盖**全部**声明行(缺键会取到零值 0,等于静默把期望写成 0)",
+					c.Name, r.BizNo)
+			}
+		}
+		if len(c.LegacyObservedNetDelta) != len(c.Arrears.Rows) {
+			return fmt.Errorf("case %q 的 LegacyObservedNetDelta 有 %d 个键,但声明了 %d 行报课"+
+				"(多出来的键说明有 case 没删干净,留着会让人以为还有一行没被覆盖)",
+				c.Name, len(c.LegacyObservedNetDelta), len(c.Arrears.Rows))
+		}
+	}
 	return nil
 }
 

+ 15 - 9
internal/academic/dualrun/stage2/case.go

@@ -280,19 +280,25 @@ type Case struct {
 	// 为什么必须**两侧都种**:target 库整库为空,而比对锚是 `biz_no`。
 	// 只种一侧 ⇒ 另一侧读不到行 ⇒ `mustCover` 会 FAIL(这正是我们要的,见 §21.3)。
 	Arrears *ArrearsSeed
-	// ArrearsLegacyNetDelta 是"1:N 扇出形态下 legacy 侧每行的 net_receipts 增量(分)"。
+	// LegacyObservedNetDelta 是"1:N 扇出形态下 legacy 侧每行的 net_receipts 增量(分)"——
+	// **它是一次 legacy 实测的观测记录,不是期望值**。
+	//
+	// 为什么名字这么长、还带 `Observed`:它极容易被后人读成"legacy 就该这样,所以这是正确的"。
+	// 事实相反 —— 这条 case 的判定标签是 INTENTIONAL-STRICT(我们不复刻这个失效模式),
+	// 这个值唯一的作用是**钉住 legacy 的实际行为**,让"legacy 变了"这件事立刻可见。
+	//
+	// **它是 tripwire(回归探针),不是死数据**:`arrearsCompare` 每次跑都拿 legacy 实测
+	// 与本值**逐行比对,不等即红**。谁改了种子、谁动了 legacy 的实现,这条 case 立刻红,
+	// 被迫回来重新解释一遍。这比注释管用 —— 注释没人读,红必须处理。
 	//
 	// 只在这两种情况下填:
 	//   - 某条报课行的 `ExtraStartTimes` 非空(同一 `(courses,class)` 有多条排班 ⇒ 游标扇出);
-	//   - 该形态下 legacy 的终态**不能从声明推出**(第二趟读到的是游标物化旧值还是更新后新值,
-	//     由存储引擎决定;也可能因 `remainder_receivable` 是 **unsigned** 而直接 1690 越界)。
-	//
-	// 所以这不是"期望值",是**观测记录**:值来自第一次真实运行,机制解释写在 case 的 Note 里,
-	// 并且必须能连跑两次复现。`nil` = 与 target 折平后的期望相同。
+	//   - 该形态下 legacy 的终态**不能从声明推出**(第二趟读的是游标物化旧值还是更新后新值;
+	//     更可能因 `remainder_receivable` 是 **unsigned** 而越界中断,见 F23)。
 	//
-	// ⚠ 不许把"legacy 实测值"直接当成"正确值"来写实现 —— 它只用来**钉住分歧的形态**,
-	// 判定标签是 INTENTIONAL-STRICT(我们更严/更稳),不是"legacy 是对的"。
-	ArrearsLegacyNetDelta map[string]int64
+	// **必须覆盖全部声明行**(缺行 = 用零值冒充"期望 0",那是静默假绿,由 validate 拒绝)。
+	// `nil` = 不启用 tripwire(两侧按同一折平期望判)。
+	LegacyObservedNetDelta map[string]int64
 
 	// IntentionalStrict true = 这个 case **两侧不应一致**,而是"legacy 静默 / Go 显式拒绝"。
 	// 用于 R19(campusid<1 → SP LEAVE 静默 no-op,Go 报 3140)。

+ 53 - 16
internal/academic/dualrun/stage2/run.go

@@ -797,8 +797,8 @@ func DefaultCases(_ string) []Case {
 			//   ⇒ **整个 CALL 中断**,`ac15-ra2-1` 一分钱没拿到(**部分写入**:ra2-0 的 +60.00 已落)。
 			//   即:legacy 不只"重复核销",它是**一行崩掉整个循环,后面的报课行全部漏核**。
 			// target 折平后正常核完两行(60 + 60 = 120.00),不报错。
-			ArrearsLegacyNetDelta: map[string]int64{"ac15-ra2-0": 6000, "ac15-ra2-1": 0},
-			ExpectTargetSuccess:   true,
+			LegacyObservedNetDelta: map[string]int64{"ac15-ra2-0": 6000, "ac15-ra2-1": 0},
+			ExpectTargetSuccess:    true,
 			// legacy 侧**必然失败**(1264),且这是"这条 case 要钉住的分歧"本身,不是偶发错误。
 			ExpectLegacySuccess: false,
 			IntentionalStrict:   true,
@@ -1025,11 +1025,20 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
 	}
 	if want := arrearsSnapshotRowsOrDefault(c); want > 0 {
 		n := minInt(len(lg.Before.Arrears), len(tg.Before.Arrears))
+		declared := arrearsDeclaredRowsOrDefault(c)
+		fanout := want - declared
+		// 三个数必须同时出现在 detail 里:**声明 M / 扇出 F / 物理期望 M+F**。
+		// 只打总和会让 "3 是哪来的" 变成魔数 —— 而"差 1 没有出处"正是 3a 那次
+		// 12+2+1+2=17 的同一个病:数本身不重要,**能闭合才重要**。
+		fanoutWhy := "无扇出"
+		if fanout > 0 {
+			fanoutWhy = "扇出来自 RA-2 的同一 (coursesid,classid) 两条排班(LEGACY 侧游标会把同一报课行取多次)"
+		}
 		res.Assertions = append(res.Assertions, mustCover(
 			"case 有效性前提:**课酬应收两侧都读到且条数与声明一致**(按核销 cursor 顺序)",
 			n, want,
-			fmt.Sprintf("声明 %d 条报课行、期望快照 %d 行(含排班扇出),legacy 读到 %d、target 读到 %d",
-				arrearsDeclaredRowsOrDefault(c), want, len(lg.Before.Arrears), len(tg.Before.Arrears))))
+			fmt.Sprintf("声明 %d 行报课 / 扇出 %d 行 / 物理期望 %d 行(%s)| legacy 读到 %d、target 读到 %d",
+				declared, fanout, want, fanoutWhy, len(lg.Before.Arrears), len(tg.Before.Arrears))))
 	}
 
 	// ①-d 精度口径的**业务断言**在 `arrearsCompare` 里(它已经拿到了 `Before` 快照的原始 3 位列)。
@@ -1950,11 +1959,27 @@ func Summarize(w io.Writer, results []*CaseResult) bool {
 	} else {
 		fmt.Fprintf(w, "未分类断言(Kind==0):0 条\n")
 	}
-	// 活动域形态分布:**按唯一形态统计**,所以 Σ 必须等于该批条数。
+	// 形态分布:**按唯一形态统计**,所以每个域行自己的 Σ 必须等于该域条数。
 	// 这一行存在的理由就是 3a 那次人工加法算错(12+2+1+2=17,实际 15)——
 	// 报告自己给一个恒等式,比任何人再算一遍都可靠。
-	if line, ok := activityShapeLine(results); ok {
-		fmt.Fprintf(w, "活动域形态:%s\n", line)
+	//
+	// ⚠ **一个域一行,不许合并**:把课酬并进"活动域"会静默改掉 `9e2b0b7` 的口径
+	//("活动域 15 条"变成 17 条,而它本该是 15)。判据没变,只是多了个"谁来数它"。
+	actN, okAct := shapeLineOf(results, isActivityChainOp)
+	if okAct {
+		fmt.Fprintf(w, "活动域形态:%s\n", actN.line)
+	}
+	arrN, okArr := shapeLineOf(results, isArrearsOp)
+	if okArr {
+		fmt.Fprintf(w, "课酬域形态:%s\n", arrN.line)
+	}
+	// 跨域闭合:**域内 case 之和 + 资金域 case = 总 case 数**。
+	// 为什么要有这一行:域内恒等式(Σ 形态 == 该域条数)只保证"域内没漏",
+	// 不保证"域之间没重叠、没漏整批"。`52 这个数从哪来` 应该在报告里闭合,不该让人去减。
+	if okAct || okArr {
+		domainN := actN.n + arrN.n
+		fmt.Fprintf(w, "跨域合计:域内 case %d(活动 %d + 课酬 %d)| 资金域 %d | 合计 %d\n",
+			domainN, actN.n, arrN.n, len(results)-domainN, len(results))
 	}
 	for _, r := range results {
 		st := "PASS"
@@ -1977,24 +2002,36 @@ func sumBusiness(results []*CaseResult) int {
 	return n
 }
 
-// activityShapeLine 生成活动域形态分布那一行;非活动域 case 为 0 条时返回 ok=false(不打印)。
+// shapeLineResult 是一行形态统计的返回值:`line` 是给人看的字符串,`n` 是该域 case 条数。
+//
+// `n` 单独返回(不从字符串里抠)是为了让"跨域合计"那一行**用数不用解析** ——
+// 从格式化字符串里反解数字是典型的"看起来能用、改一次文案就悄悄错"的写法。
+type shapeLineResult struct {
+	line string
+	n    int
+}
+
+// shapeLineOf 按谓词挑出一批 case,生成形态分布那一行;该域 0 条时 ok=false(不打印)。
 //
 // 输出形如:`15 条:legacy-defect 4 | intentional-strict 2 | zero-write 6 | equality 3`
 // **尾部再打一次总数**,让人不必自己加:两侧对不上就是记账错了,不是"少打印了一类"。
-func activityShapeLine(results []*CaseResult) (string, bool) {
-	act := make([]Case, 0, len(results))
+func shapeLineOf(results []*CaseResult, pred func(Op) bool) (shapeLineResult, bool) {
+	picked := make([]Case, 0, len(results))
 	for _, r := range results {
-		if isActivityChainOp(r.Case.Op) {
-			act = append(act, r.Case)
+		if pred(r.Case.Op) {
+			picked = append(picked, r.Case)
 		}
 	}
-	if len(act) == 0 {
-		return "", false
+	if len(picked) == 0 {
+		return shapeLineResult{}, false
 	}
-	tally := ActivityShapeTally(act)
+	tally := ActivityShapeTally(picked)
 	parts := make([]string, 0, len(tally))
 	for _, t := range tally {
 		parts = append(parts, fmt.Sprintf("%s %d", t.Shape.Label(), t.N))
 	}
-	return fmt.Sprintf("%d 条:%s", len(act), strings.Join(parts, " | ")), true
+	return shapeLineResult{
+		line: fmt.Sprintf("%d 条:%s", len(picked), strings.Join(parts, " | ")),
+		n:    len(picked),
+	}, true
 }