@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.
- 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/mergify/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +14 -83
- 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/smoke/SKILL.md +1 -1
- package/skills/payment/payment-analysis/SKILL.md +13 -7
- package/skills/payment/payment-analysis/references/chargebacks.md +43 -18
- package/skills/payment/payment-analysis/references/event-layers.md +4 -0
- package/skills/payment/payment-analysis/references/metrics.md +9 -7
- package/skills/payment/payment-analysis/references/report-template.md +3 -3
- package/skills/payment/payment-analysis/references/terminology.md +6 -1
- package/skills/payment/payment-analysis/references/visualization.md +1 -1
- 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,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()
|