@1aboveio/skills 0.19.3 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/README.md +9 -4
  2. package/package.json +1 -1
  3. package/runtime/skills/distribution/generated/recipes.json +54 -48
  4. package/runtime/skills/distribution/scripts/bundles.mjs +113 -7
  5. package/runtime/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -6
  6. package/runtime/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +3 -36
  7. package/skills/cicd-pipeline/cloud-build/SKILL.md +6 -6
  8. package/skills/cicd-pipeline/mergify/SKILL.md +1 -1
  9. package/skills/engineering/engineering-runtime/coherence/workflow.json +15 -84
  10. package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -6
  11. package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +3 -36
  12. package/skills/engineering/rush-release/SKILL.md +46 -25
  13. package/skills/engineering/rush-release/evals/evals.json +22 -8
  14. package/skills/engineering/rush-release/references/cut.md +21 -4
  15. package/skills/engineering/rush-release/references/preflight.md +25 -6
  16. package/skills/engineering/rush-release/references/promotion.md +70 -0
  17. package/skills/engineering/rush-release/references/publish.md +49 -16
  18. package/skills/engineering/rush-release/scripts/plan.mjs +33 -7
  19. package/skills/engineering/smoke/SKILL.md +4 -4
  20. package/skills/engineering/smoke/references/manifest.md +3 -3
  21. package/skills/payment/payment-analysis/SKILL.md +5 -3
  22. package/skills/payment/tld-detection/SKILL.md +163 -0
  23. package/skills/payment/tld-detection/agents/openai.yaml +9 -0
  24. package/skills/payment/tld-detection/evals/evals.json +107 -0
  25. package/skills/payment/tld-detection/references/clean.md +51 -0
  26. package/skills/payment/tld-detection/references/clustering.md +219 -0
  27. package/skills/payment/tld-detection/references/decision.md +85 -0
  28. package/skills/payment/tld-detection/references/report-template.md +169 -0
  29. package/skills/payment/tld-detection/references/terminology.md +69 -0
  30. package/skills/payment/tld-detection/references/workflow.md +78 -0
  31. package/skills/payment/tld-detection/scripts/detect_ladders.py +390 -0
  32. package/skills/payment/tld-detection/tests/test_detect_ladders.py +132 -0
  33. package/skills/engineering/resolve-issues/SKILL.md +0 -168
  34. package/skills/engineering/resolve-issues/agents/openai.yaml +0 -9
  35. package/skills/engineering/resolve-issues/generated/workflow-repair-policy.json +0 -499
  36. package/skills/engineering/resolve-issues/references/breaker.md +0 -82
  37. package/skills/engineering/resolve-issues/references/deliverables.md +0 -27
  38. package/skills/engineering/resolve-issues/references/delivery.md +0 -108
  39. package/skills/engineering/resolve-issues/references/evidence-lane.md +0 -21
  40. package/skills/engineering/resolve-issues/references/exact-head-ci.md +0 -287
  41. package/skills/engineering/resolve-issues/references/fan-out.md +0 -33
  42. package/skills/engineering/resolve-issues/references/finalization.md +0 -68
  43. package/skills/engineering/resolve-issues/references/guarantees.md +0 -10
  44. package/skills/engineering/resolve-issues/references/high-risk.md +0 -29
  45. package/skills/engineering/resolve-issues/references/incidents/848/README.md +0 -156
  46. package/skills/engineering/resolve-issues/references/intake.md +0 -86
  47. package/skills/engineering/resolve-issues/references/integration-gate.md +0 -53
  48. package/skills/engineering/resolve-issues/references/interference.md +0 -87
  49. package/skills/engineering/resolve-issues/references/loop.md +0 -134
  50. package/skills/engineering/resolve-issues/references/model-catalog.md +0 -9
  51. package/skills/engineering/resolve-issues/references/postmortem.md +0 -27
  52. package/skills/engineering/resolve-issues/references/pre-flight-model-slots.md +0 -41
  53. package/skills/engineering/resolve-issues/references/pre-flight-recording-and-checkout.md +0 -48
  54. package/skills/engineering/resolve-issues/references/pre-flight.md +0 -41
  55. package/skills/engineering/resolve-issues/references/regression-checklist.md +0 -26
  56. package/skills/engineering/resolve-issues/references/run-state.md +0 -288
  57. package/skills/engineering/resolve-issues/references/sandboxed-testing.md +0 -48
  58. package/skills/engineering/resolve-issues/references/spawn-contract.md +0 -96
  59. package/skills/engineering/resolve-issues/references/terminal-evidence-journal.md +0 -40
  60. package/skills/engineering/resolve-issues/references/why.md +0 -653
  61. package/skills/engineering/resolve-issues/schemas/fix-round.schema.json +0 -49
  62. package/skills/engineering/resolve-issues/scripts/combine-and-verify.mjs +0 -721
  63. package/skills/engineering/resolve-issues/scripts/component-candidate.mjs +0 -962
  64. package/skills/engineering/resolve-issues/scripts/contract-revision.mjs +0 -220
  65. package/skills/engineering/resolve-issues/scripts/detect-delivery-mode.mjs +0 -420
  66. package/skills/engineering/resolve-issues/scripts/detect-target-branch.mjs +0 -256
  67. package/skills/engineering/resolve-issues/scripts/detect-workspace-mode.mjs +0 -168
  68. package/skills/engineering/resolve-issues/scripts/discover-models.mjs +0 -9
  69. package/skills/engineering/resolve-issues/scripts/doctrine.mjs +0 -62
  70. package/skills/engineering/resolve-issues/scripts/evidence-lifecycle-contract.mjs +0 -191
  71. package/skills/engineering/resolve-issues/scripts/exact-head-ci.mjs +0 -413
  72. package/skills/engineering/resolve-issues/scripts/exact-head-github-provider.mjs +0 -332
  73. package/skills/engineering/resolve-issues/scripts/finalize.mjs +0 -488
  74. package/skills/engineering/resolve-issues/scripts/fix-rounds.mjs +0 -3307
  75. package/skills/engineering/resolve-issues/scripts/fixtures/evidence-lifecycle-circular-1001.json +0 -16
  76. package/skills/engineering/resolve-issues/scripts/fixtures/evidence-lifecycle-valid-sequencing.json +0 -51
  77. package/skills/engineering/resolve-issues/scripts/fixtures/fmm-express-830-component-candidate.json +0 -17
  78. package/skills/engineering/resolve-issues/scripts/fixtures/head-check-set-1081.json +0 -166
  79. package/skills/engineering/resolve-issues/scripts/gate-value-series.mjs +0 -92
  80. package/skills/engineering/resolve-issues/scripts/guide-index.mjs +0 -73
  81. package/skills/engineering/resolve-issues/scripts/head-check-set.mjs +0 -159
  82. package/skills/engineering/resolve-issues/scripts/interference.mjs +0 -427
  83. package/skills/engineering/resolve-issues/scripts/model-catalog.mjs +0 -9
  84. package/skills/engineering/resolve-issues/scripts/next-operations.mjs +0 -419
  85. package/skills/engineering/resolve-issues/scripts/postmortem.mjs +0 -909
  86. package/skills/engineering/resolve-issues/scripts/preflight-questions.mjs +0 -345
  87. package/skills/engineering/resolve-issues/scripts/reconcile-contained-unit-prs.mjs +0 -415
  88. package/skills/engineering/resolve-issues/scripts/release-state-contract.mjs +0 -697
  89. package/skills/engineering/resolve-issues/scripts/report.mjs +0 -494
  90. package/skills/engineering/resolve-issues/scripts/required-check-plan.mjs +0 -172
  91. package/skills/engineering/resolve-issues/scripts/round-metadata.mjs +0 -79
  92. package/skills/engineering/resolve-issues/scripts/run-state-review6-cases.mjs +0 -334
  93. package/skills/engineering/resolve-issues/scripts/run-state.mjs +0 -4792
  94. package/skills/engineering/resolve-issues/scripts/sandbox-selftest.mjs +0 -395
  95. package/skills/engineering/resolve-issues/scripts/spawn-contract.mjs +0 -290
  96. package/skills/engineering/resolve-issues/scripts/terminal-dispositions.mjs +0 -170
  97. package/skills/engineering/resolve-issues/scripts/terminal-evidence-journal.mjs +0 -293
  98. package/skills/engineering/resolve-issues/scripts/unit-kind.mjs +0 -197
  99. package/skills/engineering/resolve-issues/scripts/unit-lifecycle.mjs +0 -127
  100. package/skills/engineering/resolve-issues/scripts/watch-delivery.mjs +0 -893
  101. package/skills/engineering/resolve-issues/scripts/workspaces.mjs +0 -829
  102. package/skills/engineering/resolve-issues/workflows/independent-review.workflow.js +0 -290
  103. package/skills/engineering/resolve-issues/workflows/prior-art-scan.workflow.js +0 -80
  104. package/skills/engineering/resolve-issues/workflows/workflow-smoke.mjs +0 -102
  105. package/skills/engineering/resolve-release/LICENSE +0 -3
  106. package/skills/engineering/resolve-release/SKILL.md +0 -113
  107. package/skills/engineering/resolve-release/agents/openai.yaml +0 -9
  108. package/skills/engineering/resolve-release/references/assembly.md +0 -137
  109. package/skills/engineering/resolve-release/references/auto-when-green.md +0 -56
  110. package/skills/engineering/resolve-release/references/candidate.md +0 -167
  111. package/skills/engineering/resolve-release/references/exposure.md +0 -178
  112. package/skills/engineering/resolve-release/references/handoff.md +0 -24
  113. package/skills/engineering/resolve-release/references/postmortem.md +0 -230
  114. package/skills/engineering/resolve-release/references/preflight.md +0 -208
  115. package/skills/engineering/resolve-release/references/principles.md +0 -94
  116. package/skills/engineering/resolve-release/references/regression-checklist.md +0 -36
  117. package/skills/engineering/resolve-release/references/related-skills.md +0 -13
  118. package/skills/engineering/resolve-release/references/routing.md +0 -149
  119. package/skills/engineering/resolve-release/references/verified-sha-github-flow.md +0 -285
  120. package/skills/engineering/resolve-release/references/versioning.md +0 -202
  121. package/skills/engineering/resolve-release/references/why.md +0 -53
  122. package/skills/engineering/resolve-release/scripts/adapter-completion-artifact.mjs +0 -389
  123. package/skills/engineering/resolve-release/scripts/build-changes.mjs +0 -209
  124. package/skills/engineering/resolve-release/scripts/candidate-hygiene.mjs +0 -407
  125. package/skills/engineering/resolve-release/scripts/candidate-identity.mjs +0 -904
  126. package/skills/engineering/resolve-release/scripts/candidate-traffic.mjs +0 -81
  127. package/skills/engineering/resolve-release/scripts/checked-adapter-loader.mjs +0 -612
  128. package/skills/engineering/resolve-release/scripts/close-attempt.mjs +0 -135
  129. package/skills/engineering/resolve-release/scripts/closeout-release.mjs +0 -161
  130. package/skills/engineering/resolve-release/scripts/doctrine.mjs +0 -106
  131. package/skills/engineering/resolve-release/scripts/durable-processing.mjs +0 -522
  132. package/skills/engineering/resolve-release/scripts/ensure-target-green.mjs +0 -659
  133. package/skills/engineering/resolve-release/scripts/evidence-bundle.mjs +0 -1014
  134. package/skills/engineering/resolve-release/scripts/finalize-release.mjs +0 -526
  135. package/skills/engineering/resolve-release/scripts/fixtures/durable-processing-adapter.mjs +0 -169
  136. package/skills/engineering/resolve-release/scripts/green-gate.mjs +0 -599
  137. package/skills/engineering/resolve-release/scripts/isolated-adapter-evaluator.mjs +0 -752
  138. package/skills/engineering/resolve-release/scripts/metadata-pr-status.mjs +0 -56
  139. package/skills/engineering/resolve-release/scripts/metadata-sync.mjs +0 -1538
  140. package/skills/engineering/resolve-release/scripts/postmortem.mjs +0 -381
  141. package/skills/engineering/resolve-release/scripts/preflight-probes.mjs +0 -510
  142. package/skills/engineering/resolve-release/scripts/production-endpoints.mjs +0 -326
  143. package/skills/engineering/resolve-release/scripts/rc-circuit-breaker.mjs +0 -272
  144. package/skills/engineering/resolve-release/scripts/report.mjs +0 -417
  145. package/skills/engineering/resolve-release/scripts/reprobe-credentials.mjs +0 -114
  146. package/skills/engineering/resolve-release/scripts/revalidate-candidate.mjs +0 -238
  147. package/skills/engineering/resolve-release/scripts/review-packet.mjs +0 -503
  148. package/skills/engineering/resolve-release/scripts/rollback-floor.mjs +0 -263
  149. package/skills/engineering/resolve-release/scripts/version-assert.mjs +0 -339
  150. package/skills/engineering/resolve-release/scripts/version-postmortem.mjs +0 -485
  151. package/skills/engineering/resolve-release/scripts/version.mjs +0 -1199
  152. package/skills/engineering/resolve-release/scripts/watch-candidate-delivery.mjs +0 -449
  153. package/skills/engineering/resolve-release/vendor/ACORN-LICENSE +0 -21
  154. package/skills/engineering/resolve-release/vendor/README.md +0 -60
  155. package/skills/engineering/resolve-release/vendor/acorn.mjs +0 -6233
  156. package/skills/engineering/resolve-release/vendor/quickjs/core/LICENSE +0 -21
  157. package/skills/engineering/resolve-release/vendor/quickjs/core/README.md +0 -341
  158. package/skills/engineering/resolve-release/vendor/quickjs/core/README.template.md +0 -70
  159. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-TAV5CUKK.mjs +0 -2
  160. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-TAV5CUKK.mjs.map +0 -1
  161. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-V2S4ZYJR.mjs +0 -7
  162. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-V2S4ZYJR.mjs.map +0 -1
  163. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.d.mts +0 -2033
  164. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.d.ts +0 -2033
  165. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.js +0 -7
  166. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.js.map +0 -1
  167. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.mjs +0 -2
  168. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.mjs.map +0 -1
  169. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-ES6BEMUI.mjs +0 -2
  170. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-ES6BEMUI.mjs.map +0 -1
  171. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-asyncify-2EFITU5U.mjs +0 -2
  172. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-asyncify-2EFITU5U.mjs.map +0 -1
  173. package/skills/engineering/resolve-release/vendor/quickjs/core/package.json +0 -49
  174. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/LICENSE +0 -21
  175. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/README.md +0 -5
  176. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.d.mts +0 -549
  177. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.d.ts +0 -549
  178. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.js +0 -2
  179. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.js.map +0 -1
  180. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.mjs +0 -2
  181. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.mjs.map +0 -1
  182. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/package.json +0 -36
  183. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/LICENSE +0 -47
  184. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/README.md +0 -82
  185. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.browser.d.ts +0 -11
  186. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.browser.mjs +0 -22
  187. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.cjs +0 -21
  188. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.cloudflare.cjs +0 -21
  189. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.cloudflare.d.ts +0 -11
  190. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.d.ts +0 -11
  191. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.mjs +0 -25
  192. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.wasm +0 -0
  193. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.d.mts +0 -85
  194. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.d.ts +0 -85
  195. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.js +0 -2
  196. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.js.map +0 -1
  197. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.mjs +0 -2
  198. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.mjs.map +0 -1
  199. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.d.mts +0 -20
  200. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.d.ts +0 -20
  201. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.js +0 -2
  202. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.js.map +0 -1
  203. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.mjs +0 -2
  204. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.mjs.map +0 -1
  205. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/package.json +0 -61
  206. /package/skills/{engineering/resolve-issues → payment/tld-detection}/LICENSE +0 -0
@@ -0,0 +1,69 @@
1
+ # Terminology
2
+
3
+ Use these terms in TLD reports. Read before writing. After writing, run
4
+ the proofread in `SKILL.md`.
5
+
6
+ TLD in this skill is **transaction laundering**. Spell it out on first
7
+ use. It is not a top-level domain.
8
+
9
+ ## Canonical glossary
10
+
11
+ | English | Chinese | Do not write |
12
+ |---|---|---|
13
+ | TLD (transaction laundering) | 交易清洗 / TLD | 把 TLD 写成域名;洗钱结案 |
14
+ | potential TLD currency-amount pair | 潜在 TLD 币种金额对 | 已确认 TLD;TLD 商户 |
15
+ | cluster (report section) | 币种金额对 | 客户簇;客群;分群结果 |
16
+ | anchor detection | 锚点识别 | 客户聚类(对业务读者) |
17
+ | currency-amount-pair | 币种金额对 | 金额币种对;金额对;price point(作指标名时) |
18
+ | descriptor partition | descriptor 分区 | 客群;BIN 国家分区 |
19
+ | BIN-country attribution component | BIN 国家归因组件 | 客户国家;成员分区 |
20
+ | geometric anchor family | 币种金额对 | 阶梯(业务报告中);价格带;SKU |
21
+ | stuffing | 掺量 | 注水(可括注一次);corridor |
22
+ | MCC miscoding | MCC 错配 | 改 MCC;纠正 MCC |
23
+ | descriptor | descriptor / 账单描述 | 商户名(指 descriptor 时) |
24
+ | baseline | 基线 | 对照组(可括注);everything else |
25
+ | all authorization attempts | 全部授权尝试 | U(业务报告中);原始全量 |
26
+ | US-BIN | US BIN / 美国发卡 | 美国交易(指卡BIN时) |
27
+ | attempt | 授权尝试 | 交易(指授权行时) |
28
+ | approval | 授权成功 | 批准 |
29
+ | auth rate | 授权成功率 | 转化率 |
30
+ | suspected fraud | 疑似欺诈 | 已确认欺诈;正文里写 D102(表内代码可留) |
31
+ | BIN country / issuer country | 发卡行国家 / BIN 国家 | 客户国家;收货国;发卡国 |
32
+ | cluster composition by BIN country | BIN 国家占 Cluster | 客户国家占比 |
33
+ | descriptor-country slice coverage | Cluster 占 Descriptor 对应 BIN 国家 | BIN 国家风险率;国家命中率 |
34
+ | exact single-country standout | 100% 单一 BIN 国家 standout | 高国家占比;接近 100% |
35
+ | BIN-country local hour | BIN 国家当地小时 | 客户所在地时间;统一美国东部时间 |
36
+ | this analysis window | 当前分析时段内 | 本窗口 |
37
+ | MCC vs descriptor | MCC 与 descriptor 合理性 | 纠正 MCC;改 MCC |
38
+ | verdict | 结论 | 长文综述(作结论部分时) |
39
+ | High / Medium / Low risk | 高风险 / 中风险 / 低风险 | Supported;Weak;Ladder only;已确认 TLD |
40
+ | risk exposure | 风险敞口 | 已确认 TLD 影响;定罪金额 |
41
+
42
+ Keep proper nouns: Visa, MasterCard, BIN, IIN, MCC, MID, Worldpay,
43
+ Pazien, Order ID, descriptor. Tables may show response codes; prose uses
44
+ 疑似欺诈 / suspected fraud.
45
+
46
+ ## Axis and UI
47
+
48
+ - Amount axes name the **original** currency of that cluster
49
+ (`授权尝试金额 USD`, `授权尝试金额 EUR`). Do not mix currencies on one
50
+ axis.
51
+ - Date charts use each series' share of attempts by UTC transaction date.
52
+ - Time charts use each BIN country's local hour, falling back to UTC when no
53
+ timezone is configured. Normalize Cluster and Baseline independently.
54
+ - HTML footer: data-source filenames only. No skill name.
55
+
56
+ ## Banned strings (grep after writing)
57
+
58
+ Chinese: `走廊`, `杠杆`, `转化率`, `赋能`, `抓手`, `闭环`, `打法`,
59
+ `本窗口`, `已确认 TLD`, `已确认交易清洗`, `洗钱`, `发卡国`, `客群运营`,
60
+ `客户簇`, `金额币种对`
61
+
62
+ English: `confirmed laundering`, `confirmed TLD`, `money laundering`,
63
+ `SAR`, `file a SAR`, `top-level domain`, `customer country`,
64
+ `conversion rate` (when meaning auth rate), `corridor`, `price band`
65
+ (as the cluster name)
66
+
67
+ `洗钱` is banned as a finding. If a source document uses the legal term,
68
+ quote it in supporting evidence and keep the report on potential TLD
69
+ currency-amount pairs.
@@ -0,0 +1,78 @@
1
+ # Workflow detail
2
+
3
+ SKILL.md is the index. This file is the procedure those steps run.
4
+
5
+ ## Input
6
+
7
+ Typical file: `Authorizations_*.csv`. Grain: **one authorization attempt**.
8
+
9
+ Amount, currency, and descriptor columns are required. BIN/issuer country,
10
+ issuing bank, MCC, and response are expected for characteristics; a missing
11
+ optional column is captioned. Missing descriptor values enter an explicit
12
+ partition bucket; missing BIN-country values enter a reporting bucket after
13
+ membership is frozen. Do not drop those rows.
14
+
15
+ Settlements and chargebacks are not the clustering universe. Do not join
16
+ them into `U`. If only settlements exist, stop and say the clustering
17
+ grain is auth attempts.
18
+
19
+ ## Column aliases
20
+
21
+ Use the first present name. Record the alias actually used.
22
+
23
+ | Role | Common names |
24
+ |---|---|
25
+ | Time | `Timestamp`, `Auth timestamp`, `Transaction date` |
26
+ | Amount | `Amount`, `Transaction amount`, `Requested amount` |
27
+ | Currency | `Currency`, `Transaction currency`, `Auth currency` |
28
+ | BIN / issuer country | `BIN country`, `IIN country`, `Issuer country`, `Card country` |
29
+ | Issuing bank | `Issuing bank`, `Issuer`, `Issuer name`, `Bank name` |
30
+ | Descriptor | `Billing descriptor`, `Descriptor`, `Soft descriptor`, `Submerchant`, `DBA` |
31
+ | MCC | `MCC`, `Merchant category code` |
32
+ | Response | `Response code`, `Response message`, `Auth response` |
33
+ | Identity | `Order ID`, `Transaction ID`, `Auth ID` |
34
+ | Card | `IIN`, `BIN`, `Payment method`, `Card type`, masked `Account` |
35
+
36
+ Portfolio constants (MID, acquirer, entry method) are recorded once. They
37
+ are not partition or clustering keys.
38
+
39
+ ## Window
40
+
41
+ Default: min→max of the time field on the **cleaned** universe. If the user
42
+ names a week or month, cut there and state it.
43
+
44
+ ## Response classes
45
+
46
+ Recompute from the file. Pazien-style defaults when those codes exist:
47
+
48
+ | Class | Typical |
49
+ |---|---|
50
+ | Approval | A001 / message contains approval |
51
+ | Suspected fraud | D102 / message contains suspected fraud |
52
+ | Other decline | remaining non-approvals |
53
+
54
+ Suspected-fraud decline ≠ confirmed fraud loss. Do not treat D102 as a
55
+ positive label for `fraud-analysis`.
56
+
57
+ ## What this skill is looking for
58
+
59
+ Transaction laundering in this book usually shows up as:
60
+
61
+ 1. **MCC miscoding** — different kinds of goods mixed under one “normal” MCC
62
+ 2. **Descriptor mixing** — several customer types share a billing name, or
63
+ one TLD customer is split across several descriptors
64
+ 3. **Stuffing / 掺量** — a geometric amount ladder riding the same MCC as
65
+ legitimate traffic
66
+
67
+ Descriptor defines the initial partition, while BIN country and MCC do not.
68
+ Geometrically related currency-amount anchors inside each descriptor define
69
+ membership. BIN country and issuing bank are reported only after membership
70
+ is frozen.
71
+
72
+ ## Order that must not move
73
+
74
+ Clean → descriptor partition → currency-amount pairs → same-descriptor baseline → decide → report.
75
+
76
+ Comparison features must not create a cluster that amount detection did not
77
+ find. Decision does not add evidence; it only writes the ≤3-sentence
78
+ conclusion.
@@ -0,0 +1,390 @@
1
+ #!/usr/bin/env python3
2
+ """Detect exact currency-amount-pair ladders with absolute rung residuals."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import csv
7
+ import json
8
+ import re
9
+ from collections import Counter, defaultdict
10
+ from dataclasses import dataclass
11
+ from decimal import Decimal
12
+ from pathlib import Path
13
+ from typing import Iterable, Mapping, Sequence
14
+
15
+
16
+ Pair = tuple[str, Decimal]
17
+ Partition = str
18
+ CLUSTER_MULTIPLIER = Decimal("2")
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class LadderConfig:
23
+ absolute_caps: Mapping[str, Decimal]
24
+ min_levels: int = 3
25
+ min_cluster_attempts: int = 100
26
+ multi_anchor_min_attempts: int = 24
27
+
28
+
29
+ def _rank(keys: Iterable[Pair], counts: Mapping[Pair, int]) -> list[Pair]:
30
+ return sorted(keys, key=lambda key: (-counts[key], key[1], key[0]))
31
+
32
+
33
+ def _residual(lower: Pair, upper: Pair, multiplier: Decimal) -> Decimal:
34
+ return abs(upper[1] - lower[1] * multiplier)
35
+
36
+
37
+ def _expand_levels(
38
+ seed: Pair,
39
+ multiplier: Decimal,
40
+ available: set[Pair],
41
+ counts: Mapping[Pair, int],
42
+ cap: Decimal,
43
+ multi_anchor_min_attempts: int,
44
+ ) -> dict[int, set[Pair]]:
45
+ """Expand from a seed; every retained key is an explicit exact anchor."""
46
+ levels: dict[int, set[Pair]] = {0: {seed}}
47
+ claimed = {seed}
48
+ for direction in (1, -1):
49
+ previous = {seed}
50
+ for step in range(1, 21):
51
+ if direction == 1:
52
+ candidates = {
53
+ candidate
54
+ for candidate in available - claimed
55
+ if candidate[0] == seed[0]
56
+ and any(_residual(anchor, candidate, multiplier) <= cap for anchor in previous)
57
+ }
58
+ else:
59
+ candidates = {
60
+ candidate
61
+ for candidate in available - claimed
62
+ if candidate[0] == seed[0]
63
+ and any(_residual(candidate, anchor, multiplier) <= cap for anchor in previous)
64
+ }
65
+ if not candidates:
66
+ break
67
+
68
+ def candidate_residual(candidate: Pair) -> Decimal:
69
+ if direction == 1:
70
+ return min(_residual(anchor, candidate, multiplier) for anchor in previous)
71
+ return min(_residual(candidate, anchor, multiplier) for anchor in previous)
72
+
73
+ primary = min(candidates, key=lambda candidate: (-counts[candidate], candidate_residual(candidate), candidate[1]))
74
+ next_level = {primary} | {
75
+ candidate for candidate in candidates if counts[candidate] >= multi_anchor_min_attempts
76
+ }
77
+ levels[direction * step] = next_level
78
+ claimed.update(next_level)
79
+ previous = next_level
80
+ return levels
81
+
82
+
83
+ def _fit_residual(levels: Mapping[int, set[Pair]], multiplier: Decimal) -> Decimal:
84
+ total = Decimal("0")
85
+ for level, anchors in levels.items():
86
+ if level > 0:
87
+ total += sum(min(_residual(lower, anchor, multiplier) for lower in levels[level - 1]) for anchor in anchors)
88
+ elif level < 0:
89
+ total += sum(min(_residual(anchor, upper, multiplier) for upper in levels[level + 1]) for anchor in anchors)
90
+ return total
91
+
92
+
93
+ def detect_ladders(
94
+ counts: Mapping[Pair, int], universe_count: int, config: LadderConfig
95
+ ) -> tuple[list[dict[str, object]], dict[str, object]]:
96
+ """Return disjoint ladders and deterministic threshold metadata."""
97
+ if universe_count <= 0:
98
+ raise ValueError("universe_count must be positive")
99
+ normalized = Counter({(currency.upper(), Decimal(amount)): count for (currency, amount), count in counts.items()})
100
+ high_threshold = max(30, 0.005 * universe_count)
101
+ rung_threshold = max(5, 0.001 * universe_count)
102
+ ranked = _rank(normalized, normalized)
103
+ global_top = set(ranked[:25])
104
+ by_currency: dict[str, list[Pair]] = defaultdict(list)
105
+ for key in normalized:
106
+ by_currency[key[0]].append(key)
107
+ high = {key for key, count in normalized.items() if count >= high_threshold} | global_top
108
+ for currency, keys in by_currency.items():
109
+ currency_top = _rank(keys, normalized)[:10]
110
+ high.update(key for key in currency_top if normalized[key] >= 30)
111
+
112
+ rung_pool = {key for key, count in normalized.items() if count >= rung_threshold}
113
+ rung_currencies = {currency for currency, _ in rung_pool}
114
+ missing_caps = sorted(rung_currencies - set(config.absolute_caps))
115
+ if missing_caps:
116
+ raise ValueError(f"missing absolute residual cap for rung-pool currencies: {', '.join(missing_caps)}")
117
+ unused = set(rung_pool)
118
+ found: list[dict[str, object]] = []
119
+ below_threshold: dict[frozenset[Pair], dict[str, object]] = {}
120
+ for seed in _rank(rung_pool, normalized):
121
+ if seed not in unused:
122
+ continue
123
+ candidates: list[tuple[tuple[object, ...], dict[int, set[Pair]], Decimal, Decimal]] = []
124
+ for multiplier in (CLUSTER_MULTIPLIER,):
125
+ cap = config.absolute_caps[seed[0]]
126
+ levels = _expand_levels(
127
+ seed,
128
+ multiplier,
129
+ unused,
130
+ normalized,
131
+ cap,
132
+ config.multi_anchor_min_attempts,
133
+ )
134
+ level_count = len(levels)
135
+ if level_count < max(3, config.min_levels):
136
+ continue
137
+ family = set().union(*levels.values())
138
+ fit_residual = _fit_residual(levels, multiplier)
139
+ score = (
140
+ level_count,
141
+ sum(normalized[key] for key in family),
142
+ -fit_residual,
143
+ )
144
+ candidates.append((score, levels, multiplier, fit_residual))
145
+ if not candidates:
146
+ continue
147
+ _, levels, multiplier, fit_residual = max(candidates, key=lambda item: item[0])
148
+ family = set().union(*levels.values())
149
+ candidate = {
150
+ "seed": seed,
151
+ "multiplier": multiplier,
152
+ "levels": levels,
153
+ "pairs": family,
154
+ "rule": "adjacent-absolute-residual",
155
+ "absolute_cap": config.absolute_caps[seed[0]],
156
+ "fit_residual": fit_residual,
157
+ "multi_anchor_levels": {level: anchors for level, anchors in levels.items() if len(anchors) > 1},
158
+ "alternatives": {},
159
+ }
160
+ family_attempts = sum(normalized[key] for key in family)
161
+ if family_attempts < config.min_cluster_attempts:
162
+ below_threshold.setdefault(frozenset(family), {**candidate, "attempts": family_attempts})
163
+ continue
164
+ found.append(candidate)
165
+ unused -= family
166
+
167
+ found.sort(key=lambda cluster: -sum(normalized[key] for key in cluster["pairs"]))
168
+ for index, cluster in enumerate(found, 1):
169
+ cluster["id"] = f"C{index}"
170
+ metadata = {
171
+ "method": "adjacent-absolute-residual",
172
+ "absolute_caps": dict(config.absolute_caps),
173
+ "high": high_threshold,
174
+ "rung": rung_threshold,
175
+ "high_pairs": _rank(high, normalized),
176
+ "rung_pool_size": len(rung_pool),
177
+ "pair_count": len(normalized),
178
+ "below_threshold": sorted(below_threshold.values(), key=lambda item: (-item["attempts"], item["seed"])),
179
+ }
180
+ return found, metadata
181
+
182
+
183
+ def detect_partitioned_ladders(
184
+ entries: Iterable[tuple[Partition, Pair]],
185
+ config: LadderConfig,
186
+ ) -> tuple[Counter[tuple[Partition, Pair]], list[dict[str, object]], dict[str, object]]:
187
+ """Detect ladders independently inside frozen descriptor partitions."""
188
+ counts: Counter[tuple[Partition, Pair]] = Counter(entries)
189
+ partition_counts: Counter[Partition] = Counter()
190
+ pair_counts: dict[Partition, Counter[Pair]] = defaultdict(Counter)
191
+ for (partition, pair), count in counts.items():
192
+ partition_counts[partition] += count
193
+ pair_counts[partition][pair] += count
194
+
195
+ found: list[dict[str, object]] = []
196
+ partition_metadata: dict[Partition, dict[str, object]] = {}
197
+ high_entries: list[dict[str, object]] = []
198
+ for partition in sorted(partition_counts):
199
+ ladders, metadata = detect_ladders(pair_counts[partition], partition_counts[partition], config)
200
+ partition_metadata[partition] = metadata
201
+ for ladder in ladders:
202
+ ladder["partition"] = partition
203
+ ladder["partition_attempts"] = partition_counts[partition]
204
+ found.append(ladder)
205
+ for pair in metadata["high_pairs"]:
206
+ high_entries.append(
207
+ {
208
+ "partition": partition,
209
+ "pair": pair,
210
+ "attempts": pair_counts[partition][pair],
211
+ "partition_attempts": partition_counts[partition],
212
+ }
213
+ )
214
+
215
+ found.sort(key=lambda ladder: -sum(counts[(ladder["partition"], pair)] for pair in ladder["pairs"]))
216
+ for index, ladder in enumerate(found, 1):
217
+ ladder["id"] = f"C{index}"
218
+ high_entries.sort(key=lambda item: (-item["attempts"], item["pair"][1], item["partition"]))
219
+ return counts, found, {
220
+ "method": "partitioned-adjacent-absolute-residual",
221
+ "absolute_caps": dict(config.absolute_caps),
222
+ "min_cluster_attempts": config.min_cluster_attempts,
223
+ "partition_counts": partition_counts,
224
+ "partitions": partition_metadata,
225
+ "high_entries": high_entries,
226
+ }
227
+
228
+
229
+ def sensitivity_analysis(
230
+ counts: Mapping[Pair, int],
231
+ universe_count: int,
232
+ config: LadderConfig,
233
+ currency: str,
234
+ caps: Sequence[Decimal],
235
+ ) -> list[dict[str, object]]:
236
+ """Run one-currency cap sensitivity while holding all other settings fixed."""
237
+ output: list[dict[str, object]] = []
238
+ currency = currency.upper()
239
+ for cap in caps:
240
+ varied_caps = dict(config.absolute_caps)
241
+ varied_caps[currency] = cap
242
+ ladders, _ = detect_ladders(
243
+ counts,
244
+ universe_count,
245
+ LadderConfig(
246
+ varied_caps,
247
+ min_levels=config.min_levels,
248
+ min_cluster_attempts=config.min_cluster_attempts,
249
+ multi_anchor_min_attempts=config.multi_anchor_min_attempts,
250
+ ),
251
+ )
252
+ output.append(
253
+ {
254
+ "currency": currency,
255
+ "cap": cap,
256
+ "ladder_count": len(ladders),
257
+ "covered_attempts": sum(sum(counts[key] for key in ladder["pairs"]) for ladder in ladders),
258
+ "ladders": ladders,
259
+ }
260
+ )
261
+ return output
262
+
263
+
264
+ def partitioned_sensitivity_analysis(
265
+ entries: Iterable[tuple[Partition, Pair]],
266
+ config: LadderConfig,
267
+ currency: str,
268
+ caps: Sequence[Decimal],
269
+ ) -> list[dict[str, object]]:
270
+ """Run cap sensitivity over frozen partitions."""
271
+ entries = tuple(entries)
272
+ output: list[dict[str, object]] = []
273
+ currency = currency.upper()
274
+ for cap in caps:
275
+ varied_caps = dict(config.absolute_caps)
276
+ varied_caps[currency] = cap
277
+ counts, ladders, _ = detect_partitioned_ladders(
278
+ entries,
279
+ LadderConfig(
280
+ varied_caps,
281
+ min_levels=config.min_levels,
282
+ min_cluster_attempts=config.min_cluster_attempts,
283
+ multi_anchor_min_attempts=config.multi_anchor_min_attempts,
284
+ ),
285
+ )
286
+ output.append(
287
+ {
288
+ "currency": currency,
289
+ "cap": cap,
290
+ "ladder_count": len(ladders),
291
+ "covered_attempts": sum(sum(counts[(ladder["partition"], key)] for key in ladder["pairs"]) for ladder in ladders),
292
+ "ladders": ladders,
293
+ }
294
+ )
295
+ return output
296
+
297
+
298
+ def normalize_descriptor(value: str) -> str:
299
+ return re.sub(r"\s+", " ", value.strip()).casefold() or "UNKNOWN_DESCRIPTOR"
300
+
301
+
302
+ def normalize_bin_country(value: str) -> str:
303
+ normalized = re.sub(r"\s+", " ", value.strip()).upper()
304
+ aliases = {
305
+ "UNITED STATES": "US",
306
+ "UNITED STATES OF AMERICA": "US",
307
+ "USA": "US",
308
+ "UNITED KINGDOM": "GB",
309
+ "GREAT BRITAIN": "GB",
310
+ }
311
+ return aliases.get(normalized, normalized or "UNKNOWN_BIN_COUNTRY")
312
+
313
+
314
+ def _parse_cap(value: str) -> tuple[str, Decimal]:
315
+ try:
316
+ currency, amount = value.split("=", 1)
317
+ cap = Decimal(amount)
318
+ except Exception as exc:
319
+ raise argparse.ArgumentTypeError("cap must be CURRENCY=AMOUNT") from exc
320
+ if not currency.strip() or cap < 0:
321
+ raise argparse.ArgumentTypeError("cap must use a currency and a non-negative amount")
322
+ return currency.strip().upper(), cap
323
+
324
+
325
+ def _json_value(value: object) -> object:
326
+ if isinstance(value, Decimal):
327
+ return format(value, "f")
328
+ if isinstance(value, tuple) and len(value) == 2:
329
+ return {"currency": value[0], "amount": _json_value(value[1])}
330
+ if isinstance(value, set):
331
+ return [_json_value(item) for item in sorted(value)]
332
+ if isinstance(value, dict):
333
+ return {str(key): _json_value(item) for key, item in value.items()}
334
+ if isinstance(value, list):
335
+ return [_json_value(item) for item in value]
336
+ return value
337
+
338
+
339
+ def main() -> None:
340
+ parser = argparse.ArgumentParser(description=__doc__)
341
+ parser.add_argument("csv_path", type=Path)
342
+ parser.add_argument("--currency-column", required=True)
343
+ parser.add_argument("--amount-column", required=True)
344
+ parser.add_argument("--descriptor-column", required=True)
345
+ parser.add_argument("--bin-country-column")
346
+ parser.add_argument("--cap", action="append", default=[], type=_parse_cap, metavar="CCY=AMOUNT")
347
+ parser.add_argument("--sensitivity-cap", action="append", default=[], type=_parse_cap, metavar="CCY=AMOUNT")
348
+ parser.add_argument("--multi-anchor-min-attempts", type=int, default=24)
349
+ parser.add_argument("--output", type=Path)
350
+ args = parser.parse_args()
351
+
352
+ entries: list[tuple[Partition, Pair]] = []
353
+ country_counts: Counter[tuple[Partition, Pair, str]] = Counter()
354
+ with args.csv_path.open(encoding="utf-8-sig", newline="") as handle:
355
+ for row in csv.DictReader(handle):
356
+ partition = normalize_descriptor(row[args.descriptor_column])
357
+ country = normalize_bin_country(row[args.bin_country_column]) if args.bin_country_column else "UNKNOWN_BIN_COUNTRY"
358
+ entries.append((partition, (row[args.currency_column].strip().upper(), Decimal(row[args.amount_column].strip()))))
359
+ country_counts[(partition, entries[-1][1], country)] += 1
360
+ config = LadderConfig(
361
+ dict(args.cap),
362
+ multi_anchor_min_attempts=args.multi_anchor_min_attempts,
363
+ )
364
+ _, ladders, metadata = detect_partitioned_ladders(entries, config)
365
+ for ladder in ladders:
366
+ components: Counter[str] = Counter()
367
+ for pair in ladder["pairs"]:
368
+ for (descriptor, candidate, country), count in country_counts.items():
369
+ if descriptor == ladder["partition"] and candidate == pair:
370
+ components[country] += count
371
+ ladder["bin_country_components"] = dict(components.most_common())
372
+ sensitivity: dict[str, list[dict[str, object]]] = {}
373
+ sensitivity_args: dict[str, list[Decimal]] = defaultdict(list)
374
+ for currency, cap in args.sensitivity_cap:
375
+ sensitivity_args[currency].append(cap)
376
+ for currency, caps in sensitivity_args.items():
377
+ sensitivity[currency] = partitioned_sensitivity_analysis(entries, config, currency, caps)
378
+ payload = json.dumps(
379
+ _json_value({"metadata": metadata, "ladders": ladders, "sensitivity": sensitivity}),
380
+ ensure_ascii=False,
381
+ indent=2,
382
+ )
383
+ if args.output:
384
+ args.output.write_text(payload + "\n", encoding="utf-8")
385
+ else:
386
+ print(payload)
387
+
388
+
389
+ if __name__ == "__main__":
390
+ main()
@@ -0,0 +1,132 @@
1
+ from collections import Counter
2
+ import csv
3
+ from decimal import Decimal
4
+ import importlib.util
5
+ import json
6
+ from pathlib import Path
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import unittest
11
+
12
+
13
+ sys.dont_write_bytecode = True
14
+ SCRIPT = Path(__file__).parents[1] / "scripts" / "detect_ladders.py"
15
+ SPEC = importlib.util.spec_from_file_location("detect_ladders", SCRIPT)
16
+ MODULE = importlib.util.module_from_spec(SPEC)
17
+ assert SPEC.loader is not None
18
+ sys.modules[SPEC.name] = MODULE
19
+ SPEC.loader.exec_module(MODULE)
20
+
21
+
22
+ def pair(amount: str) -> tuple[str, Decimal]:
23
+ return "USD", Decimal(amount)
24
+
25
+
26
+ class DetectLaddersTest(unittest.TestCase):
27
+ def detect(self, values: dict[str, int], cap: str = "0.15"):
28
+ counts = Counter({pair(amount): count for amount, count in values.items()})
29
+ return MODULE.detect_ladders(
30
+ counts,
31
+ sum(counts.values()),
32
+ MODULE.LadderConfig({"USD": Decimal(cap)}),
33
+ )[0]
34
+
35
+ def test_detects_tight_exact_ladder(self):
36
+ ladders = self.detect({"28.03": 131, "56.04": 117, "112.09": 65, "224.19": 27, "70.00": 100})
37
+ amounts = {amount for _, amount in ladders[0]["pairs"]}
38
+ self.assertEqual(amounts, {Decimal("28.03"), Decimal("56.04"), Decimal("112.09"), Decimal("224.19")})
39
+
40
+ def test_keeps_multiple_exact_anchors_at_one_level(self):
41
+ ladders = self.detect({"14.41": 34, "14.52": 25, "28.94": 43, "57.96": 29, "80.00": 100})
42
+ levels = next(ladder["levels"] for ladder in ladders if pair("28.94") in ladder["pairs"])
43
+ self.assertEqual(levels[-1], {pair("14.41"), pair("14.52")})
44
+
45
+ def test_rejects_upper_amount_outside_absolute_cap(self):
46
+ ladders = self.detect({"14.41": 34, "28.94": 43, "57.96": 29, "116.85": 29, "80.00": 100})
47
+ ladder = next(ladder for ladder in ladders if pair("14.41") in ladder["pairs"])
48
+ self.assertNotIn(pair("116.85"), ladder["pairs"])
49
+
50
+ def test_requires_explicit_currency_cap(self):
51
+ with self.assertRaisesRegex(ValueError, "missing absolute residual cap"):
52
+ MODULE.detect_ladders(Counter({pair("10"): 10}), 10, MODULE.LadderConfig({}))
53
+
54
+ def test_ignores_currency_below_rung_pool_threshold(self):
55
+ counts = Counter({pair("10"): 1000, ("EUR", Decimal("9.99")): 1})
56
+ ladders, _ = MODULE.detect_ladders(counts, 1001, MODULE.LadderConfig({"USD": Decimal("0.15")}))
57
+ self.assertEqual(ladders, [])
58
+
59
+ def test_sensitivity_reports_coverage_by_cap(self):
60
+ counts = Counter({pair("14.41"): 34, pair("28.94"): 43, pair("57.96"): 29})
61
+ rows = MODULE.sensitivity_analysis(
62
+ counts,
63
+ sum(counts.values()),
64
+ MODULE.LadderConfig({"USD": Decimal("0.15")}),
65
+ "USD",
66
+ [Decimal("0.10"), Decimal("0.15")],
67
+ )
68
+ self.assertEqual([row["ladder_count"] for row in rows], [0, 1])
69
+
70
+ def test_keeps_below_threshold_family_out_of_clusters(self):
71
+ counts = Counter({pair("10"): 32, pair("20"): 32, pair("40"): 32})
72
+ ladders, metadata = MODULE.detect_ladders(
73
+ counts,
74
+ 96,
75
+ MODULE.LadderConfig({"USD": Decimal("0.15")}),
76
+ )
77
+ self.assertEqual(ladders, [])
78
+ self.assertEqual(metadata["below_threshold"][0]["attempts"], 96)
79
+
80
+ def test_same_pair_can_belong_to_different_partitions(self):
81
+ entries = []
82
+ amounts = (("10", 40), ("20", 40), ("40", 40))
83
+ for partition in ("alpha", "beta"):
84
+ for amount, count in amounts:
85
+ entries.extend([(partition, pair(amount))] * count)
86
+ _, ladders, _ = MODULE.detect_partitioned_ladders(
87
+ entries,
88
+ MODULE.LadderConfig({"USD": Decimal("0.15")}),
89
+ )
90
+ self.assertEqual(len(ladders), 2)
91
+ self.assertEqual({ladder["partition"] for ladder in ladders}, {"alpha", "beta"})
92
+
93
+ def test_requires_at_least_three_levels(self):
94
+ self.assertEqual(self.detect({"10": 60, "20": 60, "70": 100}), [])
95
+
96
+ def test_rejects_non_two_x_relationships(self):
97
+ self.assertEqual(self.detect({"10": 40, "30": 40, "90": 40}), [])
98
+
99
+ def test_cli_attributes_bin_country_after_descriptor_ladder(self):
100
+ with tempfile.TemporaryDirectory() as directory:
101
+ csv_path = Path(directory) / "clean.csv"
102
+ with csv_path.open("w", encoding="utf-8", newline="") as handle:
103
+ writer = csv.DictWriter(handle, fieldnames=["descriptor", "country", "currency", "amount"])
104
+ writer.writeheader()
105
+ for country in ("US", "GB"):
106
+ for amount in ("10", "20", "40"):
107
+ writer.writerows(
108
+ {"descriptor": "ACME", "country": country, "currency": "USD", "amount": amount}
109
+ for _ in range(20)
110
+ )
111
+ result = subprocess.run(
112
+ [
113
+ sys.executable,
114
+ str(SCRIPT),
115
+ str(csv_path),
116
+ "--descriptor-column", "descriptor",
117
+ "--bin-country-column", "country",
118
+ "--currency-column", "currency",
119
+ "--amount-column", "amount",
120
+ "--cap", "USD=0.15",
121
+ ],
122
+ check=True,
123
+ capture_output=True,
124
+ text=True,
125
+ )
126
+ payload = json.loads(result.stdout)
127
+ self.assertEqual(len(payload["ladders"]), 1)
128
+ self.assertEqual(payload["ladders"][0]["bin_country_components"], {"US": 60, "GB": 60})
129
+
130
+
131
+ if __name__ == "__main__":
132
+ unittest.main()