Skip to content

About

麦麦积分精算师 — 基于麦当劳中国 MCP,把「渠道费 / 券叠加 / 积分机会成本」统一折算到一个目标函数「到手成本 = 现金 + 积分 × 影子价格」,回答同一份订单在到店自取、麦乐送、美团、饿了么哪边到手最便宜、该叠哪几张券、要不要动积分;并给出积分价值矩阵(同积分价值差 13 倍)、抽奖期望值边界与到期止损方案(麦当劳程序员创意开发大赛参赛作品)

Topics

Resources

Stars

119 stars

Watchers

0 watching

Forks

Repository files navigation

麦麦积分精算师 · MCD Points Actuary

点外卖这件事,钱已经有很多工具在算,积分几乎没人算。 本项目把「渠道费 / 券叠加 / 积分机会成本」折算到同一个目标函数上, 回答一句以前没人算得清的话:这单到底在哪买、用哪张券、要不要动积分,到手最便宜?

MCP Python Tests License


架构总览:数据来源 → 本地求解 → 统一目标函数 → 输出

上图四个阶段的相邻两层之间没有网络调用:7 次只读 MCP 取数之后, 组合枚举、券叠加、积分折算全部在本地完成,不占用 MCP 计价配额。

一句话价值

同一份订单 —— 巨无霸 + 薯条 + 可乐,在麦当劳南京新百餐厅(storeCode=1990346)实查菜单下:

渠道 券后商品 配送 打包 实付现金 用到券 到手成本
美团外卖 ¥22.75 ¥3.50 ¥1.50 ¥27.75 半价周末·巨无霸 5折 + 买薯条送可乐 + 必点榜减5 ¥27.75
饿了么 ¥34.44 ¥4.50 ¥1.00 ¥39.94 店铺满35减10 + 整单88折 ¥39.94
麦当劳 · 到店自取 ¥50.50 ¥0.00 ¥0.00 ¥50.50 无 ¥50.50
麦当劳 · 麦乐送 ¥50.50 ¥0.00 ¥0.00 ¥50.50 无 ¥50.50

选对渠道 + 选对券,比最贵的组合省 ¥22.75(−45%)。 而这还没算积分那一层 —— 见下。

统一目标函数

三层变量(渠道 × 券 × 用现金还是用积分)被压到一个式子上:

到手成本 = 现金支出 + 积分消耗 × 积分影子价格(元/积分)

关键在于「积分影子价格」不是拍脑袋设的常数,而是由积分商城实测算出来的 —— "这些积分拿去做最优兑换,能保证换回多少现金"。它让"花 500 积分省 24 元" 这类决策第一次可以被判定为亏损,而不是凭感觉。

真实积分商城的实测结果(2026-10-09):周期内最优兑换 0.1380 元/积分,最差 0.0274。

一个数不够:影子价格要并列三种口径

0.1380 是"积分商城里最划算的那一档",不是"你手上这些积分"的价值。把两者混为一谈, 会系统性高估积分、把本来划算的抵扣误判成亏损。所以本项目不再只给一个数,而是并列摆出三档, 每一档都写清楚它是怎么算的、什么时候该用:

口径 定义 怎么算 该在什么时候用
最优标的效率 周期内效率最高的那件标的 max(面值 ÷ 积分) 主口径,对外公布、跨期可比;刻意保守,杜绝"积分不值钱"的过度乐观
保守分位(P25) 历史各期最优效率的 25% 分位 shadow_price.py 时间序列 历史攒够多期后,给出"至少不会比这更差"的下界;样本不足时明确降级而不是假装有
余额处边际效率 在你的余额处,最后一份积分还能换回什么 有界背包在余额处的边际值 判断"这一单该不该花积分" —— 它才是这笔决策的机会成本

主口径仍然是 0.1380,本文与对外数字一律以它为准;保守分位与边际效率并列展示, 用于说明结论的适用边界,不改动任何已公布的结论。

为什么必须并列:0.1380 会在余额偏大时高估 100%

原因很朴素 —— 最优标的是限量的。「50 积分换 6.9 元派」每人每月通常只能换 1 次, 换完之后下一档是 0.1090、再下一档 0.0358……所以 0.1380 只对最前面那几十个积分成立。

expiry_policy.py 把这个"效率断崖"直接算出来(有界背包的精确解,不是估的):

余额 1500 积分 · 距到期 36 天

影子价格(这是本模块的核心产出):
  最优标的效率(v3 现用口径)  0.1380 元/积分   ← 只对最前面那几十个积分成立
  余额处平均效率               0.0594 元/积分   ← 全部积分摊平后的真实产出
  余额处**边际**效率 ← 建议用  0.0000 元/积分   ← 最后一份积分还能换回什么
  现用口径的高估幅度           0.1380 元/积分(100.0%)

有效兑换容量 1300 积分(再多积分也换不出更多价值)· 死积分 200 积分

同一套代码在余额 50 积分时,三档全部等于 0.1380(高估幅度 0.0%)—— 差异从哪来、 什么时候来,是可复算的,不是话术。

于是下面这张表可以被算出来:

积分餐品券 所需积分 菜单单点价 打包价 抵扣 打平 ypp 判定
17.9元板烧可乐组合 500 ¥33.00 ¥17.90 ¥15.10 0.0302 ❌ 不划算
21.9元巨无霸可乐组合 800 ¥36.00 ¥21.90 ¥14.10 0.0176 ❌ 不划算
16.9元中薯麦乐鸡组合 200 ¥28.50 ¥16.90 ¥11.60 0.0580 ❌ 不划算
28.8元巨无霸四件套 800 ¥32.00 ¥28.80 ¥3.20 0.0040 ❌ 不划算

打平 ypp = 抵扣额 ÷ 积分消耗。 只有它低于影子价格(主口径 0.1380),用这张券才亏。 上表 6 张券全部远低于基准 —— 结论很反直觉:积分商城里"抵得最多"的券,恰恰是最不该换的。

终端里的真实样子(含 ①积分定价 与 ②积分券打平线 两段,原始输出见 docs/captures/unified-live.txt):

真实运行:积分定价与积分券打平线

同样的券,如果你有积分即将过期(--shadow-ypp 0,视为沉没成本),立刻全部变成划算。 两种口径的差异,就是"积分到期前该不该突击兑换"的量化答案。

四层能力叠在一起

层 回答 引擎 在统一目标函数里的角色
L-1 · 接入 券面文字怎么变成规则? rule_parser.py 把「满30减5」「半价周末」这类文本转成结构化约束;置信度不足就不介入
L0 · 执行 算得快不快?问得多不多? combo_search.py + mcp_cache.py 剪掉注定赢不了的组合;同一份数据不重复问
L1 · 点餐组合 买哪几件? optimize_deals.py 枚举候选组合 + 券语义唯一实现
L2 · 积分资产 积分值多少钱? points_engine.py 产出 影子价格 ypp,给"积分"标价
L3 · 渠道 × 券 在哪买?用哪张券? channel_planner.py 把三层折到同一个 到手成本 上

L2 是 L3 的度量基础:没有影子价格,渠道比较只能比现金,"要不要用积分"永远算不清。 L1 是 L3 的候选来源:渠道价差几乎全部来自券,所以候选池按"券能生效的商品"构造。 L0 是 L1–L3 的成本底座:两者都不允许改变答案,只允许少算、少问 —— 由对拍测试守着。 L-1 是 L1–L3 的输入闸门:它只做一件事 —— 把文本变成约束,变不动的就不放进来。 宁可少一张券,也不要一张读错的券:少一张顶多错失优惠,读错一张会给出一个买不到的价格。

工作流(全程 7 次 MCP 调用;重复运行降为 0 次)

① query-nearby-stores   → 门店(默认选营业中、距离最近)
② query-meals ×2        → 到店菜单 + 外送菜单(外送需 beCode,取不到则降级并声明)
③ query-store-coupons   → 门店可用券(到店)
   query-my-coupons     → 我的券
   available-coupons    → 可领取券(接口只给名称,列为"未建模",不猜测面额)
④ query-my-account      → 积分账户(到期提醒)
   mall-points-products → 积分商城 → 价值矩阵 → 影子价格

   ── 以下全部在本地完成,不占 MCP 配额 ──
   券面文本解析(无 type 的券行)→ 券叠加求解 → 全渠道组合枚举 → 到手成本排序 → 报告

真实响应里 structuredContent 已含服务端预解析的 JSON,客户端优先走这条路径, 比从"说明文字 + JSON"里正则捞取稳健得多(旧版本自动回退到文本提取)。

上面这 7 次调用走的是 mcp_cache.py 的 TTL 缓存:同一份数据在有效期内不再问第二次, 所以第二次运行同一条工作流是 0 次 MCP 调用。缓存不会把失败载荷写进去 —— 一个 {"error": ...} 被缓存 30 分钟,比不缓存糟糕得多。

九个数学内核

内核 回答 做法
价值矩阵 同样积分换什么最值? ypp = 面值 ÷ 积分,中位数识别陷阱,周期冲突降级为"待复核"
抽奖期望 该换券还是抽奖? 零概率信息下的分层夹逼(见下)
券叠加 这堆券怎么组合最优? 同 stack_group 互斥、跨组叠加、按净收益择优、整单券限张数
到手成本 在哪买最便宜? 全渠道枚举 × 预算按"到手成本"判定,而非券后商品价
券面解析 一堆文字券怎么进模型? 七种券型的模式匹配 + 逐条可复核的置信度;不够格就不产出券(见下)
组合剪枝 池子大到枚举不完怎么办? 按价格升序建搜索树 + 可采纳下界,把"再涨也赢不了"整棵砍掉(见下)
约束求解 券的组合多到枚举不完怎么办? 表成 C1–C4 约束,分支定界求精确解,规模过大退化为束搜索并标注方法
影子价格时间序列 这个价格稳不稳? 每期快照入档,按窗口取保守分位;样本不足就降级并说出来
跨期序贯策略 拖着晚点换行不行? 有界背包的精确解 + 逐点边际曲线,给出有效兑换容量与"最晚开始日"

亮点:MCP 不返回中奖概率,怎么判断抽奖划不划算?

query-lottery-info 只给奖品名称,不给概率。常规做法只能"猜",本项目改用分层夹逼, 让每个结论都带上它的成立条件:

判定 成立条件 含义
dominated 奖池最高单项 < 打平线 必亏,不需要任何概率假设
robust 假设「永不上头奖、其余等概率」仍划算 结论不依赖头奖命中率
likely 全奖池等概率期望达标 倾向抽,但依赖头奖命中率
depends 仅当头奖命中率 ≥ p 才划算 反解出一元不等式给出精确门槛

打平线 = 单次消耗积分 × 影子价格 —— 即"这些积分拿去兑换能保证拿到的价值"。

分层夹逼给的是"边界",lottery_mc.py 再补上"分布":在四种概率假设下各跑 2 万次 定点模拟(固定种子,可原样复现),除均值外还给出亏损概率与分位数:

══ 跨概率假设压力测试(20,000 次 × 33 抽,种子 20261009)══
假设             均值/抽      实测 ypp   单次亏损概率   合计亏损概率
uniform            ¥8.20   0.3418 元/积分       37.9%         0.0%
pessimistic        ¥6.92   0.2882 元/积分       43.2%         0.0%
long_tail          ¥4.56   0.1900 元/积分       67.4%         7.0%
optimistic        ¥13.09   0.5454 元/积分        9.3%         0.0%

均值稳稳高于打平线 ¥3.31,但单次亏损概率最高到 67.4% —— "期望值为正"和"这一抽不会亏"是两回事。均匀与悲观两种假设有闭式解, 蒙特卡洛均值必须与它对上(差 3 分左右,抽样误差内),这是唯一能证明"模拟没写错"的硬校验。

亮点:券叠加不再靠"逐张贪心",而是解一个约束问题

v3 的券叠加是"逐张试、留更优"的贪心。它在商品池这一层会算错。最小反例:

购物车:A ¥10 + B ¥10
可用券:「A 单品5折」(互斥组 g1) + 「A 免单」(互斥组 g2)

旧实现:抵扣 ¥15.00 → 应付 ¥5.00    ← 错:把 A 抵成负数,多出的 ¥5 记到 B 头上
新实现:抵扣 ¥10.00 → 应付 ¥10.00   ← 对:A 一共只值 ¥10

coupon_solver.py 把问题显式表成四条约束,而不是继续打补丁:

约束 含义
C1 组内互斥 同一 stack_group 只能有一张生效(空组视为同一默认组)
C2 整单券限张 max_stack 限制整单级券张数;单品级属平台定价,不占额度
C3 商品池 每件商品被抵扣总额 ≤ 标价 —— 就是上面那个反例
C4 适用条件 门槛、商品范围、张数上限

规模小时分支定界求精确解(结果里标 method=exact,附带剪枝前后节点数); 规模超限自动退化为束搜索,并把 method=beam 写进结果 —— 用了近似就说用了近似。 与旧实现对拍 2548 组购物车 × 4 档影子价格 = 0 处不一致(在旧实现本就正确的问题上), 而上面那个反例被单独钉成回归用例。引擎通过 apply_coupons(..., engine=...) 切换, 默认仍是旧引擎,所以已公布的任何数字都不会因为这次重构而移动。

亮点:剪枝不是"少算点",而是"证明剩下的没必要算"

全渠道求解要枚举候选池的 k 元组合。样例池 10 项、k≤5 只有 637 个组合;换成全菜单 (60+ 项)允许 4 件,就是 52 万个 —— 而绝大多数注定赢不了。

combo_search.py 把组合空间组织成一棵按价格升序的搜索树,边走边算 「这个前缀再怎么加也赢不了」,然后整棵砍掉:

到手成本 ≥ 商品小计 × 最小标价系数 − 取整余量 − 抵扣上界 + 最小打包费

等式右边每一项都小于等于真实值(抵扣上界按互斥组取最大再求和;积分机会成本与 配送费都按 0 算),所以它是可采纳下界。商品小计沿子树只增不减 ⇒ 一个孩子一旦越过阈值, 它的所有后代同样出局。安全性依赖三条,测试里各自单独钉住:下界可采纳、下界对小计单调不减、 只有前 N 名已满时才启用阈值(否则会拿"只有 2 个结果"的边界去剪)。

实测(tools/bench.py,全部可复跑):

场景 枚举量 耗时(中位) 省下 内存峰值 结果一致
样例池 10 项,k≤5 637 → 184 36.07 → 9.96 ms(3.62×) 71.1% 79 → 25 KiB 是
合成池 22 项,k≤4 9,108 → 1,114 463.3 → 52.4 ms(8.84×) 87.8% 2115 → 256 KiB 是

「结果一致」不是形容词:同一份输入跑 full 与 pruned 逐字段比对, 另有独立写的暴力枚举当第三方裁判。

过程中真的踩到一个会静默剪错最优解的坑,值得写出来:

引擎是两段式的:单品级券先落地 → 整单级券作用于剩余金额
「到店自取」的默认组里同时挂着一张 item_free 和一张 threshold_reduce

按 stack_group 取最大值(错):950 分
真实能拿到的抵扣(对):    950 + 500 = 1450 分

stack_group 只表达"同组互斥",不含单品级/整单级这个维度 —— 两张同名组但不同阶段的券 会同时生效。上界一旦低于真实抵扣,下界就被抬高,最优解会被静默剪掉。 修法是让上界按引擎的真实两段结构分开估;顺带按 allow_stack / max_stack 收紧, 剪枝率因此从 63.9% 提到 71.1%(k≤5)。修完还发现第二个问题:剪枝只保留前 N 条, 成本打平时边界上的并列者取决于各自枚举顺序,于是两个引擎会各挑一个 "同样便宜但不同"的方案 —— 排序键因此补成全序(成本 → 件数 → 池内下标元组)。

这两条现在各有一个专门的测试类守着(TestDiscountCeilingMixedStage、 TestEngineSelection.test_ties_are_broken_deterministically)。

亮点:MCP 缓存 —— 快是次要的,别把失败缓存起来才是主要的

缓存最容易"看起来在工作":它确实返回了数据、确实比每次打接口快 —— 但可能返回过期数据, 或者把一次失败缓存成一小时都修不好的脏条目。所以 mcp_cache.py 的重点不在命中率:

  • 按工具分档 TTL(菜单 30 min / 门店 10 min / 券 5 min / 账户 1 min),每档都写清为什么是这个量级;
  • 失败与空载荷绝不入缓存 —— {"error": ...} / {"code": 40x} / 空列表一律放行到网络;
  • 命中率分成"本次运行"与"累计"两个数,同时报"逻辑调用"与"真实网络调用"; 混在一起说就是在给自己发奖状;
  • 条目损坏就删掉再回源,而不是反复读一个坏 JSON。

效果:同一条工作流第二次运行是 0 次 MCP 调用。

亮点:微优化 —— 有一条我测完决定不拿它当论据

tools/bench.py 把三类改动逐项拆开测,每项都给出对照实现。其中一条结论是负面的:

改动 实测 结论
math.comb 替掉手写循环 4~5×(每次省 0.5 µs) 微秒级,不该拿它当性能论据 —— 全项目只调用 ≤5 次
约束检查用位掩码随递归传递 3~4×(每个叶子省一次建 set) 值得:叶子上万时是实打实的常数
@dataclass(slots=True) 对象 + __dict__:344 B → 80 B 摊到"每对象实测"只省 40 B(字段内容两边都要分配)—— 两个数一起报

math.comb 那条值得做(不写循环、不用自己处理 k>n 的边界),但它省下的时间占一次 全渠道求解不到万分之一。把一条改动的收益说成它实际的样子,比把三条都写成"大幅提升" 更接近工程。

亮点:券面文本解析 —— 一条"宁可不算,也不猜算"的流水线

v1~v3.2 的券都是人手抄进 JSON 的结构化行。这在演示里没问题,一碰真实数据就崩: 券面长这样 —— 「满30元减5元」「麦乐鸡(5块)立减4元」「半价周末·巨无霸 5折」 「必点榜专属无门槛券 减5」「饿了么整单 88折(封顶12元)」。它们都是文本, 字段是人读出来的,不是程序读出来的。而抄错一个门槛(把"满39"写成"满29") 会让整条决策链算出一个买不到的"最便宜",比不做还糟。

所以 rule_parser.py 不是一条"能解析就解析"的流水线,而是一条带闸门的流水线:

                         ┌─ 结构不支持(refused)──→ 大声说出来,并说明模型边界在哪
券面文本 ─→ 尾部说明切分 ─→ 模式匹配 ─→ 置信度打分 ─→ 闸门 0.75 ─┬─ 通过 ─→ 结构化字段
                         └─ 置信度不足(ambiguous)─→ 不产出券,以 App 下单页为准

三条设计原则,每条都在测试里钉住:

  1. 宁可不算,不可猜算。 低于阈值的结果不产出 Coupon(fields 直接清空), 而不是产出一张"低分券"—— 后者会让调用方有机会"顺手用一下",闸门就白设了。

  2. 置信度是算出来的,不是拍出来的。 分数由一串带原文位置与原因的 Evidence 加出来,可逐条复核:

    原文:半价周末·巨无霸 5折
    结论:已解析为 item_discount(置信度 0.86)
      +0.60  结构          '5折'@9:11  —— 折扣
      +0.20  关键量齐全       ''  —— 折后比例 0.5
      +0.04  交叉印证        '半价 + 5折'  —— 两处独立陈述一致
      +0.14  商品定位        "['半价周末', '巨无霸'] → BURGER-BIGMAC"  —— 唯一命中,判为**单品折扣**
      -0.12  无封顶         ''  —— 文本没写封顶:按不封顶估算会高估优惠
    文本未给出(不推断,交调用方):封顶额(cap)、门槛(threshold)、stack_group(互斥组)
    
  3. 绝不推断文本没写的东西。 封顶、门槛、互斥组这些文本里根本不存在的字段, 一律记进 unstated 交给调用方,不替平台"补全"规则。

「结构不支持」与「不敢确定」必须分开报。 前者是模型边界(文本清清楚楚,只是装不下), 后者是信息不足。混在一起会让人以为"这个项目不支持券":

券面 判定 为什么
第二杯半价 / 买一送一 refused 需要同一商品多份,而购物车是集合,表达不了份数
满2件减5元 refused 件数门槛,v3 的门槛只有金额口径
随机立减最高5元 refused 优惠额是随机变量;要纳入应取保守分位而非期望值
满30减5,限部分门店 ambiguous 券在不在本店可用,文本读不出来 ⇒ 一律按不可用
买中薯条送中杯可乐 每单限用一张 通过 频次限制不改变"这一单省多少",拒绝它等于白扔一张算得对的券

最后一行是关键取舍:闸门要拦的是"算错",不是"看不懂"。所以「每单限用一张」 不但通过,还会附一个 _stack_hint=single 的结构提示给下游(长期策略需要"能用几次")。

跑真实数据(data/sample_coupon_text.json,19 条手抄券面): 14 条通过、4 条结构不支持、1 条置信度不足,退出码 = 未通过条数,可直接接 CI。

券面文本解析:19 条样例,14 通过 / 4 结构不支持 / 1 置信度不足

$ python scripts/rule_parser.py --coupons data/sample_coupon_text.json --menu data/sample_menu.json
通过        0.92  threshold_reduce   满30元减5元                         门槛¥30.00 减¥5.00
通过        1.00  buy_get            买中薯条送中杯可乐 每单限用一张     买FRIES-M 送COLA-M
通过        0.86  item_discount      半价周末·巨无霸 5折                 折扣5折 商品BURGER-BIGMAC
通过        0.88  discount           饿了么整单 88折(封顶12元)         折扣8.8折 封顶¥12.00
不支持      0.00  —                  第二杯半价                          —
不敢算      0.40  —                  满30减5,限部分门店                 —
19 条:通过 14,未通过 5(未通过的不参与计算,以 App 下单页为准)

接入方式:run_unified_live.resolve_coupon_rows 遇到没有 type 的行就会先过一遍 解析层;解析不通过的直接丢弃并附原因。有 type 的行完全不走这条路 —— 人工标注优先于文本推断,这个优先级不能倒。实测接入后离线全流程输出逐字节不变。

对拍真实数据时发现的事:文本的信息量小于结构化行

把 17 条真实券面解析出来,与 data/sample_channels.json 里人工标注的结构化行逐字段对拍, 大部分完全一致,但有两处必然对不上:

券面 人工标注 文本能推出来的 差在哪
半价周末·巨无霸 5折 cap = 15.0 无封顶 文本里没有封顶
饿了么整单 88折(封顶12元) threshold = 30.0 门槛 0 文本里没有门槛

这不是解析器的 bug,是这两张券的文字确实没写全。而这两个字段都是乐观方向:

  • discount 的 threshold_cents 留 0 ⇒ 券变成"无条件可用",从"可能打折"变成"必打折";
  • cap_cents 留 0 ⇒ 折扣没有上界。

所以解析层把它们写进 unstated,并因此把「全场8折」这类无封顶折扣券拦在闸门外 (置信度 0.70 < 0.75)。这条结论比"我解析对了多少条"更有价值: 文本解析永远只能是入口,替代不了人工标注 —— 代码把这件事说出来了,而不是假装没看见。

核心特性

  • 🎯 一个目标函数管三层:渠道费、券叠加、积分机会成本统一折算
  • 🧭 积分定价让"该不该用券"可判定:算出每张券的打平影子价格,一句话给结论
  • 📐 影子价格三档并列:最优标的效率(主口径 0.1380)/ 历史保守分位 / 你余额处的边际效率 —— 余额 1500 时前者高估 100%,差异是算出来的,不是话术
  • 🧱 券叠加显式建模:单品级先落地 → 整单级按余额判定 → 同组互斥、跨组叠加、限张数
  • 🧮 券组合是约束求解,不是贪心:C1–C4 四条约束 + 分支定界精确解,方法(exact/beam)写进结果
  • ⚡ 组合枚举是可采纳下界剪枝:样例池 k≤5 省 71.1% 枚举量(3.62×), 22 项合成池省 87.8%(8.84×),且与全量枚举逐字段一致
  • 🗃️ MCP 有 TTL 缓存:按工具分档过期、失败载荷绝不入缓存、命中率分"本次/累计"报; 同一条工作流第二次运行是 0 次 MCP 调用
  • 📊 性能改动都带可复跑的数字:tools/bench.py 逐项给对照实现 —— 包括一条"这条优化太小、不该拿它当论据"的负面结论
  • 🔤 券面文本 → 结构化约束,且带闸门:七种券型可解析(满减 / 单品立减 / 免单 / 买赠 / 折扣 / 单品折扣 / 组合特价);置信度由逐条带原文位置的证据加出来,不够格就不产出券
  • 🚧 「结构不支持」与「不敢确定」分开报:「第二杯半价」要的是同一商品两份, 购物车是集合装不下;「限部分门店」是本店能不能用读不出来 —— 两种拒绝,两种话术
  • 🛡️ 拒绝模糊推断:积分商城 SKU 与菜单 SKU 不一致时丢弃而非猜测 (实测「18.8元板烧鸡腿堡两件套」会被错配到菜单「板烧鸡腿堡三件套」)
  • 🕳️ 积分黑洞预警:给出每个低效标的的机会成本金额
  • ⏳ 跨期序贯策略:有界背包精确解 → 有效兑换容量、死积分数量、最晚开始日、 建议日均消耗速率;"再拖 20 天会少挽回多少"是可算的
  • 🎲 抽奖给出分布而不只是期望:四档概率假设 × 2 万次定点模拟,输出亏损概率与分位数
  • 🗂️ 影子价格入档:每期快照存 data/shadow_price_history.json,历史不足时明确降级,不假装有
  • 🧾 金额全程以「分」整数计算,无浮点误差
  • 🔌 离线可测 + 离线可跑:514 项单元测试全绿;--offline 无需 Token 即可复现全流程
  • 🚦 限流友好:全流程 MCP 调用次数为个位数

目标用户

人群 痛点 本 Skill 的价值
点外卖的麦麦会员 到店/麦乐送/美团/饿了么,不知道哪边便宜 直接给各渠道到手价对比与最优选择
手上一堆券的人 平台券 + 商家券 + 必点榜券,不知道能不能叠 按声明口径求解最优券组合
攒了积分的人 积分躺在账户里,不知道换什么 全量价值矩阵 + 黑洞清单 + 最优兑换组合
收到"积分将过期"提醒的人 知道要过期,不知道该换什么止损 最快的消耗路径 + 剩余损失金额 + 沉没成本口径的决策
爱抽奖的用户 不知道抽奖是不是在烧积分 给出抽与不抽的判定条件

安装方法

1. 申请麦当劳 MCP Token

访问 https://open.mcd.cn/mcp → 右上角「登录」(手机号验证)→「控制台」→「激活」→ 复制 Token。

2. 配置 MCP 连接器

将 mcp-config.example.json 中的 ${MCD_MCP_TOKEN} 替换为实际 Token,填入你的 MCP Client(WorkBuddy / Cursor / Cherry Studio / Trae 等):

{
  "mcpServers": {
    "mcd-mcp": {
      "type": "streamablehttp",
      "url": "https://mcp.mcd.cn",
      "headers": { "Authorization": "Bearer YOUR_MCP_TOKEN" }
    }
  }
}

3. 设置环境变量(脚本层需要)

export MCD_MCP_TOKEN=你的Token        # Linux / macOS / Git Bash
setx MCD_MCP_TOKEN 你的Token          # Windows(需重开终端)

4. 运行

git clone https://github.com/akexin/mcd-points-actuary.git
cd mcd-points-actuary
python -m unittest discover -s tests        # 118 项离线自检
python scripts/run_unified_live.py --offline  # 无需 Token 复现全流程

无需 pip install:本项目只依赖 Python 标准库。

使用示例

示例 A · 统一联调(真实 MCP,本项目的入口)

export MCD_MCP_TOKEN=你的Token
python scripts/run_unified_live.py --city 南京市 --keyword 新街口
python scripts/run_unified_live.py --store 1990346 --items 1100 4810 9900008751
python scripts/run_unified_live.py --store 1990346 --shadow-ypp 0   # 积分即将过期

真实运行输出(2026-10-09,麦当劳南京新百餐厅 1990346):

真实运行:渠道与券池 / 全网最低到手价

上图是原样渲染的真实 stdout —— ③ 列出各渠道费率与券数,④ 给出四个渠道的到手价对比。 输出里处处留着可复核的诚实标注:外送菜单不可得 … 按到店价估算、 可领取 9 条(去重后 6 个名称)… 无法量化,故不参与计算、 以及末尾那句 实际应付金额以下单页为准。

逐字原文与复现命令:docs/captures/unified-live.txt (74 行,由上表那条命令原样重定向得到);配图由 tools/make_readme_assets.py 从该文件渲染 —— 所以改一个数字,图就跟着变,不存在"图和代码对不上"的可能。

示例 B · 离线体验(不需要 Token)

python scripts/run_unified_live.py --offline
python scripts/points_engine.py matrix --products data/sample_points_products.json

积分价值矩阵输出(节选,真实商城数据脱敏快照):

可定价标的 20 项(周期内 7 · 已过周期 13 · 无法定价 4)
中位效率 0.0695 元/积分
效率倍数差:全库 13.7 倍 | 周期内 5.0 倍
 1.    0.3760 元/积分     50 积分  18.8元麦辣鸡腿汉堡两件套  ← 已过周期·待复核
 4.    0.1380 元/积分     50 积分  6.9元可乐麦炫酷
20.    0.0274 元/积分    800 积分  21.9元巨无霸可乐组合  ← 陷阱

示例 C · 抽奖到底划不划算

python scripts/points_engine.py lottery \
  --lottery data/sample_lottery.json --products data/sample_points_products.json \
  --refs data/sample_reference_prices.json
单次消耗 24 积分 | 兑换基准 0.1380 元/积分
打平直接兑换所需期望价值:¥3.31
  最高单项 ¥17.25 | 最低 ¥1.00 | 全池等概率期望 ¥8.24 | 悲观期望 ¥6.95
判定:抽奖占优 —— 结论不依赖头奖命中率(ROBUST)

示例 D · 积分资产诊断

python scripts/run_points_live.py --city 南京市 --keyword 新街口 --points 1500 --expiring 800
【800 积分即将过期,止损方案】
  ① 兑换挽回 ¥92.10:18.8元麦辣鸡腿汉堡两件套 + … + 16.9元中薯麦乐鸡组合
  ② 余额抽奖 6 次(144 积分,按基准折价 ¥19.87)
  合计挽回 ¥111.97
  ⚠ 仍有 6 积分无法消耗,预计损失 ¥0.83

示例 E · 影子价格的历史与保守分位

python scripts/shadow_price.py report
  共 1 条快照:
    2026-10-10  门店 1990346    最优 0.1380  中位 0.0695  最差 0.0274  (周期内 7 项)

  口径                           建议值
    最新实测(主口径)     0.1380 元/积分  ← 主口径:对外公布、跨期可比,刻意偏保守
    保守分位(近窗 P25)   0.1380 元/积分  ← 下界:历史攒够多期后才与主口径分叉

  主口径 0.1380 · 保守 P25 0.1380 · 区间 [0.1380, 0.1380] · 样本 1 次 · 窗口 6
  历史样本仅 1 次(<6 次窗口要求)→ 保守分位退化为单点值,**不假装有历史**

样本只有 1 条时它明说自己退化了,而不是拿单点冒充"历史区间"。 每跑一次 run_unified_live.py --record-shadow 就往 data/shadow_price_history.json 入档一条(按「门店+日期」去重,同日重复跑不会堆垃圾数据)。 攒够一个窗口之后,两个口径才真正分叉 —— 分叉的那一天,保守值会自己出现。

示例 F · 到期前的序贯兑换策略

python scripts/expiry_policy.py --balance 1500 --expiry 2026-11-15
余额 1500 积分 · 距到期 36 天(2026-11-15)

建议兑换 5 类、共 9 次:
  6.9元香芋派/菠萝派任选 × 2  → 花   100 积分换回 ¥13.80(单件 0.1380 元/积分)
  6.9元可乐麦炫酷 × 2  → 花   100 积分换回 ¥13.80(单件 0.1380 元/积分)
  10.9元指定小食任选 × 2  → 花   200 积分换回 ¥21.80(单件 0.1090 元/积分)
  10.9元指定小食任选 × 2  → 花   400 积分换回 ¥21.80(单件 0.0545 元/积分)
  17.9元板烧可乐组合 × 1  → 花   500 积分换回 ¥17.90(单件 0.0358 元/积分)

合计:花 1300 积分 · 挽回 ¥89.10 · 余 200 积分 · 相对最优解少拿 ¥0.00
建议日均消耗 36.1 积分

影子价格(这是本模块的核心产出):
  最优标的效率(v3 现用口径)  0.1380 元/积分   ← 只对最前面那几十个积分成立
  余额处平均效率               0.0594 元/积分   ← 全部积分摊平后的真实产出
  余额处**边际**效率 ← 建议用  0.0000 元/积分   ← 最后一份积分还能换回什么
  现用口径的高估幅度           0.1380 元/积分(100.0%)
  前向:再攒一份能多换回       0.0020 元/积分   ← 手上的分已换不完,别再攒

有效兑换容量 1300 积分(再多积分也换不出更多价值)· 死积分 200 积分

最晚开始日:2026-10-15(5 天后,还有 36 天到期)

两个名字很像的「10.9元指定小食任选」是两条不同的标的(100 分 / 200 分), 所以清单里它们分开列 —— 按名称合并会把结论改掉,这一条有专门的回归测试守着。 最晚开始日 也不是拍出来的:它是对「每期可兑换次数被到期日截断」做扫描求出来的 —— 再拖下去,可挽回金额会真的下降。

示例 G · 券组合的约束求解与两套口径

python scripts/coupon_solver.py --items data/sample_menu.json \
  --channels data/sample_channels.json --channel meituan \
  --cart BURGER-BIGMAC BURGER-DOUBLE-CHEESE CHICKEN-MCNUGGETS-5 --shadow-ypp 0.138
  渠道:美团外卖(券 6 张,可叠加 ≤ 2 张)
  购物车:巨无霸 + 双层吉士汉堡 + 麦乐鸡(5块) = ¥52.50

  ── order_mode=middle ──
    方法 exact · 单品级展开 2/2 状态(剪掉 0.0%)· 整单级试算 2 次
      半价周末·巨无霸 5折(item_discount)→ ¥12.25
      美团平台券 满39减8(threshold_reduce)→ ¥8.00
      麦当劳商家券 满30减8(threshold_reduce)→ ¥8.00
    抵扣 ¥28.25 · 积分 0 · 应付 ¥24.25 · 净收益 ¥28.25
    商品池占用:巨无霸 ¥12.25

「商品池占用」这一行就是约束 C3 的可视化:巨无霸一共只被抵了 ¥12.25(它标价的一半)。 两套整单级口径并列输出 —— middle(都在扣掉单品级优惠后的同一个余额上算)与 sequential(逐张在上一张扣减后的余额上算),差异看得见,见「建模口径」第 2 条。 方法 exact 表示这是精确解;规模超限时会自动退化为 beam 并如实标注。

示例 H · 组合枚举:全量 vs 剪枝(结果必须一致)

# 剪枝引擎与全量枚举逐字段对照,并打印下界的三段构成
python scripts/combo_search.py --max-items 5 --top 5

# 同一件事通过统一入口跑:加 --engine pruned 即可
python scripts/channel_planner.py --menu data/sample_menu.json \
  --channels data/sample_channels.json optimize --max-items 5 --top 5 --engine pruned

# 微优化基准(组合数 / 剪枝 / 对象内存 / 位掩码,全部本地测量)
python tools/bench.py
  下界构成:下界 = (小计 × 1) − 5 分取整余量 − min(小计, 4025 分) + 0 分打包费

  结果一致:是
  耗时:35.0 ms → 9.8 ms(3.57×)
  相对 v3 原始组合数(637):637 → 184(总省 71.1%)
  其中**下界剪枝**单独贡献:453 个组合(占可行组合的 71.1%),砍掉 229 棵子树

归因刻意分成两笔:约束剪枝(exclude / must_include 可达性,两个引擎都开) 与下界剪枝(--engine pruned 才开)分开报。混成一个数字,就等于把"本来就该过滤掉的" 算进剪枝的战功里。逐字原文:docs/captures/combo-search.txt、 docs/captures/bench.txt。

示例 I · 券面文本 → 结构化券(带闸门)

# 19 条手抄券面(前 14 条可解析,后 5 条刻意放入必须被拒的形态)
# 退出码 = 未通过条数,可直接用于 CI
python scripts/rule_parser.py --coupons data/sample_coupon_text.json --menu data/sample_menu.json

# 真实渠道券面(17 条,全部应通过)
python scripts/rule_parser.py --coupons data/sample_channels.json --menu data/sample_menu.json

# 逐条看置信度是怎么来的 —— 每个加/减分都带原文位置与原因
python scripts/rule_parser.py --text "半价周末·巨无霸 5折" --menu data/sample_menu.json --explain
python scripts/rule_parser.py --text "全场8折" --menu data/sample_menu.json --explain

# 机器可读输出(含 status / confidence / unstated / evidence)
python scripts/rule_parser.py --text "圣代免费" --menu data/sample_menu.json --json
原文:全场8折
结论:置信度 0.70 < 阈值 0.75,**不参与计算**,以 App 下单页为准;
     扣分项:无封顶(文本没写封顶:按不封顶估算会高估优惠。补上封顶额即可重新纳入)
命中模式:discount
  +0.60  结构          '8折'@2:4  —— 折扣
  +0.20  关键量齐全       ''  —— 折后比例 0.8
  +0.02  适用对象明确      '整单/全场'  —— 券面写明是整单折扣 ⇒ 按整单估算是对的
  -0.12  无封顶         ''  —— 文本没写封顶:按不封顶估算会高估优惠
文本未给出(不推断,交调用方):封顶额(cap)、门槛(threshold)、stack_group(互斥组)

加一条 --threshold 0.70 这张券就会被纳入 —— 闸门是可调的, 但默认值卡在"结构完整 + 金额带单位"(0.86)与"关键量缺失"(≤0.70)之间的那条缝里。 逐字原文:docs/captures/rule-parse.txt。

自检(514 项,全离线)

python -m unittest discover -s tests -v

全部测试通过:Ran 514 tests … OK

测试文件 项数 盯住什么
test_combo_search.py 72 下界可采纳且单调(随机池 × 全部购物车逐个验证)、pruned 与 full 逐字段一致、掩码与 _satisfies 全组合对拍
test_mcp_cache.py 57 TTL 到点必失效(假时钟,不 sleep)、失败载荷绝不入缓存、命中率与真实行为对得上
test_expiry_policy.py 48 有界背包对拍穷举、回溯自洽、同名不同档不合并、余额饱和时边际为 0
test_points_engine.py 47 价值矩阵、抽奖期望分层夹逼、止损背包、券名匹配回归
test_channel_planner.py 44 券叠加口径、bundle_price、渠道比价、按净收益决定用不用积分券、两个枚举引擎全参数矩阵等价
test_lottery_mc.py 30 蒙特卡洛均值对拍解析解、固定种子可复现、跨假设压力测试
test_shadow_price.py 27 统计量、快照去重、样本不足时降级、保守分位永不高于主口径
test_rule_parser.py 98 真实券面对拍人工标注、低置信度必须清空字段、结构不支持与不敢确定分流、文本没写的字段进 unstated
test_bench.py 24 基准的对照实现与生产实现结果相同、合成数据真的能触发被测路径、自检能拦住坏下界
test_coupon_solver.py 20 2548 组购物车与旧实现对拍、C1–C4 约束、剪枝不改变结果
test_unified_live.py 30 菜单归一化、券名精确解析、积分券生成判据、终端列宽对齐、缓存开关默认值、无 type 行走券面解析且不覆盖人工标注
test_optimizer.py 13 v1 点餐组合引擎

几个刻意写成"回归哨兵"的用例:

  • test_point_bundle_coupon_rejected_when_points_are_valuable —— 抵扣 ¥12.10 的积分券 在影子价格 0.1380 下被判为不该用;影子价格取 0(积分即将过期)时同一张券又变为可用。 两种口径的分歧就是结论本身,所以必须锁死。
  • test_unknown_name_dropped_not_guessed —— 「巨无霸套餐」绝不能被近似匹配到「巨无霸」。 实测证明 v2 的 0.85 字面重合阈值挡不住这个错配(24 张餐品券里只有 1 张能通过 去价格前缀后的完全同名校验),因此阈值匹配被整体废弃。
  • test_column_edges_line_up —— 表格必须按显示列宽(汉字 2 列)补齐: 两行字段的右边界列号必须逐一相等。这条测试保护的正是上面那两张截图的整齐。
  • test_matches_brute_force / test_random_cases_match_brute_force —— 有界背包与笛卡尔积穷举 逐一对拍(含 60 组随机用例)。背包写错是最容易"看起来对"的一类错,所以必须有个不依赖 待测代码的第三方裁判。
  • test_aggregates_by_identity_not_name —— 积分商城里存在同名但积分档位不同的标的 (实测「10.9元指定小食任选」同时以 100 分和 200 分出现)。按名称合并会把它们并成一项、 直接改变结论,所以按对象聚合。
  • test_conservative_never_above_point —— 200 组随机序列下,保守分位必须永远不高于主口径。 早期实现用分位数做"缓冲",在"刚跌下来"的序列上反而给出更高的值 —— 这条测试就是那次翻车的墓碑。
  • test_uniform_mean_matches_expected_if_uniform / test_pessimistic_mean_matches_expected_pessimistic —— 蒙特卡洛均值必须收敛到闭式解(差 3 分左右,抽样误差内)。 这是唯一能证明"模拟没写错"的硬校验。
  • test_pruning_does_not_change_result —— 剪枝只能省时间,不能改变答案: 剪枝前后必须逐字相同。这是性能优化唯一可接受的安全性证明。
  • test_admissible_on_random_pools_and_channels(test_combo_search.py)—— 随机券池 × 随机渠道 × 池子里全部购物车,逐个验证「下界 ≤ 到手成本」与「上界 ≥ 真实抵扣」。 这两条不成立时剪枝会静默剪掉最优解,所以它们必须是被测出来的,不是被推理出来的。
  • test_ceiling_counts_both_stages(TestDiscountCeilingMixedStage)—— 单品级与整单级券 挂在同一个 stack_group 上时,上界必须相加而不是取最大。这条对应的正是上面 「按组取最大值 = 950,真实 = 1450」那个会剪错最优解的坑。
  • test_ties_are_broken_deterministically —— 成本打平时(样例数据里「中薯条」与「满福堡」 都是 ¥12.00 且无券可用)两个引擎必须挑出同一个方案,且顺序按菜单顺序而非字母序。
  • test_item_pool_no_double_count / test_pool_used_never_exceeds_price —— 商品池约束 C3 的哨兵: 两套引擎对拍 2548 组购物车,逐组比对抵扣额与每件商品的占用。
  • test_attribution_sums_to_total —— 券求解与背包都自检"逐项之和 = 总额",不等就自己喊 归因异常,而不是安静地输出一个看着对的错数。
  • test_repeat_run_costs_zero_calls / test_prefetch_fetches_all_then_second_run_is_free (test_mcp_cache.py)—— 第二次读同一份数据时真实网络调用数必须是 0; test_failures_are_never_cached / test_business_failure_payload_is_not_cached 则保证失败不会被缓存成"事实"。
  • test_missing_must_include_is_rejected_loudly(test_bench.py)—— 基准脚本里那个 "必选商品不在池里"的情况必须报错而不是静默放行:静默放行会给出一个虚高的加速比。
  • test_unstated_threshold_really_changes_the_estimate(test_rule_parser.py)—— 这条测试断言解析结果与人工标注不一致,而且断言这种不一致是对的: 券面文本里根本没有门槛,threshold_cents 只能取 0,而 single_coupon_discount 会拿它做门槛判定 —— 留 0 等于"无条件可用"。所以这个字段必须进 unstated。 把"我解析不了"写成一条会红的测试,比在文档里说一句"仅供参考"结实得多。
  • test_low_confidence_clears_fields / test_scope_restriction_bites_hard —— 低于闸门时必须 fields == {},不是"输出一张低分券"。同时验证「限部分门店」 真的能把一个本来 0.94 的解析压到 0.54:这条限定不改变金额,它决定券存不存在。
  • test_explicit_type_is_never_overridden(test_unified_live.py)—— 带 type 的券行绝不能被解析层改写。人工标注优先于文本推断,优先级倒过来 会让"手抄对了"反而被改成错的,那是最坏的一类 bug。
  • test_frequency_limit_is_a_note_not_a_refusal —— 「每单限用一张」必须通过。 闸门拦的是"算错",不是"看不懂":频次限制不改变这一单能省多少, 拒绝它等于白扔一张算得对的券。拒绝的方向也要被测出来,否则"保守"会变成"缩头"。

建模口径(显式声明,便于复核)

券叠加在真实平台远比这复杂且会随时变,所以本项目只承诺:在声明的口径下,结论可复算。

  1. 单品级券先落地:item_free / item_reduce / item_discount / buy_get / bundle_price 作用于单件或指定组合,等价于平台常见的"半价周末""单品直降""X 元买这个组合"。
  2. 整单级券后应用:threshold_reduce / discount 作用在扣除单品级优惠后的金额上 (保守口径 —— 因此"平台满 39 减 8"在单品直降后可能不再满足门槛,报告里会看得见)。
  3. 同 stack_group 内互斥,跨组可叠加;空组视为同一默认组, 因此"单笔只用 1 张券"的官方规则天然包含在内。
  4. max_stack 限制整单级券张数(单品级属平台定价,不占叠加额度)。
  5. 按净收益择优:净收益 = 抵扣额 − 积分消耗 × 影子价格,净收益 ≤ 0 的券永不使用。
  6. bundle_price 要求每个指定商品各出现一次。购物车是"商品集合", 无法表达"2 份某单品",因此「X 元 2 份 Y」这类券在数据层排除,而非勉强近似。
  7. 整单级券有两套口径,并列输出不混用:middle = 所有整单级券都在同一个 (扣掉单品级优惠后的)余额上计算;sequential = 逐张在前一张扣减后的余额上计算。 默认 middle,两者结果不可直接比较,所以输出里会带上口径名。
  8. 影子价格三档并列,主口径不动:主口径 = 周期内最优标的效率(0.1380,对外公布、 跨期可比);保守分位 = 近窗 P25,样本不足时退化为主口径并明确标注; 余额处边际效率 = 有界背包在你的余额处的后向差分,只用于"这一单该不该花积分", 不改动任何已公布结论。
  9. 兑换节流是假设,不是事实:官方接口不返回"每人每月限换几次", expiry_policy.py 默认取「每 30 天 1 次」,并允许 --period-days/--per-period 显式覆盖 —— 宁可把假设写在参数里,也不假装知道。
  10. 性能优化不许改变答案:缓存、剪枝、微优化都必须输出等价。 任何一处优化让结果发生变化,就按 bug 处理 —— 由对拍测试(剪枝前后逐字比对、 新旧引擎 2548 组对拍)守着。用了近似(如 beam)必须在结果里写明。
  11. 默认引擎一律是"已公布数字的来源":组合枚举默认 full(全量),券求解默认 legacy。 新引擎要通过 --engine 显式开启 —— 优化不得悄悄改动对外发布过的结论。
  12. 缓存 TTL 按工具分档,且过期即失效:菜单 30 min、门店/配送 10 min、券 5 min、 账户 1 min;未知工具取 300 s 的保守默认。失败与空载荷永不入缓存。 命中率分成"本次运行"与"累计"两个数分别报,并把"逻辑调用"与"真实网络调用"分开。
  13. 剪枝只能用可采纳下界:下界必须对所有超集成立、且对商品小计单调不减; 只有前 N 名已满时才启用阈值。抵扣上界按引擎的真实两段结构估 (单品级按组取最大求和 → 整单级再按组取最大、受 max_stack 限流), 两段的组名不可混用。给不出界时返回哨兵 UNBOUNDED 并放弃剪枝 —— 宁可少剪,也不能剪错。
  14. 券面解析有一条 0.75 的置信度闸门,且低于阈值时清空字段: 不是"输出一张低分券",而是"不输出券"。错的方向不对称 —— 少一张券顶多错失优惠,读错一张券会给出一个买不到的价格。
  15. 券面文本里没写的字段一律进 unstated,绝不推断:封顶、门槛、stack_group 都不从文本里"合理猜测"。discount 的 threshold_cents 留 0 会让券变成无条件可用 (乐观偏差),因此它会被显式登记 —— 实测「饿了么整单 88折(封顶12元)」的 门槛 ¥30 只存在于人工标注里,文本里没有。
  16. 人工标注的优先级高于文本推断:券行只要带 type,解析层完全不介入。 优先级倒过来会让"手抄对了"反而被改写,那才是最坏的一类 bug。

目录结构

mcd-points-actuary/
├── SKILL.md                          # WorkBuddy Skill 定义与工作流
├── README.md                         # 本文件
├── CONTEST_DECLARATION.md            # 参赛声明(官方原文,未改动)
├── MCP_INTEGRATION.md                # MCP 接入说明与实测记录
├── mcp-config.example.json           # 脱敏 MCP 配置(环境变量占位符)
├── workbuddy.md                      # WorkBuddy 开发对话上下文
├── scripts/
│   ├── channel_planner.py            # ★ v3 跨渠道到手成本规划器(券叠加 + 渠道比价 + 双枚举引擎)
│   ├── run_unified_live.py           # ★ v3 统一端到端联调(点餐 ⊗ 第三方券 ⊗ 积分)
│   ├── coupon_solver.py              # ★ 券叠加的**约束求解**(C1–C4 + 分支定界 + 对拍旧引擎)
│   ├── combo_search.py               # ★ 组合枚举的**可采纳下界剪枝**(省 71~88%,结果逐字段一致)
│   ├── mcp_cache.py                  # ★ MCP 调用的 TTL 缓存与预测性预取(失败载荷绝不入缓存)
│   ├── rule_parser.py                # ★ 券面文本 → 结构化约束(七种券型 + 置信度闸门 + unstated)
│   ├── expiry_policy.py              # ★ 跨期序贯策略(有界背包 → 有效容量 / 边际效率 / 最晚开始日)
│   ├── shadow_price.py               # ★ 影子价格历史与保守分位(样本不足时明确降级)
│   ├── lottery_mc.py                 # ★ 抽奖蒙特卡洛(四种概率假设 × 定点种子 + 解析解校验)
│   ├── termtext.py                   # 终端**显示列宽**工具(全项目唯一实现,汉字按 2 列)
│   ├── points_engine.py              # v2 积分价值引擎(矩阵 / 抽奖 EV / 背包 / 止损)
│   ├── run_points_live.py            # v2 端到端联调(真实 MCP)
│   ├── optimize_deals.py             # v1 点餐组合引擎 + 统一券模型(7 种券型)
│   ├── run_live.py                   # v1 端到端联调
│   └── mcd_mcp.py                    # MCP Streamable HTTP 客户端(429 退避 / 结构化优先)
├── data/
│   ├── live_third_party.json         # ★ 美团/饿了么渠道费率与券面(用户提供,按**商品名**声明)
│   ├── sample_points_mapping.json    # ★ 积分商城标的 → 菜单商品 的**人工核验映射**
│   ├── shadow_price_history.json     # ★ 影子价格实测快照(按「门店+日期」去重,只记实测值)
│   ├── sample_channels.json          # 渠道建模样例(离线演示用,编码式)
│   ├── sample_points_products.json   # 积分商城标的(真实商城数据脱敏)
│   ├── sample_lottery.json           # 抽奖活动与奖品池
│   ├── sample_reference_prices.json  # 折扣券原价映射(门店菜单实查)
│   ├── sample_menu.json              # 离线示例菜单
│   ├── sample_coupon_text.json       # ★ 券面**原文**样例(19 条手抄文本,自带 _expected 验收标准)
│   └── sample_coupons.json           # 离线示例优惠券
├── tests/
│   ├── test_combo_search.py          # 72 项:下界可采纳 / 单调 / 双引擎等价 / 掩码对拍 _satisfies
│   ├── test_rule_parser.py           # 98 项:券面解析 / 置信度闸门 / 拒绝分流 / 对拍人工标注
│   ├── test_mcp_cache.py             # 57 项:TTL / 失败不入缓存 / 命中率自洽 / 预取
│   ├── test_expiry_policy.py         # 48 项:背包对拍穷举 / 回溯自洽 / 同名不合并 / 容量与边际
│   ├── test_points_engine.py         # 47 项:积分引擎 + 匹配回归
│   ├── test_channel_planner.py       # 44 项:券叠加 / 组合特价 / 渠道比价 / 引擎全参数矩阵等价
│   ├── test_lottery_mc.py            # 30 项:MC 对拍解析解 / 种子可复现 / 跨假设压测
│   ├── test_shadow_price.py          # 27 项:统计量 / 去重 / 降级 / 保守分位不越界
│   ├── test_bench.py                 # 24 项:基准对照实现正确 / 合成数据有效 / 自检能拦住坏下界
│   ├── test_coupon_solver.py         # 20 项:2548 组对拍旧引擎 / C1–C4 / 剪枝等价
│   ├── test_unified_live.py          # 30 项:菜单归一化 / 券名解析 / 积分券生成 / 列宽对齐 / CLI 开关 / 券面解析接入
│   └── test_optimizer.py             # 13 项:v1 点餐引擎
├── docs/captures/
│   ├── unified-live.txt              # ★ 真实运行 stdout 原样重定向(配图的数据源)
│   ├── tests-verbose.txt             # ★ 全部测试的原始输出
│   ├── combo-search.txt              # ★ 剪枝引擎的完整对照输出
│   ├── rule-parse.txt                # ★ 券面解析的完整输出(含逐条置信度构成)
│   └── bench.txt                     # ★ 微优化基准的完整输出
├── tools/
│   ├── make_readme_assets.py         # 由 captures 渲染 README 配图(架构图 / 截图 / 测试图)
│   └── bench.py                      # 微优化基准(组合数 / 剪枝 / 对象内存 / 位掩码)
├── assets/                           # 渲染产物(5 张 PNG,README 引用)
└── LICENSE

docs/captures/*.txt 是配图的唯一数据源:先跑脚本重定向出文本,再由 tools/make_readme_assets.py 渲染成 PNG。图不是画出来的,是可复算的 —— 这也是为什么截图里的表格列能对齐(渲染器逐字符摆放到固定列网格上, 并对 SimHei 缺失的 ¥ ➜ ⚠ − 自动走符号字体兜底)。

能力演进

本项目是三轮迭代 + 两轮加固的结果,仓库同时保留三层能力:

版本 命题 引擎 状态
v1 怎么买最省?(组合剪枝 + MCP 精算) optimize_deals.py 已完成,13 项测试
v2 怎么花积分最值?(价值矩阵 + 期望值边界) points_engine.py 已完成,47 项测试
v3 在哪买、用哪张券、要不要动积分? channel_planner.py + run_unified_live.py 已完成,58 项测试
v3.1 上述结论在什么条件下还成立? coupon_solver.py + shadow_price.py + expiry_policy.py + lottery_mc.py 已完成,125 项新测试
v3.2 算得快不快?问得多不多? combo_search.py + mcp_cache.py + tools/bench.py 已完成,162 项新测试
v3.3 一堆文字券怎么进模型? rule_parser.py 当前主线,98 项新测试

为什么一轮轮往前走? v1 把"钱"算到极致 → 发现钱之外的资产没人算(积分会过期、多路径、价值差 13 倍); v2 把积分算清楚 → 发现积分算清了却没用上:它必须和现金放在同一刻度上, 才能真正回答"这单该怎么买"。v3 就是把前两层接进同一个目标函数。

v3.1 补的是可信度:v3 的每个数字都能算出来,但有几个地方经不起追问 —— 影子价格是单点还是区间?券叠加在商品池这一层对不对?拖着不换行不行? "期望为正"就等于"不会亏"吗?这四个问题各自对应一个新的内核, 而且每一个都用"能不能证伪自己"来设计:对拍穷举、对拍旧引擎、对拍闭式解。

v3.2 补的是代价:v3/v3.1 的结论是对的,但代价随规模非线性上涨 —— 池子一大就枚举不完,工作流一跑就重复问接口。这一轮只做两件事:少算(可采纳下界剪枝) 与少问(TTL 缓存 + 预取),并且给出一条自己定的红线: 优化只允许改变耗时与调用次数,不允许改变任何一个输出字段。 还有一个副产品:tools/bench.py 把三类微优化逐项量了出来,其中一条的结论是负面的 —— 它太小,不该被写进文档当卖点。这条也写进 README 了。

v3.3 补的是入口:前三轮都默认"券已经被人工抄成结构化字段了",一碰真实券面就崩 —— 券面是文本,抄错一个门槛比不抄更糟。这一轮把文本到约束之间那段补上,但不给它无限权力: 置信度不足就不产出券,文本没写的字段不推断,结构装不下的形态大声说出来。 对拍真实数据时发现的事情比"我解析对了 17 条"有价值得多 —— 有两张券的文字信息量本身就不够(一张没写封顶、一张没写门槛), 而这两个字段的默认值都偏乐观。代码把这件事写进 unstated 并因此拦住了同类形态, 而不是假装没看见。

已知边界(写出来,而不是藏起来)

  • 券叠加的真实规则比本项目建模的更复杂,且会随时变。 本项目只承诺 「在声明的口径下结论可复算」,不承诺复现平台的全部细节。
  • 兑换节流(每人每月限换几次)官方接口不返回,默认按「每 30 天 1 次」假设, 可显式覆盖。这个假设会直接影响"有效兑换容量",所以它写在参数里、也写在这里。
  • 影子价格历史目前只有 1 期,保守分位因此退化为单点值 —— 代码会明说, 不会拿单点冒充区间。多跑几次 --record-shadow 即可积累。
  • 抽奖的官方概率不公开,所以只给"在什么假设下结论是什么",不给一个假装权威的概率。
  • beam 退化:券组合规模超过精确求解上限时用束搜索,结果里会标注 method=beam —— 此时答案是近似的,不是精确的。
  • 剪枝的上界偏松,discount 无封顶时直接失效。 抵扣上界按"每个互斥组能给出的最大抵扣 再按段求和"估,是可采纳但偏松的。若券池里存在无 cap 的整单折扣券,上界退化为哨兵 UNBOUNDED,此时一律不剪枝(stats.cuts == 0,输出里也看得出来)。 剩下来的松紧主要来自"很多张互不冲突的满减券可以叠加":组数越多,上界越松。
  • TTL 缓存有陈旧风险,且是刻意的取舍。 券面与库存会中途变化,"5 分钟"是拿 一致性换调用次数。不想要可以 --no-cache;拿不准就 --refresh 强制回源。 缓存目录 .cache/ 已被 gitignore,删除它不会有任何副作用。
  • 基准数字与机器相关。 tools/bench.py 的绝对值不可跨机器比较,比值才有意义; 脚本里每项都给了重复次数与中位数,也给了对照实现 —— 结论要能被重跑,不能被引用。
  • 券面文本解析覆盖不到平台规则的"另一半"。 解析层能读出的只有金额结构 (门槛、立减、折扣率、封顶、积分成本)与买赠/组合的适用商品。以下三类它读了也不信: 互斥组与叠加策略(平台策略,不在券面上)、每人限领/限用次数(账户维度)、 券面限定适用门店时本店是否在名单内。这三类都必须由调用方在渠道配置里声明。
  • 券面文本的信息量可能小于人工标注。 实测 data/sample_channels.json 的 17 条里, 「半价周末·巨无霸 5折」没写封顶、「饿了么整单 88折(封顶12元)」没写门槛 —— 两条都是乐观方向的缺口。解析层把它们登记进 unstated,但不会替平台补全, 所以它只能当入口,替代不了人工核验。
  • 券面解析的置信度是启发式评分,不是概率。 每档权重都是"读券面时最容易被什么字带偏" 的经验赋值,0.75 的阈值卡在"结构完整 + 金额带单位"(0.86)与"关键量缺失"(≤0.70) 之间的缝里。它保证的是同一份输入永远得到同一个判定,不保证"分数高就一定对"。
  • Python ≥ 3.10:@dataclass(slots=True) 与 int.bit_count() 都是 3.10 才有的。 本项目只用标准库,但不用旧解释器。

数据说明

  • data/ 下的商城标的、奖品池、菜单与金额均来自真实 MCP 响应的脱敏快照 (不含账号、Token 等隐私字段),用于离线复现与文档演示。
  • live_third_party.json 的第三方券面取自公开活动信息,仅用于建模演示; 各平台券的可领状态、门槛与叠加规则因人因店因时段而异,实际应付金额以各平台下单页为准。
  • sample_points_mapping.json 是人工逐条核验的映射表,两条路径: ① 券名明示组合成分;② 券名去价格前缀后与菜单商品名完全一致。 收录与排除的理由都写在文件里 —— 排除项比收录项多,这是刻意的。
  • 商城下架/上新、菜单调价都会改变结果,请以实时 MCP 返回为准。

声明

本项目为麦当劳程序员节创意开发大赛参赛作品,由参赛者独立开发,非麦当劳官方产品。 项目输出仅供参考,不构成投资、金融或其他专业建议;积分规则、奖品池及兑换可用性以麦当劳官方渠道的实时结果为准。

许可证

MIT © 2026 akeXin

About

麦麦积分精算师 — 基于麦当劳中国 MCP,把「渠道费 / 券叠加 / 积分机会成本」统一折算到一个目标函数「到手成本 = 现金 + 积分 × 影子价格」,回答同一份订单在到店自取、麦乐送、美团、饿了么哪边到手最便宜、该叠哪几张券、要不要动积分;并给出积分价值矩阵(同积分价值差 13 倍)、抽奖期望值边界与到期止损方案(麦当劳程序员创意开发大赛参赛作品)

Topics

Resources

Stars

119 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages