@1aboveio/skills 0.19.2 → 0.20.1

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 (203) 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/mergify/SKILL.md +1 -1
  8. package/skills/engineering/engineering-runtime/coherence/workflow.json +14 -83
  9. package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -6
  10. package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +3 -36
  11. package/skills/engineering/smoke/SKILL.md +1 -1
  12. package/skills/payment/payment-analysis/SKILL.md +13 -7
  13. package/skills/payment/payment-analysis/references/chargebacks.md +43 -18
  14. package/skills/payment/payment-analysis/references/event-layers.md +4 -0
  15. package/skills/payment/payment-analysis/references/metrics.md +9 -7
  16. package/skills/payment/payment-analysis/references/report-template.md +3 -3
  17. package/skills/payment/payment-analysis/references/terminology.md +6 -1
  18. package/skills/payment/payment-analysis/references/visualization.md +1 -1
  19. package/skills/payment/tld-detection/SKILL.md +163 -0
  20. package/skills/payment/tld-detection/agents/openai.yaml +9 -0
  21. package/skills/payment/tld-detection/evals/evals.json +107 -0
  22. package/skills/payment/tld-detection/references/clean.md +51 -0
  23. package/skills/payment/tld-detection/references/clustering.md +219 -0
  24. package/skills/payment/tld-detection/references/decision.md +85 -0
  25. package/skills/payment/tld-detection/references/report-template.md +169 -0
  26. package/skills/payment/tld-detection/references/terminology.md +69 -0
  27. package/skills/payment/tld-detection/references/workflow.md +78 -0
  28. package/skills/payment/tld-detection/scripts/detect_ladders.py +390 -0
  29. package/skills/payment/tld-detection/tests/test_detect_ladders.py +132 -0
  30. package/skills/engineering/resolve-issues/SKILL.md +0 -168
  31. package/skills/engineering/resolve-issues/agents/openai.yaml +0 -9
  32. package/skills/engineering/resolve-issues/generated/workflow-repair-policy.json +0 -499
  33. package/skills/engineering/resolve-issues/references/breaker.md +0 -82
  34. package/skills/engineering/resolve-issues/references/deliverables.md +0 -27
  35. package/skills/engineering/resolve-issues/references/delivery.md +0 -108
  36. package/skills/engineering/resolve-issues/references/evidence-lane.md +0 -21
  37. package/skills/engineering/resolve-issues/references/exact-head-ci.md +0 -287
  38. package/skills/engineering/resolve-issues/references/fan-out.md +0 -33
  39. package/skills/engineering/resolve-issues/references/finalization.md +0 -68
  40. package/skills/engineering/resolve-issues/references/guarantees.md +0 -10
  41. package/skills/engineering/resolve-issues/references/high-risk.md +0 -29
  42. package/skills/engineering/resolve-issues/references/incidents/848/README.md +0 -156
  43. package/skills/engineering/resolve-issues/references/intake.md +0 -86
  44. package/skills/engineering/resolve-issues/references/integration-gate.md +0 -53
  45. package/skills/engineering/resolve-issues/references/interference.md +0 -87
  46. package/skills/engineering/resolve-issues/references/loop.md +0 -134
  47. package/skills/engineering/resolve-issues/references/model-catalog.md +0 -9
  48. package/skills/engineering/resolve-issues/references/postmortem.md +0 -27
  49. package/skills/engineering/resolve-issues/references/pre-flight-model-slots.md +0 -41
  50. package/skills/engineering/resolve-issues/references/pre-flight-recording-and-checkout.md +0 -48
  51. package/skills/engineering/resolve-issues/references/pre-flight.md +0 -41
  52. package/skills/engineering/resolve-issues/references/regression-checklist.md +0 -26
  53. package/skills/engineering/resolve-issues/references/run-state.md +0 -288
  54. package/skills/engineering/resolve-issues/references/sandboxed-testing.md +0 -48
  55. package/skills/engineering/resolve-issues/references/spawn-contract.md +0 -96
  56. package/skills/engineering/resolve-issues/references/terminal-evidence-journal.md +0 -40
  57. package/skills/engineering/resolve-issues/references/why.md +0 -653
  58. package/skills/engineering/resolve-issues/schemas/fix-round.schema.json +0 -49
  59. package/skills/engineering/resolve-issues/scripts/combine-and-verify.mjs +0 -721
  60. package/skills/engineering/resolve-issues/scripts/component-candidate.mjs +0 -962
  61. package/skills/engineering/resolve-issues/scripts/contract-revision.mjs +0 -220
  62. package/skills/engineering/resolve-issues/scripts/detect-delivery-mode.mjs +0 -420
  63. package/skills/engineering/resolve-issues/scripts/detect-target-branch.mjs +0 -256
  64. package/skills/engineering/resolve-issues/scripts/detect-workspace-mode.mjs +0 -168
  65. package/skills/engineering/resolve-issues/scripts/discover-models.mjs +0 -9
  66. package/skills/engineering/resolve-issues/scripts/doctrine.mjs +0 -62
  67. package/skills/engineering/resolve-issues/scripts/evidence-lifecycle-contract.mjs +0 -191
  68. package/skills/engineering/resolve-issues/scripts/exact-head-ci.mjs +0 -413
  69. package/skills/engineering/resolve-issues/scripts/exact-head-github-provider.mjs +0 -332
  70. package/skills/engineering/resolve-issues/scripts/finalize.mjs +0 -488
  71. package/skills/engineering/resolve-issues/scripts/fix-rounds.mjs +0 -3307
  72. package/skills/engineering/resolve-issues/scripts/fixtures/evidence-lifecycle-circular-1001.json +0 -16
  73. package/skills/engineering/resolve-issues/scripts/fixtures/evidence-lifecycle-valid-sequencing.json +0 -51
  74. package/skills/engineering/resolve-issues/scripts/fixtures/fmm-express-830-component-candidate.json +0 -17
  75. package/skills/engineering/resolve-issues/scripts/fixtures/head-check-set-1081.json +0 -166
  76. package/skills/engineering/resolve-issues/scripts/gate-value-series.mjs +0 -92
  77. package/skills/engineering/resolve-issues/scripts/guide-index.mjs +0 -73
  78. package/skills/engineering/resolve-issues/scripts/head-check-set.mjs +0 -159
  79. package/skills/engineering/resolve-issues/scripts/interference.mjs +0 -427
  80. package/skills/engineering/resolve-issues/scripts/model-catalog.mjs +0 -9
  81. package/skills/engineering/resolve-issues/scripts/next-operations.mjs +0 -419
  82. package/skills/engineering/resolve-issues/scripts/postmortem.mjs +0 -909
  83. package/skills/engineering/resolve-issues/scripts/preflight-questions.mjs +0 -345
  84. package/skills/engineering/resolve-issues/scripts/reconcile-contained-unit-prs.mjs +0 -415
  85. package/skills/engineering/resolve-issues/scripts/release-state-contract.mjs +0 -697
  86. package/skills/engineering/resolve-issues/scripts/report.mjs +0 -494
  87. package/skills/engineering/resolve-issues/scripts/required-check-plan.mjs +0 -172
  88. package/skills/engineering/resolve-issues/scripts/round-metadata.mjs +0 -79
  89. package/skills/engineering/resolve-issues/scripts/run-state-review6-cases.mjs +0 -334
  90. package/skills/engineering/resolve-issues/scripts/run-state.mjs +0 -4792
  91. package/skills/engineering/resolve-issues/scripts/sandbox-selftest.mjs +0 -395
  92. package/skills/engineering/resolve-issues/scripts/spawn-contract.mjs +0 -290
  93. package/skills/engineering/resolve-issues/scripts/terminal-dispositions.mjs +0 -170
  94. package/skills/engineering/resolve-issues/scripts/terminal-evidence-journal.mjs +0 -293
  95. package/skills/engineering/resolve-issues/scripts/unit-kind.mjs +0 -197
  96. package/skills/engineering/resolve-issues/scripts/unit-lifecycle.mjs +0 -127
  97. package/skills/engineering/resolve-issues/scripts/watch-delivery.mjs +0 -893
  98. package/skills/engineering/resolve-issues/scripts/workspaces.mjs +0 -829
  99. package/skills/engineering/resolve-issues/workflows/independent-review.workflow.js +0 -290
  100. package/skills/engineering/resolve-issues/workflows/prior-art-scan.workflow.js +0 -80
  101. package/skills/engineering/resolve-issues/workflows/workflow-smoke.mjs +0 -102
  102. package/skills/engineering/resolve-release/LICENSE +0 -3
  103. package/skills/engineering/resolve-release/SKILL.md +0 -113
  104. package/skills/engineering/resolve-release/agents/openai.yaml +0 -9
  105. package/skills/engineering/resolve-release/references/assembly.md +0 -137
  106. package/skills/engineering/resolve-release/references/auto-when-green.md +0 -56
  107. package/skills/engineering/resolve-release/references/candidate.md +0 -167
  108. package/skills/engineering/resolve-release/references/exposure.md +0 -178
  109. package/skills/engineering/resolve-release/references/handoff.md +0 -24
  110. package/skills/engineering/resolve-release/references/postmortem.md +0 -230
  111. package/skills/engineering/resolve-release/references/preflight.md +0 -208
  112. package/skills/engineering/resolve-release/references/principles.md +0 -94
  113. package/skills/engineering/resolve-release/references/regression-checklist.md +0 -36
  114. package/skills/engineering/resolve-release/references/related-skills.md +0 -13
  115. package/skills/engineering/resolve-release/references/routing.md +0 -149
  116. package/skills/engineering/resolve-release/references/verified-sha-github-flow.md +0 -285
  117. package/skills/engineering/resolve-release/references/versioning.md +0 -202
  118. package/skills/engineering/resolve-release/references/why.md +0 -53
  119. package/skills/engineering/resolve-release/scripts/adapter-completion-artifact.mjs +0 -389
  120. package/skills/engineering/resolve-release/scripts/build-changes.mjs +0 -209
  121. package/skills/engineering/resolve-release/scripts/candidate-hygiene.mjs +0 -407
  122. package/skills/engineering/resolve-release/scripts/candidate-identity.mjs +0 -904
  123. package/skills/engineering/resolve-release/scripts/candidate-traffic.mjs +0 -81
  124. package/skills/engineering/resolve-release/scripts/checked-adapter-loader.mjs +0 -612
  125. package/skills/engineering/resolve-release/scripts/close-attempt.mjs +0 -135
  126. package/skills/engineering/resolve-release/scripts/closeout-release.mjs +0 -161
  127. package/skills/engineering/resolve-release/scripts/doctrine.mjs +0 -106
  128. package/skills/engineering/resolve-release/scripts/durable-processing.mjs +0 -522
  129. package/skills/engineering/resolve-release/scripts/ensure-target-green.mjs +0 -659
  130. package/skills/engineering/resolve-release/scripts/evidence-bundle.mjs +0 -1014
  131. package/skills/engineering/resolve-release/scripts/finalize-release.mjs +0 -526
  132. package/skills/engineering/resolve-release/scripts/fixtures/durable-processing-adapter.mjs +0 -169
  133. package/skills/engineering/resolve-release/scripts/green-gate.mjs +0 -599
  134. package/skills/engineering/resolve-release/scripts/isolated-adapter-evaluator.mjs +0 -752
  135. package/skills/engineering/resolve-release/scripts/metadata-pr-status.mjs +0 -56
  136. package/skills/engineering/resolve-release/scripts/metadata-sync.mjs +0 -1538
  137. package/skills/engineering/resolve-release/scripts/postmortem.mjs +0 -381
  138. package/skills/engineering/resolve-release/scripts/preflight-probes.mjs +0 -510
  139. package/skills/engineering/resolve-release/scripts/production-endpoints.mjs +0 -326
  140. package/skills/engineering/resolve-release/scripts/rc-circuit-breaker.mjs +0 -272
  141. package/skills/engineering/resolve-release/scripts/report.mjs +0 -417
  142. package/skills/engineering/resolve-release/scripts/reprobe-credentials.mjs +0 -114
  143. package/skills/engineering/resolve-release/scripts/revalidate-candidate.mjs +0 -238
  144. package/skills/engineering/resolve-release/scripts/review-packet.mjs +0 -503
  145. package/skills/engineering/resolve-release/scripts/rollback-floor.mjs +0 -263
  146. package/skills/engineering/resolve-release/scripts/version-assert.mjs +0 -339
  147. package/skills/engineering/resolve-release/scripts/version-postmortem.mjs +0 -485
  148. package/skills/engineering/resolve-release/scripts/version.mjs +0 -1199
  149. package/skills/engineering/resolve-release/scripts/watch-candidate-delivery.mjs +0 -449
  150. package/skills/engineering/resolve-release/vendor/ACORN-LICENSE +0 -21
  151. package/skills/engineering/resolve-release/vendor/README.md +0 -60
  152. package/skills/engineering/resolve-release/vendor/acorn.mjs +0 -6233
  153. package/skills/engineering/resolve-release/vendor/quickjs/core/LICENSE +0 -21
  154. package/skills/engineering/resolve-release/vendor/quickjs/core/README.md +0 -341
  155. package/skills/engineering/resolve-release/vendor/quickjs/core/README.template.md +0 -70
  156. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-TAV5CUKK.mjs +0 -2
  157. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-TAV5CUKK.mjs.map +0 -1
  158. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-V2S4ZYJR.mjs +0 -7
  159. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-V2S4ZYJR.mjs.map +0 -1
  160. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.d.mts +0 -2033
  161. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.d.ts +0 -2033
  162. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.js +0 -7
  163. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.js.map +0 -1
  164. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.mjs +0 -2
  165. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.mjs.map +0 -1
  166. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-ES6BEMUI.mjs +0 -2
  167. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-ES6BEMUI.mjs.map +0 -1
  168. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-asyncify-2EFITU5U.mjs +0 -2
  169. package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-asyncify-2EFITU5U.mjs.map +0 -1
  170. package/skills/engineering/resolve-release/vendor/quickjs/core/package.json +0 -49
  171. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/LICENSE +0 -21
  172. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/README.md +0 -5
  173. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.d.mts +0 -549
  174. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.d.ts +0 -549
  175. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.js +0 -2
  176. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.js.map +0 -1
  177. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.mjs +0 -2
  178. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.mjs.map +0 -1
  179. package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/package.json +0 -36
  180. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/LICENSE +0 -47
  181. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/README.md +0 -82
  182. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.browser.d.ts +0 -11
  183. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.browser.mjs +0 -22
  184. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.cjs +0 -21
  185. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.cloudflare.cjs +0 -21
  186. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.cloudflare.d.ts +0 -11
  187. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.d.ts +0 -11
  188. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.mjs +0 -25
  189. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.wasm +0 -0
  190. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.d.mts +0 -85
  191. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.d.ts +0 -85
  192. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.js +0 -2
  193. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.js.map +0 -1
  194. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.mjs +0 -2
  195. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.mjs.map +0 -1
  196. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.d.mts +0 -20
  197. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.d.ts +0 -20
  198. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.js +0 -2
  199. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.js.map +0 -1
  200. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.mjs +0 -2
  201. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.mjs.map +0 -1
  202. package/skills/engineering/resolve-release/vendor/quickjs/release-sync/package.json +0 -61
  203. /package/skills/{engineering/resolve-issues → payment/tld-detection}/LICENSE +0 -0
@@ -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()