@1aboveio/skills 0.20.2 → 0.20.4

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 (85) hide show
  1. package/README.md +14 -6
  2. package/package.json +1 -1
  3. package/runtime/skills/distribution/generated/recipes.json +220 -25
  4. package/runtime/skills/distribution/scripts/bundles.mjs +167 -48
  5. package/runtime/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -0
  6. package/runtime/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
  7. package/skills/compliance/compliance-audit/LICENSE +3 -0
  8. package/skills/compliance/compliance-audit/SKILL.md +466 -0
  9. package/skills/compliance/compliance-audit/references/findings-schema.md +290 -0
  10. package/skills/compliance/compliance-audit/references/vars-checklist.md +1068 -0
  11. package/skills/compliance/compliance-audit/references/vars-findings-schema.md +395 -0
  12. package/skills/compliance/compliance-audit/scripts/reconcile_tracker.py +454 -0
  13. package/skills/compliance/compliance-audit/scripts/test_validate_tracker.py +90 -0
  14. package/skills/compliance/compliance-audit/scripts/validate_tracker.py +752 -0
  15. package/skills/compliance/doc-authenticity/LICENSE +3 -0
  16. package/skills/compliance/doc-authenticity/SKILL.md +158 -0
  17. package/skills/compliance/doc-authenticity/references/agreement.md +66 -0
  18. package/skills/compliance/doc-authenticity/references/bank-statement.md +150 -0
  19. package/skills/compliance/doc-authenticity/references/forensic-signals.md +157 -0
  20. package/skills/compliance/doc-authenticity/references/image-signals.md +114 -0
  21. package/skills/compliance/doc-authenticity/references/invoice.md +66 -0
  22. package/skills/compliance/doc-authenticity/references/issuer-profiles.md +86 -0
  23. package/skills/compliance/doc-authenticity/references/verdict-and-report.md +95 -0
  24. package/skills/compliance/doc-authenticity/references/verdict.schema.json +81 -0
  25. package/skills/compliance/doc-authenticity/scripts/image_forensics.py +294 -0
  26. package/skills/compliance/doc-authenticity/scripts/pdf_forensics.py +401 -0
  27. package/skills/compliance/doc-authenticity/scripts/validate_codes.py +224 -0
  28. package/skills/compliance/doc-authenticity/scripts/validate_verdict.py +308 -0
  29. package/skills/compliance/report-writing/LICENSE +3 -0
  30. package/skills/compliance/report-writing/SKILL.md +206 -0
  31. package/skills/compliance/report-writing/references/audit-report-template.md +121 -0
  32. package/skills/compliance/report-writing/references/external-assessment-template.md +167 -0
  33. package/skills/compliance/report-writing/references/management-report-template.md +145 -0
  34. package/skills/compliance/report-writing/references/vars-report-template.md +224 -0
  35. package/skills/document-utils/doc-to-md/LICENSE +3 -0
  36. package/skills/document-utils/doc-to-md/SKILL.md +77 -0
  37. package/skills/document-utils/doc-to-md/scripts/convert.py +157 -0
  38. package/skills/document-utils/reg-parser/LICENSE +3 -0
  39. package/skills/document-utils/reg-parser/SKILL.md +201 -0
  40. package/skills/document-utils/reg-parser/references/checklist-schema.md +223 -0
  41. package/skills/document-utils/reg-parser/references/checklists/hk-amlo-checklist.json +1306 -0
  42. package/skills/document-utils/reg-parser/references/checklists/hk-amlo-checklist.md +469 -0
  43. package/skills/document-utils/reg-parser/references/checklists/hk-fi-corporate-onboarding-cdd-checklist.json +634 -0
  44. package/skills/document-utils/reg-parser/references/checklists/hk-fi-corporate-onboarding-cdd-checklist.md +233 -0
  45. package/skills/document-utils/reg-parser/references/checklists/hk-mso-guideline-checklist.json +2194 -0
  46. package/skills/document-utils/reg-parser/references/checklists/hk-mso-guideline-checklist.md +771 -0
  47. package/skills/document-utils/reg-parser/references/checklists/kyc-aml-checklist.md +258 -0
  48. package/skills/document-utils/reg-parser/references/checklists/visa-vars-checklist.md +1058 -0
  49. package/skills/document-utils/reg-parser/references/mandates/hk-amlo-cap615.md +389 -0
  50. package/skills/document-utils/reg-parser/references/mandates/visa-vars.md +205 -0
  51. package/skills/document-utils/reg-parser/scripts/compare_checklist.py +228 -0
  52. package/skills/document-utils/reg-parser/scripts/dump_checklist.py +348 -0
  53. package/skills/document-utils/reg-parser/scripts/preprocess-mandate.sh +171 -0
  54. package/skills/document-utils/reg-parser/scripts/test_quarantine.sh +102 -0
  55. package/skills/document-utils/reg-parser/scripts/validate_checklist.py +532 -0
  56. package/skills/document-utils/reg-parser/scripts/validate_mandate.py +242 -0
  57. package/skills/engineering/engineering-runtime/coherence/workflow.json +16 -16
  58. package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -0
  59. package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
  60. package/skills/payment/similar-domain/LICENSE +3 -0
  61. package/skills/payment/similar-domain/SKILL.md +178 -0
  62. package/skills/payment/similar-domain/references/entities.md +58 -0
  63. package/skills/payment/similar-domain/references/fingerprints.md +82 -0
  64. package/skills/payment/similar-domain/references/pivots.md +66 -0
  65. package/skills/payment/similar-domain/references/report-template.md +76 -0
  66. package/skills/payment/similar-domain/references/scoring.md +61 -0
  67. package/skills/payment/similar-domain/references/terminology.md +37 -0
  68. package/skills/payment/similar-domain/references/workflow.md +79 -0
  69. package/skills/payment/tld-detection/SKILL.md +76 -21
  70. package/skills/payment/tld-detection/references/clean.md +1 -1
  71. package/skills/payment/tld-detection/references/clustering.md +21 -6
  72. package/skills/payment/tld-detection/references/decision.md +7 -2
  73. package/skills/payment/tld-detection/references/fx-conversion.md +229 -0
  74. package/skills/payment/tld-detection/references/report-template.md +63 -27
  75. package/skills/payment/tld-detection/references/streams.md +90 -0
  76. package/skills/payment/tld-detection/references/terminology.md +15 -7
  77. package/skills/payment/tld-detection/references/workflow.md +1 -1
  78. package/skills/payment/tld-detection/scripts/detect_streams.py +542 -0
  79. package/skills/engineering/ensure-coverage/evals/evals.json +0 -125
  80. package/skills/engineering/rush-issues/evals/evals.json +0 -65
  81. package/skills/engineering/rush-release/evals/evals.json +0 -58
  82. package/skills/fullstack/shadcn/evals/evals.json +0 -90
  83. package/skills/payment/fraud-analysis/evals/evals.json +0 -40
  84. package/skills/payment/tld-detection/evals/evals.json +0 -107
  85. package/skills/payment/tld-detection/tests/test_detect_ladders.py +0 -132
@@ -0,0 +1,542 @@
1
+ #!/usr/bin/env python3
2
+ """Infer upstream streams inside a descriptor: wallet currency, preset menu, frozen-rate batches, retail lists.
3
+
4
+ Discovery layer that runs before exact currency-amount-pair clustering. It reads a cleaned
5
+ authorization CSV plus a daily FX cache and, per descriptor and BIN-country group, reports:
6
+
7
+ * inferred wallet currency (the currency under which charges collapse onto the fewest peaks)
8
+ * the preset menu (peak set) and which known scheme it matches (10-scheme, 12-scheme)
9
+ * rate consistency: whether a group's rungs drift with the market (live) or sit on one frozen rate
10
+ * a retail-list constant (list prices ending .99/.95/.98/.00 times one factor)
11
+ * off-menu groups (no peak structure)
12
+ * behaviour metrics: repeat cards, retry after decline, burst regularity, hour concentration,
13
+ address verification, 3-D Secure, prepaid share, issuer-program concentration
14
+
15
+ Every threshold is a CLI option and is echoed in the output. Stdlib only.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import csv
21
+ import json
22
+ import math
23
+ import random
24
+ import statistics
25
+ import sys
26
+ import urllib.request
27
+ from collections import Counter, defaultdict
28
+ from datetime import date, datetime, timedelta
29
+ from pathlib import Path
30
+
31
+ PEGS = {"USD": 1.0, "SAR": 3.75, "AED": 3.6725, "QAR": 3.64, "KWD": 0.3066, "BHD": 0.376, "OMR": 0.3845, "HKD": 7.8, "JOD": 0.709}
32
+
33
+ COUNTRY_CURRENCY = {
34
+ "united states of america": "USD", "united states": "USD", "us": "USD", "usa": "USD",
35
+ "united kingdom": "GBP", "gb": "GBP", "uk": "GBP", "australia": "AUD", "new zealand": "NZD", "canada": "CAD",
36
+ "switzerland": "CHF", "poland": "PLN", "czech republic": "CZK", "czechia": "CZK", "hungary": "HUF", "norway": "NOK",
37
+ "sweden": "SEK", "denmark": "DKK", "romania": "RON", "bulgaria": "BGN", "iceland": "ISK", "brazil": "BRL",
38
+ "mexico": "MXN", "japan": "JPY", "india": "INR", "south africa": "ZAR", "turkey": "TRY", "israel": "ILS",
39
+ "singapore": "SGD", "hong kong": "HKD", "korea, republic of": "KRW", "south korea": "KRW", "thailand": "THB",
40
+ "malaysia": "MYR", "philippines": "PHP", "indonesia": "IDR", "saudi arabia": "SAR", "united arab emirates": "AED",
41
+ "qatar": "QAR", "kuwait": "KWD", "bahrain": "BHD", "oman": "OMR", "jordan": "JOD",
42
+ }
43
+ EURO_COUNTRIES = {
44
+ "germany", "italy", "france", "spain", "portugal", "greece", "slovakia", "finland", "ireland", "austria",
45
+ "netherlands", "belgium", "lithuania", "croatia", "slovenia", "estonia", "latvia", "cyprus", "luxembourg", "malta",
46
+ "moldova, republic of", "moldova", "montenegro", "kosovo",
47
+ }
48
+ for _c in EURO_COUNTRIES:
49
+ COUNTRY_CURRENCY[_c] = "EUR"
50
+
51
+ SCHEMES = {
52
+ "10": [5, 10, 15, 20, 25, 30, 40, 50, 60, 75, 80, 100, 150, 200, 250, 300, 400, 500, 750, 1000, 1500, 2000],
53
+ "12": [12, 24, 36, 48, 72, 96, 108, 144, 192, 288, 384, 480],
54
+ }
55
+ SCHEME_SCALES = [1, 5, 10, 25, 100, 400, 1000]
56
+
57
+
58
+ # ----------------------------------------------------------------------------- rates
59
+
60
+ def fetch_rates(start: str, end: str, base: str, symbols: list[str]) -> dict:
61
+ url = f"https://api.frankfurter.dev/v1/{start}..{end}?base={base}&symbols={','.join(sorted(set(symbols)))}"
62
+ with urllib.request.urlopen(url, timeout=60) as resp: # noqa: S310 - explicit user action
63
+ payload = json.load(resp)
64
+ return {"base": base, "source": url, "rates": payload["rates"]}
65
+
66
+
67
+ class Rates:
68
+ """Daily rates quoted as units of currency per one unit of base; prior-day carry up to ``carry`` days."""
69
+
70
+ def __init__(self, cache: dict, carry: int = 7):
71
+ self.base = cache.get("base", "USD")
72
+ self.rates = cache["rates"]
73
+ self.days = sorted(self.rates)
74
+ self.carry = carry
75
+ self.carried: Counter = Counter()
76
+
77
+ def rate(self, day: str, cur: str) -> float | None:
78
+ if cur == self.base:
79
+ return 1.0
80
+ if cur in PEGS and self.base == "USD":
81
+ return PEGS[cur]
82
+ if not self.days:
83
+ return None
84
+ d = max(min(day, self.days[-1]), self.days[0])
85
+ for lag in range(self.carry + 1):
86
+ dd = (date.fromisoformat(d) - timedelta(days=lag)).isoformat()
87
+ if dd in self.rates and cur in self.rates[dd]:
88
+ if lag:
89
+ self.carried[cur] += 1
90
+ return self.rates[dd][cur]
91
+ return None
92
+
93
+ def market_move(self, cur: str, day_a: str, day_b: str) -> float | None:
94
+ a, b = self.rate(day_a, cur), self.rate(day_b, cur)
95
+ if not a or not b:
96
+ return None
97
+ return abs(b / a - 1)
98
+
99
+
100
+ # ----------------------------------------------------------------------------- helpers
101
+
102
+ def find_peaks(values: list[float], tol: float, min_share: float) -> list[tuple[float, int]]:
103
+ """Group values within ±tol (relative) of the modal value, largest first; return (center, count)."""
104
+ if not values:
105
+ return []
106
+ counts = Counter(round(v, 2) for v in values)
107
+ n = len(values)
108
+ used: set[float] = set()
109
+ peaks = []
110
+ for v, _ in counts.most_common():
111
+ if v in used:
112
+ continue
113
+ grp = [w for w in counts if w not in used and abs(w - v) <= tol * max(v, 1e-9)]
114
+ cnt = sum(counts[w] for w in grp)
115
+ if cnt / n < min_share:
116
+ continue
117
+ used.update(grp)
118
+ center = sum(w * counts[w] for w in grp) / cnt
119
+ peaks.append((center, cnt))
120
+ return sorted(peaks, key=lambda p: -p[1])
121
+
122
+
123
+ def coverage(values: list[float], tol: float, min_share: float, top_k: int) -> tuple[float, list[tuple[float, int]]]:
124
+ peaks = find_peaks(values, tol, min_share)[:top_k]
125
+ return (sum(c for _, c in peaks) / len(values) if values else 0.0), peaks
126
+
127
+
128
+ def coverage_against(values: list[float], centers: list[float], tol: float) -> float:
129
+ """Share of values within tol of any given centre (used for the jittered control)."""
130
+ if not values or not centers:
131
+ return 0.0
132
+ return sum(1 for v in values if any(abs(v - c) <= tol * c for c in centers)) / len(values)
133
+
134
+
135
+ def day_drift(rows: list[dict], converted: list[float], centers: list[tuple[float, int]], tol: float, min_per_day: int = 3) -> float | None:
136
+ """Weighted mean, over the top peaks, of (max - min) / centre of the daily median converted value.
137
+
138
+ Under the true wallet currency a preset converts to the same local value every day; under a wrong
139
+ currency the value follows the cross rate day by day."""
140
+ total = 0.0
141
+ weight = 0
142
+ for center, cnt in centers[:5]:
143
+ per_day: dict[str, list[float]] = defaultdict(list)
144
+ for r, v in zip(rows, converted):
145
+ if abs(v - center) <= tol * center:
146
+ per_day[r["day"]].append(v)
147
+ meds = [statistics.median(v) for v in per_day.values() if len(v) >= min_per_day]
148
+ if len(meds) < 2:
149
+ continue
150
+ total += (max(meds) - min(meds)) / center * cnt
151
+ weight += cnt
152
+ return total / weight if weight else None
153
+
154
+
155
+ def jitter(values: list[float], rng: random.Random, lo: float, hi: float) -> list[float]:
156
+ return [v * (1 + rng.choice((-1, 1)) * rng.uniform(lo, hi)) for v in values]
157
+
158
+
159
+ def retail_shaped(x: float, tol: float = 0.006) -> bool:
160
+ r = round(x)
161
+ return any(g > 0 and abs(x - g) <= tol for g in (r, r - 0.01, r - 0.05, r - 0.02))
162
+
163
+
164
+ def match_scheme(peaks: list[tuple[float, int]], tol: float) -> tuple[str, int, float]:
165
+ """Return (scheme name, scale, share of peak attempts on the scheme)."""
166
+ name, scale, share, _ = match_scheme_peaks(peaks, tol)
167
+ return name, scale, share
168
+
169
+
170
+ def match_scheme_peaks(peaks: list[tuple[float, int]], tol: float) -> tuple[str, int, float, list[tuple[float, int]]]:
171
+ total = sum(c for _, c in peaks) or 1
172
+ best = ("none", 1, 0.0, [])
173
+ for name, menu in SCHEMES.items():
174
+ for scale in SCHEME_SCALES:
175
+ on = [(center, cnt) for center, cnt in peaks if any(abs(center - m * scale) <= tol * m * scale for m in menu)]
176
+ share = sum(c for _, c in on) / total
177
+ if share > best[2]:
178
+ best = (name, scale, share, on)
179
+ return best
180
+
181
+
182
+ # ----------------------------------------------------------------------------- core
183
+
184
+ def parse_rows(path: Path, cols: dict) -> list[dict]:
185
+ rows = []
186
+ with path.open(newline="", encoding="utf-8-sig") as fh:
187
+ for r in csv.DictReader(fh):
188
+ try:
189
+ amt = float(r[cols["amount"]])
190
+ except (KeyError, ValueError):
191
+ continue
192
+ ts = r.get(cols["date"], "")[:19]
193
+ rows.append({
194
+ "amount": amt,
195
+ "currency": r.get(cols["currency"], "USD").strip().upper() or "USD",
196
+ "day": ts[:10],
197
+ "ts": ts,
198
+ "descriptor": (r.get(cols["descriptor"], "") or "UNKNOWN_DESCRIPTOR").strip(),
199
+ "country": (r.get(cols["country"], "") or "UNKNOWN").strip(),
200
+ "response": (r.get(cols["response"], "") if cols.get("response") else "").strip(),
201
+ "account": (r.get(cols["account"], "") if cols.get("account") else "").strip(),
202
+ "card_type": (r.get(cols["card_type"], "") if cols.get("card_type") else "").strip(),
203
+ "avs": (r.get(cols["avs"], "") if cols.get("avs") else "").strip(),
204
+ "auth": (r.get(cols["auth"], "") if cols.get("auth") else "").strip(),
205
+ })
206
+ return rows
207
+
208
+
209
+ def infer_wallet(rows: list[dict], rates: Rates, candidates: list[str], opt, rng) -> dict:
210
+ """Per group: coverage under each candidate currency, control against the same centres, day drift."""
211
+ out = {}
212
+ for cur in candidates:
213
+ vals, ok = [], True
214
+ for r in rows:
215
+ rt = rates.rate(r["day"], cur)
216
+ src = rates.rate(r["day"], r["currency"])
217
+ if rt is None or src is None:
218
+ ok = False
219
+ break
220
+ vals.append(r["amount"] / src * rt)
221
+ if not ok or not vals:
222
+ continue
223
+ cov, peaks = coverage(vals, opt.peak_tol, opt.peak_min_share, opt.top_k)
224
+ ctrl = coverage_against(jitter(vals, rng, opt.jitter_lo, opt.jitter_hi), [c for c, _ in peaks], opt.peak_tol)
225
+ drift = day_drift(rows, vals, peaks, opt.peak_tol)
226
+ name, scale, sshare, on = match_scheme_peaks(peaks, opt.scheme_tol)
227
+ out[cur] = {"coverage": round(cov, 4), "control": round(ctrl, 4), "excess": round(cov - ctrl, 4), "day_drift": None if drift is None else round(drift, 4),
228
+ "peaks": [(round(c, 2), n) for c, n in peaks], "scheme": name, "scheme_scale": scale, "scheme_share": round(sshare, 4),
229
+ "scheme_coverage": round(sum(n for _, n in on) / len(vals), 4), "scheme_peaks": [(round(c, 2), n) for c, n in on]}
230
+ return out
231
+
232
+
233
+ def wallet_acceptable(info: dict, opt) -> bool:
234
+ if info["excess"] < opt.wallet_min_excess * 0.4:
235
+ return False
236
+ if info["coverage"] >= opt.wallet_min_coverage and info["excess"] >= opt.wallet_min_excess:
237
+ return True
238
+ return info["scheme_coverage"] >= opt.wallet_min_coverage_with_scheme and info["scheme_share"] >= opt.scheme_min_share
239
+
240
+
241
+ def choose_wallet(walls: dict, legal: str | None, opt) -> str | None:
242
+ """Prefer a currency whose peaks land on a known menu, then the lowest day drift, then legal tender, EUR, USD."""
243
+ ok = [(cur, info) for cur, info in walls.items() if wallet_acceptable(info, opt)]
244
+ if not ok:
245
+ return None
246
+ prio = {legal: 0, "EUR": 1, "USD": 2}
247
+
248
+ def key(kv):
249
+ cur, info = kv
250
+ drift = info["day_drift"] if info["day_drift"] is not None else 1.0
251
+ return (0 if info["scheme_share"] >= opt.scheme_min_share else 1, round(drift / opt.drift_bucket), prio.get(cur, 3), -info["coverage"])
252
+ return sorted(ok, key=key)[0][0]
253
+
254
+
255
+ def rate_consistency(rows: list[dict], rates: Rates, wallet: str, peaks: list, opt) -> dict:
256
+ """For each peak, per day: modal processed amount. Frozen = same cents on >= frozen_days days while market moved."""
257
+ result = {"frozen_peaks": 0, "live_peaks": 0, "frozen_attempts": 0, "tested_attempts": 0, "detail": []}
258
+ for center, _ in peaks:
259
+ by_day: dict[str, Counter] = defaultdict(Counter)
260
+ for r in rows:
261
+ src = rates.rate(r["day"], r["currency"])
262
+ rt = rates.rate(r["day"], wallet)
263
+ if not src or not rt:
264
+ continue
265
+ local = r["amount"] / src * rt
266
+ if abs(local - center) <= opt.peak_tol * center:
267
+ by_day[r["day"]][round(r["amount"], 2)] += 1
268
+ days = sorted(d for d in by_day if sum(by_day[d].values()) >= opt.frozen_min_per_day)
269
+ if len(days) < opt.frozen_days:
270
+ continue
271
+ modal = [by_day[d].most_common(1)[0][0] for d in days]
272
+ attempts = sum(sum(by_day[d].values()) for d in days)
273
+ result["tested_attempts"] += attempts
274
+ move = rates.market_move(wallet, days[0], days[-1]) or 0.0
275
+ distinct = len(set(modal))
276
+ frozen = distinct == 1 and move >= opt.frozen_market_move and wallet != rates.base
277
+ if frozen:
278
+ result["frozen_peaks"] += 1
279
+ result["frozen_attempts"] += attempts
280
+ else:
281
+ result["live_peaks"] += 1
282
+ result["detail"].append({"peak": round(center, 2), "days": len(days), "modal_amounts": sorted(set(modal)), "market_move": round(move, 4), "frozen": frozen, "attempts": attempts})
283
+ return result
284
+
285
+
286
+ def retail_scan(rows: list[dict], opt, rng) -> dict:
287
+ """Find one constant k such that amount / k is retail-shaped for a large share of rows, with many distinct bases."""
288
+ amounts = [r["amount"] for r in rows]
289
+ empty = {"constant": None, "share": 0.0, "control": 0.0, "excess": 0.0, "distinct_bases": 0, "rows": len(amounts)}
290
+ if len(amounts) < opt.retail_min_rows:
291
+ return empty
292
+ best = (1.0, 0.0)
293
+ k = opt.retail_k_min
294
+ while k <= opt.retail_k_max + 1e-9:
295
+ share = sum(retail_shaped(a / k) for a in amounts) / len(amounts)
296
+ if share > best[1]:
297
+ best = (round(k, 4), share)
298
+ k += opt.retail_k_step
299
+ bases = Counter(round(a / best[0], 2) for a in amounts if retail_shaped(a / best[0]))
300
+ distinct = sum(1 for _, c in bases.items() if c >= 3)
301
+ ctrl = sum(retail_shaped(a / best[0]) for a in jitter(amounts, rng, 0.004, 0.02)) / len(amounts)
302
+ return {"constant": best[0], "share": round(best[1], 4), "control": round(ctrl, 4), "excess": round(best[1] - ctrl, 4), "distinct_bases": distinct, "rows": len(amounts)}
303
+
304
+
305
+ def retail_passes(res: dict, opt, strong: bool = False) -> bool:
306
+ if not res["constant"] or res["excess"] < opt.retail_min_excess:
307
+ return False
308
+ if strong:
309
+ return res["share"] >= opt.retail_min_share and res["distinct_bases"] >= opt.retail_strong_distinct
310
+ return res["share"] >= opt.retail_weak_share and res["distinct_bases"] >= opt.retail_min_distinct
311
+
312
+
313
+ def behaviour(rows: list[dict], opt) -> dict:
314
+ n = len(rows)
315
+ acc = Counter(r["account"] for r in rows if r["account"])
316
+ repeat = sum(1 for v in acc.values() if v >= 2) / len(acc) if acc else None
317
+ # retry after decline within window
318
+ by_acc: dict[str, list] = defaultdict(list)
319
+ for r in rows:
320
+ if r["account"] and r["ts"]:
321
+ by_acc[r["account"]].append(r)
322
+ declines = retries = 0
323
+ approvals = {v for v in opt.approval_values}
324
+ for lst in by_acc.values():
325
+ lst.sort(key=lambda r: r["ts"])
326
+ for i, r in enumerate(lst):
327
+ if opt.response_column and r["response"] not in approvals:
328
+ declines += 1
329
+ if i + 1 < len(lst):
330
+ try:
331
+ gap = (datetime.fromisoformat(lst[i + 1]["ts"]) - datetime.fromisoformat(r["ts"])).total_seconds()
332
+ except ValueError:
333
+ continue
334
+ if gap <= opt.retry_window_seconds:
335
+ retries += 1
336
+ hours = Counter(int(r["ts"][11:13]) for r in rows if len(r["ts"]) >= 13)
337
+ top3 = sum(v for _, v in hours.most_common(3)) / n if hours else None
338
+ # burst regularity: coefficient of variation of gaps inside the busiest hour-day
339
+ gaps = []
340
+ if hours:
341
+ buckets: dict[str, list] = defaultdict(list)
342
+ for r in rows:
343
+ if len(r["ts"]) >= 13:
344
+ buckets[r["ts"][:13]].append(r["ts"])
345
+ busiest = max(buckets.values(), key=len)
346
+ ts = sorted(datetime.fromisoformat(t) for t in busiest if len(t) >= 19)
347
+ gaps = [(ts[i + 1] - ts[i]).total_seconds() for i in range(len(ts) - 1)]
348
+ cv = (statistics.pstdev(gaps) / statistics.mean(gaps)) if len(gaps) >= 5 and statistics.mean(gaps) > 0 else None
349
+ avs = sum(1 for r in rows if r["avs"] and "not checked" not in r["avs"].lower()) / n if opt.avs_column else None
350
+ tds = sum(1 for r in rows if r["auth"] and r["auth"].lower() not in ("none", "", "n/a")) / n if opt.auth_column else None
351
+ prepaid = sum(1 for r in rows if "prepaid" in r["card_type"].lower()) / n if opt.card_type_column else None
352
+ bins = Counter(r["account"][:6] for r in rows if len(r["account"]) >= 6)
353
+ top5 = sum(v for _, v in bins.most_common(5)) / n if bins else None
354
+ approved = sum(1 for r in rows if r["response"] in approvals) / n if opt.response_column else None
355
+ fraud = sum(1 for r in rows if r["response"] in set(opt.suspected_fraud_values)) / n if opt.response_column else None
356
+ return {
357
+ "attempts": n, "cards": len(acc) or None, "repeat_card_share": repeat,
358
+ "retry_after_decline": (retries / declines) if declines else None, "declines": declines,
359
+ "top3_hour_share_utc": top3, "burst_gap_cv": cv, "avs_present_share": avs, "three_ds_share": tds,
360
+ "prepaid_share": prepaid, "top5_bin_share": top5, "approval_rate": approved, "suspected_fraud_share": fraud,
361
+ "first_day": min(r["day"] for r in rows), "last_day": max(r["day"] for r in rows),
362
+ }
363
+
364
+
365
+ def on_peak_rows(rows: list[dict], rates: Rates, wallet: str, centers: list[float], tol: float) -> tuple[list[dict], list[dict]]:
366
+ inside, outside = [], []
367
+ for r in rows:
368
+ src = rates.rate(r["day"], r["currency"])
369
+ rt = rates.rate(r["day"], wallet)
370
+ if src and rt and any(abs(r["amount"] / src * rt - c) <= tol * c for c in centers):
371
+ inside.append(r)
372
+ else:
373
+ outside.append(r)
374
+ return inside, outside
375
+
376
+
377
+ def extract_substreams(grows: list[dict], legal: str | None, rates: Rates, opt, rng) -> list[dict]:
378
+ """Peel streams off a country group: menu (wallet + scheme), retail list, then remainder as off-menu."""
379
+ subs = []
380
+ residual = list(grows)
381
+ for _round in range(opt.max_substreams):
382
+ if len(residual) < opt.min_group:
383
+ break
384
+ retail = retail_scan(residual, opt, rng)
385
+ if retail_passes(retail, opt, strong=True):
386
+ k = retail["constant"]
387
+ inside = [r for r in residual if retail_shaped(r["amount"] / k)]
388
+ residual = [r for r in residual if not retail_shaped(r["amount"] / k)]
389
+ subs.append({"stream": f"retail x{k}", "rows": inside, "retail": retail})
390
+ continue
391
+ cands = [c for c in dict.fromkeys([legal, *opt.candidate_currencies]) if c]
392
+ walls = infer_wallet(residual, rates, cands, opt, rng)
393
+ wallet = choose_wallet(walls, legal, opt)
394
+ if wallet:
395
+ info = walls[wallet]
396
+ scheme, scale, sshare = info["scheme"], info["scheme_scale"], info["scheme_share"]
397
+ use_peaks = info["scheme_peaks"] if sshare >= opt.scheme_min_share else info["peaks"]
398
+ inside, outside = on_peak_rows(residual, rates, wallet, [c for c, _ in use_peaks], opt.peak_tol)
399
+ cons = rate_consistency(inside, rates, wallet, use_peaks, opt)
400
+ frozen = bool(cons["tested_attempts"] and cons["frozen_attempts"] / cons["tested_attempts"] >= opt.frozen_share)
401
+ label = f"{wallet} menu-{scheme if sshare >= opt.scheme_min_share else 'none'}{' frozen' if frozen else ' live'}"
402
+ subs.append({"stream": label, "rows": inside, "wallet": wallet, "coverage": info["coverage"], "day_drift": info["day_drift"], "scheme": scheme,
403
+ "scheme_scale": scale, "scheme_share": round(sshare, 3), "peaks": use_peaks, "all_peaks": info["peaks"], "frozen": frozen, "rate_consistency": cons,
404
+ "alternatives": {c: i["coverage"] for c, i in walls.items() if c != wallet}})
405
+ residual = outside
406
+ continue
407
+ if retail_passes(retail, opt):
408
+ k = retail["constant"]
409
+ inside = [r for r in residual if retail_shaped(r["amount"] / k)]
410
+ residual = [r for r in residual if not retail_shaped(r["amount"] / k)]
411
+ subs.append({"stream": f"retail x{k}", "rows": inside, "retail": retail})
412
+ continue
413
+ break
414
+ if residual:
415
+ subs.append({"stream": "off-menu", "rows": residual})
416
+ return subs
417
+
418
+
419
+ def analyse(rows: list[dict], rates: Rates, opt) -> dict:
420
+ rng = random.Random(opt.seed)
421
+ report = {"parameters": {k: v for k, v in vars(opt).items() if k not in ("csv_path", "output", "markdown", "fetch_rates", "rates")}, "descriptors": []}
422
+ by_desc: dict[str, list] = defaultdict(list)
423
+ for r in rows:
424
+ by_desc[r["descriptor"]].append(r)
425
+ for desc, drows in sorted(by_desc.items(), key=lambda x: -len(x[1])):
426
+ entry = {"descriptor": desc, "attempts": len(drows), "groups": [], "streams": []}
427
+ by_ctry: dict[str, list] = defaultdict(list)
428
+ for r in drows:
429
+ by_ctry[r["country"]].append(r)
430
+ stream_rows: dict[str, list] = defaultdict(list)
431
+ small = 0
432
+ for ctry, grows in sorted(by_ctry.items(), key=lambda x: -len(x[1])):
433
+ if len(grows) < opt.min_group:
434
+ small += len(grows)
435
+ stream_rows["small groups"].extend(grows)
436
+ continue
437
+ legal = COUNTRY_CURRENCY.get(ctry.lower())
438
+ subs = extract_substreams(grows, legal, rates, opt, rng)
439
+ group = {"country": ctry, "attempts": len(grows), "legal_tender": legal, "substreams": []}
440
+ for sdef in subs:
441
+ srows = sdef.pop("rows")
442
+ sdef["attempts"] = len(srows)
443
+ sdef["share_of_group"] = round(len(srows) / len(grows), 3)
444
+ sdef["behaviour"] = behaviour(srows, opt)
445
+ group["substreams"].append(sdef)
446
+ stream_rows[sdef["stream"]].extend(srows)
447
+ entry["groups"].append(group)
448
+ for label, srows in sorted(stream_rows.items(), key=lambda kv: -len(kv[1])):
449
+ entry["streams"].append({"stream": label, "attempts": len(srows), "share_of_descriptor": round(len(srows) / len(drows), 3),
450
+ "countries": [c for c, _ in Counter(r["country"] for r in srows).most_common(6)], "behaviour": behaviour(srows, opt)})
451
+ report["descriptors"].append(entry)
452
+ report["rates_carried_days"] = dict(rates.carried)
453
+ return report
454
+
455
+
456
+ def to_markdown(report: dict) -> str:
457
+ fmt = lambda v: "n/a" if v is None else f"{v:.0%}" # noqa: E731
458
+ out = ["# Stream inference", ""]
459
+ for d in report["descriptors"]:
460
+ out += [f"## {d['descriptor']} ({d['attempts']} attempts)", "", "| Stream | Attempts | Share | Countries | Approval | Suspected fraud | Retry after decline | Repeat cards | Top-3-hour share | AVS present |", "|---|---|---|---|---|---|---|---|---|---|"]
461
+ for s in d["streams"]:
462
+ b = s["behaviour"]
463
+ out.append(f"| {s['stream']} | {s['attempts']} | {s['share_of_descriptor']:.0%} | {', '.join(s['countries'])} | {fmt(b['approval_rate'])} | {fmt(b['suspected_fraud_share'])} | {fmt(b['retry_after_decline'])} | {fmt(b['repeat_card_share'])} | {fmt(b['top3_hour_share_utc'])} | {fmt(b['avs_present_share'])} |")
464
+ out += ["", "| Country | n | Sub-stream | Rows | Coverage | Day drift | Scheme share | Peaks |", "|---|---|---|---|---|---|---|---|"]
465
+ for g in d["groups"]:
466
+ for sdef in g["substreams"]:
467
+ peaks = " ".join(f"{c:g}x{n}" for c, n in sdef.get("peaks", [])[:6])
468
+ cov = sdef.get("coverage"); dr = sdef.get("day_drift"); ss = sdef.get("scheme_share")
469
+ out.append(f"| {g['country']} | {g['attempts']} | {sdef['stream']} | {sdef['attempts']} | {'' if cov is None else f'{cov:.0%}'} | {'' if dr is None else f'{dr:.2%}'} | {'' if ss is None else f'{ss:.0%}'} | {peaks} |")
470
+ out.append("")
471
+ return "\n".join(out)
472
+
473
+
474
+ def main(argv=None) -> None:
475
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
476
+ p.add_argument("csv_path", type=Path)
477
+ p.add_argument("--rates", type=Path, required=True, help="daily FX cache JSON: {base, rates:{YYYY-MM-DD:{CCY:rate}}}")
478
+ p.add_argument("--fetch-rates", nargs=2, metavar=("START", "END"), help="download Frankfurter v1 rates into --rates before analysis")
479
+ p.add_argument("--amount-column", required=True)
480
+ p.add_argument("--currency-column", required=True)
481
+ p.add_argument("--date-column", required=True, help="timestamp or date column (ISO)")
482
+ p.add_argument("--descriptor-column", required=True)
483
+ p.add_argument("--bin-country-column", required=True)
484
+ p.add_argument("--response-column")
485
+ p.add_argument("--approval-values", nargs="*", default=["A001"])
486
+ p.add_argument("--suspected-fraud-values", nargs="*", default=["D102"])
487
+ p.add_argument("--account-column")
488
+ p.add_argument("--card-type-column")
489
+ p.add_argument("--avs-column")
490
+ p.add_argument("--auth-column")
491
+ p.add_argument("--candidate-currencies", nargs="*", default=["EUR", "USD", "GBP", "CHF"])
492
+ p.add_argument("--min-group", type=int, default=80)
493
+ p.add_argument("--peak-tol", type=float, default=0.012)
494
+ p.add_argument("--peak-min-share", type=float, default=0.02)
495
+ p.add_argument("--top-k", type=int, default=10)
496
+ p.add_argument("--jitter-lo", type=float, default=0.02)
497
+ p.add_argument("--jitter-hi", type=float, default=0.05)
498
+ p.add_argument("--wallet-min-coverage", type=float, default=0.45, help="share of a group on its top-k peaks needed to call it menu-driven")
499
+ p.add_argument("--wallet-min-excess", type=float, default=0.25)
500
+ p.add_argument("--wallet-min-coverage-with-scheme", type=float, default=0.15, help="lower coverage bar when the peaks sit on a known menu")
501
+ p.add_argument("--drift-bucket", type=float, default=0.004, help="day-drift values within one bucket are ties broken by currency priority")
502
+ p.add_argument("--scheme-min-share", type=float, default=0.5)
503
+ p.add_argument("--scheme-tol", type=float, default=0.015, help="relative tolerance when matching peaks to a menu scheme (cashier markups run 0-3%%)")
504
+ p.add_argument("--max-substreams", type=int, default=3)
505
+ p.add_argument("--frozen-days", type=int, default=4)
506
+ p.add_argument("--frozen-min-per-day", type=int, default=3)
507
+ p.add_argument("--frozen-market-move", type=float, default=0.001)
508
+ p.add_argument("--frozen-share", type=float, default=0.5)
509
+ p.add_argument("--retail-k-min", type=float, default=1.0)
510
+ p.add_argument("--retail-k-max", type=float, default=1.15)
511
+ p.add_argument("--retail-k-step", type=float, default=0.0025)
512
+ p.add_argument("--retail-min-excess", type=float, default=0.2)
513
+ p.add_argument("--retail-min-share", type=float, default=0.3)
514
+ p.add_argument("--retail-min-distinct", type=int, default=5, help="distinct retail base prices (>=3 rows each) needed")
515
+ p.add_argument("--retail-strong-distinct", type=int, default=8, help="distinct bases for retail to be tried before wallet inference")
516
+ p.add_argument("--retail-weak-share", type=float, default=0.2)
517
+ p.add_argument("--retail-min-rows", type=int, default=60)
518
+ p.add_argument("--retry-window-seconds", type=int, default=1800)
519
+ p.add_argument("--seed", type=int, default=7)
520
+ p.add_argument("--output", type=Path)
521
+ p.add_argument("--markdown", type=Path)
522
+ opt = p.parse_args(argv)
523
+ cols = {"amount": opt.amount_column, "currency": opt.currency_column, "date": opt.date_column, "descriptor": opt.descriptor_column, "country": opt.bin_country_column,
524
+ "response": opt.response_column, "account": opt.account_column, "card_type": opt.card_type_column, "avs": opt.avs_column, "auth": opt.auth_column}
525
+ rows = parse_rows(opt.csv_path, cols)
526
+ if opt.fetch_rates:
527
+ symbols = sorted({c for c in COUNTRY_CURRENCY.values() if c not in PEGS} | set(opt.candidate_currencies))
528
+ cache = fetch_rates(opt.fetch_rates[0], opt.fetch_rates[1], "USD", [s for s in symbols if s != "USD"])
529
+ opt.rates.write_text(json.dumps(cache, indent=1))
530
+ rates = Rates(json.loads(opt.rates.read_text()))
531
+ report = analyse(rows, rates, opt)
532
+ text = json.dumps(report, indent=1, default=str)
533
+ if opt.output:
534
+ opt.output.write_text(text)
535
+ else:
536
+ sys.stdout.write(text + "\n")
537
+ if opt.markdown:
538
+ opt.markdown.write_text(to_markdown(report))
539
+
540
+
541
+ if __name__ == "__main__":
542
+ main()