package billcost import ( "fmt" "sort" "strings" ) // Reflect dry-runs the cost table and emits one Violation per rule // failure. The reflector groups bands by Partition and runs the // per-partition rule set on each group, then runs the cross-cutting // coverage rule. // // Scope (alpha.25 fix #11): the reflector validates structure + // simple sanity logic only. Carrier-specific business pricing rules // (per-carrier cost ceilings, cross-band jump tolerance, city // whitelists, return-fee caps) were removed because the reflector // has no carrier context to set them correctly -- a single hard-coded // threshold either false-positives (the alpha.25 cross_band_jump // case, raising 30+ violations on real YTO tables) or fails to catch // per-carrier outliers. PM ruled the reflector should stay // schema-and-shape only; pricing-policy validation belongs to the // downstream platform layer that knows the carrier. // // Per-partition rules (in order): // // segment_order — adjacent bands sorted by LimitBottom // segment_gap — bands[i].LimitTop == bands[i+1].LimitBottom // segment_overlap — same key, but raised when LimitBottom < prev.LimitTop // segment_start_nonzero — bands[0].LimitBottom == 0 // segment_end_short — bands[len-1].LimitTop >= cfg.MaxWeightG // monotonic — cost(w) non-decreasing across band-derived sample weights // first_lt_increment — for any band where BaseWeight == IncrementWeight: BaseAmount/BaseWeight >= IncrementPrice/IncrementWeight (per-gram) // band_incomplete — each band has at least one of (BaseAmount > 0, IncrementPrice > 0) // // Cross-cutting rules: // // partition_missing — every cfg.ExpectedPartitions value appears in Bands // // Sample weights are derived from each band's own boundaries // (LimitBottom+1, BaseWeight, BaseWeight+1, LimitTop) so any band // count and any segment length is covered without a fixed grid. // // Reflect dry-run 跑成本表, 每条规则失败 emit 一条 Violation. 按 // Partition group bands 跑分区内规则集, 再跑跨规则 (分区覆盖). // // 范围 (alpha.25 修 #11): 反射器只做结构 + 简单逻辑校验, 不假定承运商 // 业务定价规则 (单价上限 / 跨段跳价容忍 / 城市白名单 / 退回费上限). // 反射器无承运商上下文, 单一阈值要么误报 (alpha.25 cross_band_jump 在 // 圆通真实表 30+ 误报), 要么漏掉单承运商离群. PM 拍板反射器只看 schema // 和 shape, 定价策略校验属于知道承运商身份的下游 platform 层. // // 分区内规则 (顺序见上方英文注释表). 跨规则同上. // // 采样重量从 band 自身边界派生 (LimitBottom+1 / BaseWeight / // BaseWeight+1 / LimitTop), 任意段数 / 任意段长都覆盖, 不用固定网格. func Reflect(out Output, cfg ReflectConfig) []Violation { var violations []Violation // ADR-0008 v3.1 (2026-05-27): partition name 归一化. sub agent 偶尔 // 输出带 "省"/"市"/"自治区"/"回族自治区"/"维吾尔自治区"/"壮族自治区" // 后缀的省名 ("江苏省" / "内蒙古自治区" / "广西壮族自治区"). 严格字 // 符串比对让 partition_missing + unknown_partition 同时报, 实际范式 // OK. 归一化后再走原校验逻辑. // // ADR-0008 v3.1: partition normalization. sub agent occasionally // emits province names with "省"/"市"/"自治区"/composite minority // region suffixes. Strict string compare double-fires // partition_missing + unknown_partition though the substance is // correct. Normalize first. out = normalizeOutputPartitions(out) // Group bands by Partition (skip strip-fee bands, those are // handled separately). // 按 Partition group bands (单带费段独立处理). byPartition := map[string][]Band{} for _, b := range out.Bands { if b.IsStripFee { continue } byPartition[b.Partition] = append(byPartition[b.Partition], b) } for partition, bands := range byPartition { violations = append(violations, reflectPartition(partition, bands, cfg)...) } violations = append(violations, reflectCoverage(byPartition, cfg)...) violations = append(violations, reflectUnknownPartition(out, cfg)...) violations = append(violations, reflectDuplicates(out)...) return violations } // reflectDuplicates flags exact-duplicate band rows: a quote table // must not list the same (Partition, LimitBottom, LimitTop) triple // twice for cost bands, nor the same (City, LimitBottom, LimitTop) // twice for strip-fee bands. r10v3 (qwen3-32B) probe surfaced the // failure mode -- the LLM emitted the entire JSON object multiple // times concatenated, which after parsing presented as duplicate // rows -- and the reflector previously had no rule to catch it, // letting downstream billing see two identical (province, weight // segment) tuples and risk double-counting. Distinct from // segment_overlap (which flags partial-range intersections like // [0,500] vs [400,800]); duplicates are exact equality. Reported // once per offending key on the second occurrence so a 3-fold // repeat yields two violations (2nd and 3rd). // // reflectDuplicates 报告"完全相同"的重复段行: 一张报价表不该对同一 // (Partition, LimitBottom, LimitTop) 普通段或同一 (City, LimitBottom, // LimitTop) 单带费段出现两次. r10v3 (qwen3-32B) probe 实测暴露此故障 // 模式 -- LLM 把整个 JSON object 拼接输出多次, 反序列化后呈现为重复 // 行, 反射器此前无对应规则下游计费层会看到同一 (省, 段) 两次, 有 // 双计费风险. 与 segment_overlap 区分 (那是 [0,500] vs [400,800] 之 // 类的部分相交); 重复是完全相等. 每个冲突 key 在第 2 次出现时报一条, // 所以 3 次重复会出 2 条 violations (第 2 / 第 3 次各一). func reflectDuplicates(out Output) []Violation { var violations []Violation // Cost bands: (Partition, LimitBottom, LimitTop) must be unique. // // 普通段: (Partition, LimitBottom, LimitTop) 唯一. seen := map[string]int{} for _, b := range out.Bands { if b.IsStripFee { continue } key := fmt.Sprintf("%s|%d-%d", b.Partition, b.LimitBottom, b.LimitTop) seen[key]++ if seen[key] >= 2 { violations = append(violations, Violation{ RuleID: RuleDuplicateBand, Partition: b.Partition, WeightG: b.LimitTop, Detail: fmt.Sprintf("partition=%q 段 %d-%dg 重复出现 (第 %d 次)", b.Partition, b.LimitBottom, b.LimitTop, seen[key]), }) } } // Strip-fee bands: (City, LimitBottom, LimitTop) must be unique. // // 单带费段: (City, LimitBottom, LimitTop) 唯一. seenStrip := map[string]int{} for _, b := range out.StripFees { key := fmt.Sprintf("%s|%d-%d", b.City, b.LimitBottom, b.LimitTop) seenStrip[key]++ if seenStrip[key] >= 2 { violations = append(violations, Violation{ RuleID: RuleDuplicateStripFee, Partition: b.City, WeightG: b.LimitTop, Detail: fmt.Sprintf("city=%q 单带费段 %d-%dg 重复出现 (第 %d 次)", b.City, b.LimitBottom, b.LimitTop, seenStrip[key]), }) } } return violations } // reflectPartition runs all per-partition rules on bands belonging // to one partition. // // reflectPartition 跑单分区的全部分区内规则. func reflectPartition(partition string, bands []Band, cfg ReflectConfig) []Violation { var violations []Violation if len(bands) == 0 { return violations } // Sort by LimitBottom so adjacent-band rules are well-defined. // The original order is preserved by the partition sort below // only insofar as LimitBottom ties (we treat that as // segment_order violation). // 按 LimitBottom 升序排序, 相邻段规则才有定义. sorted := make([]Band, len(bands)) copy(sorted, bands) sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].LimitBottom < sorted[j].LimitBottom }) // segment_order: any out-of-order bands by LimitBottom. // segment_order: 任何 LimitBottom 顺序错乱的段. for i := range bands { if bands[i].LimitBottom != sorted[i].LimitBottom || bands[i].LimitTop != sorted[i].LimitTop { violations = append(violations, Violation{ RuleID: RuleSegmentOrder, Partition: partition, Detail: fmt.Sprintf("分区 %s 的段未按 LimitBottom 升序", partition), }) break } } // segment_start_nonzero: first band must start at 0. // segment_start_nonzero: 首段下限必须为 0. if sorted[0].LimitBottom != 0 { violations = append(violations, Violation{ RuleID: RuleSegmentStartZero, Partition: partition, Detail: fmt.Sprintf("分区 %s 首段 LimitBottom=%d, 应为 0", partition, sorted[0].LimitBottom), }) } // segment_end_short: last band must reach cfg.MaxWeightG. // segment_end_short: 末段必须到期望最大重量. if cfg.MaxWeightG > 0 && sorted[len(sorted)-1].LimitTop < cfg.MaxWeightG { violations = append(violations, Violation{ RuleID: RuleSegmentEndShort, Partition: partition, Detail: fmt.Sprintf("分区 %s 末段 LimitTop=%d, 应 >= %d", partition, sorted[len(sorted)-1].LimitTop, cfg.MaxWeightG), }) } // segment_gap / segment_overlap on each adjacent pair. // 相邻段对的 gap / overlap. for i := 0; i < len(sorted)-1; i++ { prev, next := sorted[i], sorted[i+1] switch { case next.LimitBottom > prev.LimitTop: violations = append(violations, Violation{ RuleID: RuleSegmentGap, Partition: partition, Detail: fmt.Sprintf("分区 %s 段 [%d,%d] 与 [%d,%d] 之间有缺口", partition, prev.LimitBottom, prev.LimitTop, next.LimitBottom, next.LimitTop), }) case next.LimitBottom < prev.LimitTop: violations = append(violations, Violation{ RuleID: RuleSegmentOverlap, Partition: partition, Detail: fmt.Sprintf("分区 %s 段 [%d,%d] 与 [%d,%d] 有重叠", partition, prev.LimitBottom, prev.LimitTop, next.LimitBottom, next.LimitTop), }) } } // band_incomplete: each band must have at least one priced lever. // band_incomplete: 每段至少首重价或续重价之一非零. for _, b := range sorted { if b.BaseAmount == 0 && b.IncrementPrice == 0 { violations = append(violations, Violation{ RuleID: RuleBandIncomplete, Partition: partition, Detail: fmt.Sprintf("分区 %s 段 [%d,%d] 首重价与续重价都为 0", partition, b.LimitBottom, b.LimitTop), }) } } // first_lt_increment: per-gram first-weight unit price must not // fall below the per-gram continuation unit price -- but ONLY // when base_weight equals increment_weight. When base_weight is // much larger than increment_weight (e.g. 3kg first-segment // flat-rate band 3.5 元 with 1kg continuation 1.5 元/kg), the // "per-gram" division is just a flat-rate average vs. an // incremental unit price; comparing them is apples-to-oranges // (large packages get bulk discount, marginal kg costs more). // PM (alpha.25): 内蒙古 6元/kg 首重 vs 3元/kg 续重 case is the // real reverse-logic, where base_weight=1000=increment_weight. // // first_lt_increment: 每段首重每 g 单价不得 < 续重每 g 单价 -- // 仅在 base_weight 等于 increment_weight 时检查. 当 base_weight // >> increment_weight (e.g. 3kg 首段一口价 3.5 + 续重 1kg 1.5), // per-gram 除法是"首段平均"vs"边际单价", 比无意义 (大包整段折扣, // 续重边际单价高合理). PM (alpha.25): 内蒙古 6元/kg 首重 vs 3元/kg // 续重才是真反逻辑 (base_weight=1000=increment_weight). for _, b := range sorted { if b.BaseWeight <= 0 || b.IncrementWeight <= 0 || b.IncrementPrice <= 0 { continue } if b.BaseWeight != b.IncrementWeight { continue } firstPerG := b.BaseAmount / float64(b.BaseWeight) incrPerG := b.IncrementPrice / float64(b.IncrementWeight) if firstPerG+1e-9 < incrPerG { violations = append(violations, Violation{ RuleID: RuleFirstLessIncrement, Partition: partition, Detail: fmt.Sprintf("分区 %s 段 [%d,%d] 首重每 g %.5f 元 < 续重每 g %.5f 元 (反逻辑)", partition, b.LimitBottom, b.LimitTop, firstPerG, incrPerG), }) } } // monotonic + numeric_range across band-derived sample weights. // 单调 + 数值域校验, 采样点从 bands 自身边界派生. samples := derivedSamples(sorted) var prevCost float64 covered := false for _, w := range samples { cost, err := CalculateShipCost(sorted, w) if err != nil { // Sample derived from a band must be covered; if not, // it implies a gap or order violation already raised. // 派生采样点应必命中; 不命中则已被 gap/order 违规覆盖. continue } if covered && cost+1e-9 < prevCost { violations = append(violations, Violation{ RuleID: RuleMonotonic, Partition: partition, WeightG: w, GotCost: cost, PrevCost: prevCost, Detail: fmt.Sprintf("分区 %s 重量 %dg 成本 %.2f 元 < 上一采样 %.2f 元 (单调性破坏)", partition, w, cost, prevCost), }) } prevCost = cost covered = true } return violations } // derivedSamples returns sorted unique sample weights drawn from the // bands' own boundaries (LimitBottom+1, BaseWeight, BaseWeight+1, // LimitTop). The resulting list is naturally sorted ascending so a // monotonic pass over it tests cost(w) monotonicity. // // derivedSamples 从 bands 自身边界派生采样重量 (LimitBottom+1 / // BaseWeight / BaseWeight+1 / LimitTop), 去重后升序返回; 单次遍历 // 即可验 cost(w) 单调性. func derivedSamples(bands []Band) []int { seen := map[int]struct{}{} add := func(w int) { if w <= 0 { return } seen[w] = struct{}{} } for _, b := range bands { add(b.LimitBottom + 1) add(b.BaseWeight) add(b.BaseWeight + 1) add(b.LimitTop) } out := make([]int, 0, len(seen)) for w := range seen { out = append(out, w) } sort.Ints(out) return out } // reflectCoverage checks that every cfg.ExpectedPartitions value // appears in the parsed output. // // reflectCoverage 验证 cfg.ExpectedPartitions 中每个分区都在输出中 // 出现. func reflectCoverage(byPartition map[string][]Band, cfg ReflectConfig) []Violation { if len(cfg.ExpectedPartitions) == 0 { return nil } var violations []Violation // 1. partition_missing: ExpectedPartitions 里有但 byPartition 没. for _, want := range cfg.ExpectedPartitions { if _, ok := byPartition[want]; !ok { violations = append(violations, Violation{ RuleID: RuleCoverage, Partition: want, Detail: fmt.Sprintf("期望分区 %s 缺失", want), }) } } return violations } // normalizePartitionSuffix trims trailing administrative-region suffixes // from a partition name. ADR-0008 v3.1: composite suffixes ("回族自治区" // / "维吾尔自治区" / "壮族自治区") must be tried before the shorter // "自治区" so e.g. "宁夏回族自治区" does not get truncated to "宁夏回族". // // normalizePartitionSuffix 去掉行政区后缀. 复合后缀必须先于"自治区" // 试, 否则"宁夏回族自治区"会被截成"宁夏回族". func normalizePartitionSuffix(s string) string { suffixes := []string{ "回族自治区", "维吾尔自治区", "壮族自治区", "自治区", "省", "市", } for _, suf := range suffixes { if strings.HasSuffix(s, suf) { return strings.TrimSuffix(s, suf) } } return s } // normalizeOutputPartitions returns a copy of out with every band / // strip-fee Partition normalized via normalizePartitionSuffix. ReturnFee // is passed through as-is. // // normalizeOutputPartitions 返回 out 的副本, 每条 band / 单带费的 // Partition 经 normalizePartitionSuffix 归一化. ReturnFee 透传. func normalizeOutputPartitions(out Output) Output { nb := make([]Band, len(out.Bands)) for i, b := range out.Bands { b.Partition = normalizePartitionSuffix(b.Partition) nb[i] = b } ns := make([]Band, len(out.StripFees)) for i, b := range out.StripFees { b.Partition = normalizePartitionSuffix(b.Partition) ns[i] = b } return Output{Bands: nb, StripFees: ns, ReturnFee: out.ReturnFee} } // reflectUnknownPartition 报告 detail 里 Partition 不在 ExpectedPartitions // 名单的违规. ADR-0008 v2.6.1 (2026-05-27 PM 实测教训): 子代理把单 // 带费用城市当 partition 直接填进 detail.ProvinceName (例 "深圳" "苏州" // "崇明" 当 partition), 原单向 partition_missing 只校"缺"不校"多". 加 // 反向规则一并校. 跨 cost band + strip-fee band 全扫 (strip-fee 行的 // Partition 也是 ProvinceName), 因为单带费城市归属的"父省"应该是已知 // partition, "深圳" 这种 city 名出现在 Partition 字段就是范式错位. // // Reflect 主入口里独立调用 (不挤进 reflectCoverage), 因这条规则跨 cost // + strip-fee 两源, 跟 Coverage 仅扫 byPartition (skip 了 strip-fee) // 范围不同. func reflectUnknownPartition(out Output, cfg ReflectConfig) []Violation { if len(cfg.ExpectedPartitions) == 0 { return nil } want := make(map[string]struct{}, len(cfg.ExpectedPartitions)) for _, p := range cfg.ExpectedPartitions { want[p] = struct{}{} } seen := map[string]struct{}{} for _, b := range out.Bands { seen[b.Partition] = struct{}{} } for _, b := range out.StripFees { seen[b.Partition] = struct{}{} } got := make([]string, 0, len(seen)) for p := range seen { if _, ok := want[p]; !ok { got = append(got, p) } } sort.Strings(got) out2 := make([]Violation, 0, len(got)) for _, p := range got { out2 = append(out2, Violation{ RuleID: RuleUnknownPartition, Partition: p, Detail: fmt.Sprintf("分区 %s 不在期望名单 (31 省 / 偏远) 内 — 子代理可能误把单带费用城市当 partition 填 (例: city=深圳 应映射 province=广东)", p), }) } return out2 }