@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.
- package/README.md +9 -4
- package/package.json +1 -1
- package/runtime/skills/distribution/generated/recipes.json +54 -48
- package/runtime/skills/distribution/scripts/bundles.mjs +113 -7
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -6
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +3 -36
- package/skills/cicd-pipeline/cloud-build/SKILL.md +6 -6
- package/skills/cicd-pipeline/mergify/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +15 -84
- package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -6
- package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +3 -36
- package/skills/engineering/rush-release/SKILL.md +46 -25
- package/skills/engineering/rush-release/evals/evals.json +22 -8
- package/skills/engineering/rush-release/references/cut.md +21 -4
- package/skills/engineering/rush-release/references/preflight.md +25 -6
- package/skills/engineering/rush-release/references/promotion.md +70 -0
- package/skills/engineering/rush-release/references/publish.md +49 -16
- package/skills/engineering/rush-release/scripts/plan.mjs +33 -7
- package/skills/engineering/smoke/SKILL.md +4 -4
- package/skills/engineering/smoke/references/manifest.md +3 -3
- package/skills/payment/payment-analysis/SKILL.md +5 -3
- package/skills/payment/tld-detection/SKILL.md +163 -0
- package/skills/payment/tld-detection/agents/openai.yaml +9 -0
- package/skills/payment/tld-detection/evals/evals.json +107 -0
- package/skills/payment/tld-detection/references/clean.md +51 -0
- package/skills/payment/tld-detection/references/clustering.md +219 -0
- package/skills/payment/tld-detection/references/decision.md +85 -0
- package/skills/payment/tld-detection/references/report-template.md +169 -0
- package/skills/payment/tld-detection/references/terminology.md +69 -0
- package/skills/payment/tld-detection/references/workflow.md +78 -0
- package/skills/payment/tld-detection/scripts/detect_ladders.py +390 -0
- package/skills/payment/tld-detection/tests/test_detect_ladders.py +132 -0
- package/skills/engineering/resolve-issues/SKILL.md +0 -168
- package/skills/engineering/resolve-issues/agents/openai.yaml +0 -9
- package/skills/engineering/resolve-issues/generated/workflow-repair-policy.json +0 -499
- package/skills/engineering/resolve-issues/references/breaker.md +0 -82
- package/skills/engineering/resolve-issues/references/deliverables.md +0 -27
- package/skills/engineering/resolve-issues/references/delivery.md +0 -108
- package/skills/engineering/resolve-issues/references/evidence-lane.md +0 -21
- package/skills/engineering/resolve-issues/references/exact-head-ci.md +0 -287
- package/skills/engineering/resolve-issues/references/fan-out.md +0 -33
- package/skills/engineering/resolve-issues/references/finalization.md +0 -68
- package/skills/engineering/resolve-issues/references/guarantees.md +0 -10
- package/skills/engineering/resolve-issues/references/high-risk.md +0 -29
- package/skills/engineering/resolve-issues/references/incidents/848/README.md +0 -156
- package/skills/engineering/resolve-issues/references/intake.md +0 -86
- package/skills/engineering/resolve-issues/references/integration-gate.md +0 -53
- package/skills/engineering/resolve-issues/references/interference.md +0 -87
- package/skills/engineering/resolve-issues/references/loop.md +0 -134
- package/skills/engineering/resolve-issues/references/model-catalog.md +0 -9
- package/skills/engineering/resolve-issues/references/postmortem.md +0 -27
- package/skills/engineering/resolve-issues/references/pre-flight-model-slots.md +0 -41
- package/skills/engineering/resolve-issues/references/pre-flight-recording-and-checkout.md +0 -48
- package/skills/engineering/resolve-issues/references/pre-flight.md +0 -41
- package/skills/engineering/resolve-issues/references/regression-checklist.md +0 -26
- package/skills/engineering/resolve-issues/references/run-state.md +0 -288
- package/skills/engineering/resolve-issues/references/sandboxed-testing.md +0 -48
- package/skills/engineering/resolve-issues/references/spawn-contract.md +0 -96
- package/skills/engineering/resolve-issues/references/terminal-evidence-journal.md +0 -40
- package/skills/engineering/resolve-issues/references/why.md +0 -653
- package/skills/engineering/resolve-issues/schemas/fix-round.schema.json +0 -49
- package/skills/engineering/resolve-issues/scripts/combine-and-verify.mjs +0 -721
- package/skills/engineering/resolve-issues/scripts/component-candidate.mjs +0 -962
- package/skills/engineering/resolve-issues/scripts/contract-revision.mjs +0 -220
- package/skills/engineering/resolve-issues/scripts/detect-delivery-mode.mjs +0 -420
- package/skills/engineering/resolve-issues/scripts/detect-target-branch.mjs +0 -256
- package/skills/engineering/resolve-issues/scripts/detect-workspace-mode.mjs +0 -168
- package/skills/engineering/resolve-issues/scripts/discover-models.mjs +0 -9
- package/skills/engineering/resolve-issues/scripts/doctrine.mjs +0 -62
- package/skills/engineering/resolve-issues/scripts/evidence-lifecycle-contract.mjs +0 -191
- package/skills/engineering/resolve-issues/scripts/exact-head-ci.mjs +0 -413
- package/skills/engineering/resolve-issues/scripts/exact-head-github-provider.mjs +0 -332
- package/skills/engineering/resolve-issues/scripts/finalize.mjs +0 -488
- package/skills/engineering/resolve-issues/scripts/fix-rounds.mjs +0 -3307
- package/skills/engineering/resolve-issues/scripts/fixtures/evidence-lifecycle-circular-1001.json +0 -16
- package/skills/engineering/resolve-issues/scripts/fixtures/evidence-lifecycle-valid-sequencing.json +0 -51
- package/skills/engineering/resolve-issues/scripts/fixtures/fmm-express-830-component-candidate.json +0 -17
- package/skills/engineering/resolve-issues/scripts/fixtures/head-check-set-1081.json +0 -166
- package/skills/engineering/resolve-issues/scripts/gate-value-series.mjs +0 -92
- package/skills/engineering/resolve-issues/scripts/guide-index.mjs +0 -73
- package/skills/engineering/resolve-issues/scripts/head-check-set.mjs +0 -159
- package/skills/engineering/resolve-issues/scripts/interference.mjs +0 -427
- package/skills/engineering/resolve-issues/scripts/model-catalog.mjs +0 -9
- package/skills/engineering/resolve-issues/scripts/next-operations.mjs +0 -419
- package/skills/engineering/resolve-issues/scripts/postmortem.mjs +0 -909
- package/skills/engineering/resolve-issues/scripts/preflight-questions.mjs +0 -345
- package/skills/engineering/resolve-issues/scripts/reconcile-contained-unit-prs.mjs +0 -415
- package/skills/engineering/resolve-issues/scripts/release-state-contract.mjs +0 -697
- package/skills/engineering/resolve-issues/scripts/report.mjs +0 -494
- package/skills/engineering/resolve-issues/scripts/required-check-plan.mjs +0 -172
- package/skills/engineering/resolve-issues/scripts/round-metadata.mjs +0 -79
- package/skills/engineering/resolve-issues/scripts/run-state-review6-cases.mjs +0 -334
- package/skills/engineering/resolve-issues/scripts/run-state.mjs +0 -4792
- package/skills/engineering/resolve-issues/scripts/sandbox-selftest.mjs +0 -395
- package/skills/engineering/resolve-issues/scripts/spawn-contract.mjs +0 -290
- package/skills/engineering/resolve-issues/scripts/terminal-dispositions.mjs +0 -170
- package/skills/engineering/resolve-issues/scripts/terminal-evidence-journal.mjs +0 -293
- package/skills/engineering/resolve-issues/scripts/unit-kind.mjs +0 -197
- package/skills/engineering/resolve-issues/scripts/unit-lifecycle.mjs +0 -127
- package/skills/engineering/resolve-issues/scripts/watch-delivery.mjs +0 -893
- package/skills/engineering/resolve-issues/scripts/workspaces.mjs +0 -829
- package/skills/engineering/resolve-issues/workflows/independent-review.workflow.js +0 -290
- package/skills/engineering/resolve-issues/workflows/prior-art-scan.workflow.js +0 -80
- package/skills/engineering/resolve-issues/workflows/workflow-smoke.mjs +0 -102
- package/skills/engineering/resolve-release/LICENSE +0 -3
- package/skills/engineering/resolve-release/SKILL.md +0 -113
- package/skills/engineering/resolve-release/agents/openai.yaml +0 -9
- package/skills/engineering/resolve-release/references/assembly.md +0 -137
- package/skills/engineering/resolve-release/references/auto-when-green.md +0 -56
- package/skills/engineering/resolve-release/references/candidate.md +0 -167
- package/skills/engineering/resolve-release/references/exposure.md +0 -178
- package/skills/engineering/resolve-release/references/handoff.md +0 -24
- package/skills/engineering/resolve-release/references/postmortem.md +0 -230
- package/skills/engineering/resolve-release/references/preflight.md +0 -208
- package/skills/engineering/resolve-release/references/principles.md +0 -94
- package/skills/engineering/resolve-release/references/regression-checklist.md +0 -36
- package/skills/engineering/resolve-release/references/related-skills.md +0 -13
- package/skills/engineering/resolve-release/references/routing.md +0 -149
- package/skills/engineering/resolve-release/references/verified-sha-github-flow.md +0 -285
- package/skills/engineering/resolve-release/references/versioning.md +0 -202
- package/skills/engineering/resolve-release/references/why.md +0 -53
- package/skills/engineering/resolve-release/scripts/adapter-completion-artifact.mjs +0 -389
- package/skills/engineering/resolve-release/scripts/build-changes.mjs +0 -209
- package/skills/engineering/resolve-release/scripts/candidate-hygiene.mjs +0 -407
- package/skills/engineering/resolve-release/scripts/candidate-identity.mjs +0 -904
- package/skills/engineering/resolve-release/scripts/candidate-traffic.mjs +0 -81
- package/skills/engineering/resolve-release/scripts/checked-adapter-loader.mjs +0 -612
- package/skills/engineering/resolve-release/scripts/close-attempt.mjs +0 -135
- package/skills/engineering/resolve-release/scripts/closeout-release.mjs +0 -161
- package/skills/engineering/resolve-release/scripts/doctrine.mjs +0 -106
- package/skills/engineering/resolve-release/scripts/durable-processing.mjs +0 -522
- package/skills/engineering/resolve-release/scripts/ensure-target-green.mjs +0 -659
- package/skills/engineering/resolve-release/scripts/evidence-bundle.mjs +0 -1014
- package/skills/engineering/resolve-release/scripts/finalize-release.mjs +0 -526
- package/skills/engineering/resolve-release/scripts/fixtures/durable-processing-adapter.mjs +0 -169
- package/skills/engineering/resolve-release/scripts/green-gate.mjs +0 -599
- package/skills/engineering/resolve-release/scripts/isolated-adapter-evaluator.mjs +0 -752
- package/skills/engineering/resolve-release/scripts/metadata-pr-status.mjs +0 -56
- package/skills/engineering/resolve-release/scripts/metadata-sync.mjs +0 -1538
- package/skills/engineering/resolve-release/scripts/postmortem.mjs +0 -381
- package/skills/engineering/resolve-release/scripts/preflight-probes.mjs +0 -510
- package/skills/engineering/resolve-release/scripts/production-endpoints.mjs +0 -326
- package/skills/engineering/resolve-release/scripts/rc-circuit-breaker.mjs +0 -272
- package/skills/engineering/resolve-release/scripts/report.mjs +0 -417
- package/skills/engineering/resolve-release/scripts/reprobe-credentials.mjs +0 -114
- package/skills/engineering/resolve-release/scripts/revalidate-candidate.mjs +0 -238
- package/skills/engineering/resolve-release/scripts/review-packet.mjs +0 -503
- package/skills/engineering/resolve-release/scripts/rollback-floor.mjs +0 -263
- package/skills/engineering/resolve-release/scripts/version-assert.mjs +0 -339
- package/skills/engineering/resolve-release/scripts/version-postmortem.mjs +0 -485
- package/skills/engineering/resolve-release/scripts/version.mjs +0 -1199
- package/skills/engineering/resolve-release/scripts/watch-candidate-delivery.mjs +0 -449
- package/skills/engineering/resolve-release/vendor/ACORN-LICENSE +0 -21
- package/skills/engineering/resolve-release/vendor/README.md +0 -60
- package/skills/engineering/resolve-release/vendor/acorn.mjs +0 -6233
- package/skills/engineering/resolve-release/vendor/quickjs/core/LICENSE +0 -21
- package/skills/engineering/resolve-release/vendor/quickjs/core/README.md +0 -341
- package/skills/engineering/resolve-release/vendor/quickjs/core/README.template.md +0 -70
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-TAV5CUKK.mjs +0 -2
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-TAV5CUKK.mjs.map +0 -1
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-V2S4ZYJR.mjs +0 -7
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-V2S4ZYJR.mjs.map +0 -1
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.d.mts +0 -2033
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.d.ts +0 -2033
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.js +0 -7
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.js.map +0 -1
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.mjs +0 -2
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.mjs.map +0 -1
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-ES6BEMUI.mjs +0 -2
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-ES6BEMUI.mjs.map +0 -1
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-asyncify-2EFITU5U.mjs +0 -2
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-asyncify-2EFITU5U.mjs.map +0 -1
- package/skills/engineering/resolve-release/vendor/quickjs/core/package.json +0 -49
- package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/LICENSE +0 -21
- package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/README.md +0 -5
- package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.d.mts +0 -549
- package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.d.ts +0 -549
- package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.js +0 -2
- package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.js.map +0 -1
- package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.mjs +0 -2
- package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.mjs.map +0 -1
- package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/package.json +0 -36
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/LICENSE +0 -47
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/README.md +0 -82
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.browser.d.ts +0 -11
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.browser.mjs +0 -22
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.cjs +0 -21
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.cloudflare.cjs +0 -21
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.cloudflare.d.ts +0 -11
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.d.ts +0 -11
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.mjs +0 -25
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.wasm +0 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.d.mts +0 -85
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.d.ts +0 -85
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.js +0 -2
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.js.map +0 -1
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.mjs +0 -2
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.mjs.map +0 -1
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.d.mts +0 -20
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.d.ts +0 -20
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.js +0 -2
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.js.map +0 -1
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.mjs +0 -2
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.mjs.map +0 -1
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/package.json +0 -61
- /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()
|