@1aboveio/skills 1.0.10 → 1.0.12

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 (214) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/.codex-plugin/resources.json +298 -216
  3. package/README.md +3 -3
  4. package/package.json +10 -8
  5. package/skills/app-dev/airflow/SKILL.md +1 -1
  6. package/skills/app-dev/android-foundation/SKILL.md +35 -37
  7. package/skills/app-dev/android-foundation/references/delivery-focus.md +4 -2
  8. package/skills/app-dev/android-foundation/references/feature-work.md +50 -0
  9. package/skills/app-dev/android-foundation/references/setup.md +24 -9
  10. package/skills/app-dev/android-foundation/references/workspace.md +81 -0
  11. package/skills/app-dev/android-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
  12. package/skills/app-dev/android-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
  13. package/skills/app-dev/android-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
  14. package/skills/app-dev/app-debug/SKILL.md +1 -1
  15. package/skills/app-dev/better-auth/SKILL.md +1 -1
  16. package/skills/app-dev/data-catalog/SKILL.md +1 -1
  17. package/skills/app-dev/data-quality/SKILL.md +1 -1
  18. package/skills/app-dev/ios-foundation/SKILL.md +15 -10
  19. package/skills/app-dev/ios-foundation/references/setup.md +25 -9
  20. package/skills/app-dev/ios-foundation/references/workspace.md +82 -0
  21. package/skills/app-dev/ios-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
  22. package/skills/app-dev/ios-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
  23. package/skills/app-dev/ios-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
  24. package/skills/app-dev/monorepo/SKILL.md +1 -1
  25. package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
  26. package/skills/app-dev/prisma-setup/SKILL.md +1 -1
  27. package/skills/app-dev/pyspark/SKILL.md +1 -1
  28. package/skills/app-dev/python-backend/SKILL.md +1 -1
  29. package/skills/app-dev/shadcn/SKILL.md +1 -1
  30. package/skills/app-dev/zod-v4/SKILL.md +1 -1
  31. package/skills/cicd/cloud-build/SKILL.md +1 -1
  32. package/skills/cicd/cloud-debug/SKILL.md +1 -1
  33. package/skills/cicd/cloud-deploy/SKILL.md +1 -1
  34. package/skills/cicd/docker/SKILL.md +1 -1
  35. package/skills/cicd/github-actions/SKILL.md +1 -1
  36. package/skills/cicd/github-flow/SKILL.md +1 -1
  37. package/skills/cicd/google-cloud/SKILL.md +1 -1
  38. package/skills/cicd/mergify/SKILL.md +1 -1
  39. package/skills/cicd/podman/SKILL.md +1 -1
  40. package/skills/content/article-review/SKILL.md +1 -1
  41. package/skills/content/article-to-image/SKILL.md +1 -1
  42. package/skills/content/flowchart-svg/SKILL.md +1 -1
  43. package/skills/content/image-gen/SKILL.md +1 -1
  44. package/skills/content/mermaid/SKILL.md +1 -1
  45. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  46. package/skills/content/wechat-publisher/SKILL.md +1 -1
  47. package/skills/content/wechat-search/SKILL.md +1 -1
  48. package/skills/content/xai-search/SKILL.md +1 -1
  49. package/skills/core/distill-writing/SKILL.md +3 -3
  50. package/skills/core/distill-writing/references/extraction.md +1 -1
  51. package/skills/core/doc-to-md/SKILL.md +1 -1
  52. package/skills/core/editorial-review/SKILL.md +82 -12
  53. package/skills/core/editorial-review/references/checklist.md +26 -1
  54. package/skills/core/house-style/SKILL.md +8 -3
  55. package/skills/core/house-style/references/analytical-reporting.md +123 -0
  56. package/skills/core/house-style/references/analytical-writing-patterns.md +79 -0
  57. package/skills/core/house-style/references/analytical-writing-research.md +129 -0
  58. package/skills/core/{write-doc → house-style}/references/bilingual.md +7 -5
  59. package/skills/core/house-style/references/definitions-and-metrics.md +86 -0
  60. package/skills/core/house-style/references/document-workflow.md +193 -0
  61. package/skills/core/house-style/references/initialization.md +66 -0
  62. package/skills/core/house-style/references/persistence.md +9 -4
  63. package/skills/core/house-style/references/rules.md +11 -4
  64. package/skills/core/house-style/scripts/init-guidance.mjs +213 -0
  65. package/skills/core/house-style/templates/analytical-report.md +54 -0
  66. package/skills/core/house-style/templates/gap-analysis.md +1 -1
  67. package/skills/core/plain-language/SKILL.md +2 -2
  68. package/skills/core/polish-cn/SKILL.md +9 -2
  69. package/skills/core/reg-parser/SKILL.md +3 -3
  70. package/skills/core/terminology/SKILL.md +8 -4
  71. package/skills/core/write-doc/SKILL.md +40 -151
  72. package/skills/core/write-report/SKILL.md +129 -0
  73. package/skills/core/{write-doc → write-report}/references/compliance-reports.md +4 -4
  74. package/skills/engineering/better-review/SKILL.md +37 -37
  75. package/skills/engineering/better-review/references/passes.md +35 -41
  76. package/skills/engineering/better-review/references/result.md +10 -9
  77. package/skills/engineering/e2e-test/SKILL.md +17 -17
  78. package/skills/engineering/e2e-test/references/authoring/playwright-config.md +11 -13
  79. package/skills/engineering/e2e-test/references/presentation-sweep.md +7 -7
  80. package/skills/engineering/e2e-test/references/reviewing.md +9 -11
  81. package/skills/engineering/e2e-test/references/route-manifest.md +1 -1
  82. package/skills/engineering/engineering/SKILL.md +201 -0
  83. package/skills/engineering/engineering/references/engineering-conventions.md +32 -0
  84. package/skills/engineering/engineering/references/feature-delivery.md +8 -0
  85. package/skills/engineering/engineering/references/init-scope.md +45 -0
  86. package/skills/engineering/engineering/references/scope-management.md +85 -0
  87. package/skills/engineering/engineering/scripts/init.mjs +160 -0
  88. package/skills/engineering/{contributing → engineering}/scripts/persist-conventions.mjs +1 -1
  89. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  90. package/skills/engineering/engineering-runtime/coherence/workflow.json +19 -19
  91. package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
  92. package/skills/engineering/ensure-coverage/SKILL.md +22 -109
  93. package/skills/engineering/ensure-coverage/references/breadth/coverage-ledger.md +7 -5
  94. package/skills/engineering/ensure-coverage/references/breadth/surface-baseline.md +4 -4
  95. package/skills/engineering/ensure-coverage/references/breadth/surface-discovery.md +3 -3
  96. package/skills/engineering/ensure-coverage/references/depth/characterization.md +4 -65
  97. package/skills/engineering/ensure-coverage/references/depth/grading.md +5 -7
  98. package/skills/engineering/ensure-coverage/references/depth/mock-policy.md +6 -12
  99. package/skills/engineering/ensure-coverage/references/enforcement/ci-contract.md +1 -1
  100. package/skills/engineering/ensure-coverage/references/presentation/axis.md +6 -6
  101. package/skills/engineering/ensure-coverage/references/process/audit-mode.md +7 -23
  102. package/skills/engineering/ensure-coverage/references/process/output-template.md +18 -133
  103. package/skills/engineering/ensure-coverage/references/process/test-strategy.md +7 -12
  104. package/skills/engineering/ensure-coverage/scripts/README.md +12 -4
  105. package/skills/engineering/golden-path/SKILL.md +1 -1
  106. package/skills/engineering/harness-runtime/SKILL.md +3 -1
  107. package/skills/engineering/harness-runtime/bin/workspaces.mjs +52 -0
  108. package/skills/engineering/harness-runtime/native-workspace.mjs +239 -0
  109. package/skills/engineering/harness-runtime/references/workspaces.md +78 -0
  110. package/skills/engineering/harness-runtime/workspace-lifecycle.mjs +2154 -0
  111. package/skills/engineering/resolve-issues/SKILL.md +40 -61
  112. package/skills/engineering/resolve-issues/references/ci.md +13 -3
  113. package/skills/engineering/resolve-issues/references/delivery-progress.md +4 -3
  114. package/skills/engineering/resolve-issues/references/done.md +11 -2
  115. package/skills/engineering/resolve-issues/references/expire.md +9 -2
  116. package/skills/engineering/resolve-issues/references/implementation.md +33 -17
  117. package/skills/engineering/resolve-issues/references/preflight.md +3 -1
  118. package/skills/engineering/resolve-issues/references/principles.md +52 -0
  119. package/skills/engineering/resolve-issues/references/review.md +25 -15
  120. package/skills/engineering/resolve-issues/references/task-plan.md +64 -27
  121. package/skills/engineering/resolve-issues/references/workspaces.md +164 -0
  122. package/skills/engineering/resolve-issues/scripts/cleanup-runs.mjs +185 -317
  123. package/skills/engineering/resolve-issues/scripts/next-actions.mjs +44 -7
  124. package/skills/engineering/resolve-issues/scripts/rehydrate.mjs +36 -2
  125. package/skills/engineering/resolve-issues/scripts/review-verdict.mjs +9 -8
  126. package/skills/engineering/resolve-issues/scripts/workspace-lifecycle.mjs +1 -0
  127. package/skills/engineering/resolve-release/SKILL.md +1 -1
  128. package/skills/engineering/rush-issues/SKILL.md +7 -5
  129. package/skills/engineering/rush-release/SKILL.md +1 -1
  130. package/skills/engineering/test-quality/SKILL.md +19 -4
  131. package/skills/engineering/test-quality/references/lint.md +1 -1
  132. package/skills/engineering/test-quality/references/principles.md +2 -2
  133. package/skills/engineering/test-quality/references/testing-conventions.md +1 -1
  134. package/skills/risk-compliance/compliance-audit/SKILL.md +4 -8
  135. package/skills/risk-compliance/compliance-audit/references/audience-and-reports.md +12 -0
  136. package/skills/risk-compliance/contract-compare/SKILL.md +17 -1
  137. package/skills/risk-compliance/contract-review/SKILL.md +6 -1
  138. package/skills/risk-compliance/contract-review/references/report-stage.md +17 -0
  139. package/skills/risk-compliance/doc-authenticity/SKILL.md +11 -1
  140. package/skills/risk-compliance/fraud-detection/LICENSE +3 -0
  141. package/skills/risk-compliance/fraud-detection/SKILL.md +148 -0
  142. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/first-party-fraud.md +3 -2
  143. package/skills/risk-compliance/fraud-detection/references/archetypes/third-party-fraud.md +47 -0
  144. package/skills/risk-compliance/fraud-detection/references/collection-payout-features.md +151 -0
  145. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/contexts/bank-transfer.md +4 -3
  146. package/skills/risk-compliance/fraud-detection/references/contexts/card-payment.md +70 -0
  147. package/skills/risk-compliance/fraud-detection/references/contexts/payment-collection.md +34 -0
  148. package/skills/risk-compliance/fraud-detection/references/contexts/payout.md +34 -0
  149. package/skills/risk-compliance/fraud-detection/references/enrichment.md +74 -0
  150. package/skills/risk-compliance/fraud-detection/references/entities.md +48 -0
  151. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/feature-engineering.md +69 -33
  152. package/skills/risk-compliance/fraud-detection/references/feature-sql.md +150 -0
  153. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-rationale.md +4 -2
  154. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-template.md +45 -23
  155. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/review-checklist.md +60 -20
  156. package/skills/risk-compliance/fraud-detection/references/workflow.md +216 -0
  157. package/skills/risk-compliance/fraud-detection/release-check.mjs +95 -0
  158. package/skills/risk-compliance/payment-analysis/SKILL.md +36 -17
  159. package/skills/risk-compliance/payment-analysis/references/auth-rate-actions.md +2 -2
  160. package/skills/risk-compliance/payment-analysis/references/report-template.md +10 -2
  161. package/skills/risk-compliance/payment-analysis/references/visualization.md +11 -1
  162. package/skills/risk-compliance/portfolio-payment-analysis/LICENSE +3 -0
  163. package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +170 -0
  164. package/skills/risk-compliance/portfolio-payment-analysis/references/identity-and-reconciliation.md +46 -0
  165. package/skills/risk-compliance/portfolio-payment-analysis/references/metrics-and-reporting.md +170 -0
  166. package/skills/risk-compliance/portfolio-payment-analysis/references/risk-history.md +89 -0
  167. package/skills/risk-compliance/portfolio-payment-analysis/release-check.mjs +95 -0
  168. package/skills/risk-compliance/portfolio-payment-analysis/scripts/analyze.py +636 -0
  169. package/skills/risk-compliance/portfolio-payment-analysis/scripts/build_report.py +386 -0
  170. package/skills/risk-compliance/portfolio-payment-analysis/scripts/comparison_charts.py +425 -0
  171. package/skills/risk-compliance/portfolio-payment-analysis/scripts/csv_export.py +52 -0
  172. package/skills/risk-compliance/portfolio-payment-analysis/scripts/render.py +160 -0
  173. package/skills/risk-compliance/portfolio-payment-analysis/scripts/risk_history.py +435 -0
  174. package/skills/risk-compliance/similar-domain/SKILL.md +9 -4
  175. package/skills/risk-compliance/tld-detection/SKILL.md +124 -239
  176. package/skills/risk-compliance/tld-detection/agents/openai.yaml +1 -1
  177. package/skills/risk-compliance/tld-detection/references/clean.md +18 -51
  178. package/skills/risk-compliance/tld-detection/references/fx-conversion.md +56 -219
  179. package/skills/risk-compliance/tld-detection/references/metric-contract.md +109 -0
  180. package/skills/risk-compliance/tld-detection/references/pattern.md +99 -0
  181. package/skills/risk-compliance/tld-detection/references/report-template.md +49 -272
  182. package/skills/risk-compliance/tld-detection/scripts/detect_ladders.py +46 -13
  183. package/skills/risk-compliance/tld-detection/scripts/detect_patterns.py +346 -0
  184. package/skills/risk-compliance/tld-detection/scripts/detect_streams.py +51 -19
  185. package/skills/risk-compliance/tld-detection/scripts/profile_groups.py +109 -0
  186. package/skills/risk-compliance/tld-detection/scripts/render_charts.py +218 -0
  187. package/skills/risk-compliance/tld-detection/scripts/tld_common.py +243 -0
  188. package/skills/engineering/contributing/SKILL.md +0 -70
  189. package/skills/risk-compliance/fraud-analysis/SKILL.md +0 -114
  190. package/skills/risk-compliance/fraud-analysis/references/archetypes/third-party-fraud.md +0 -27
  191. package/skills/risk-compliance/fraud-analysis/references/contexts/card-payment.md +0 -30
  192. package/skills/risk-compliance/fraud-analysis/references/contexts/payment-collection.md +0 -20
  193. package/skills/risk-compliance/fraud-analysis/references/contexts/payout.md +0 -20
  194. package/skills/risk-compliance/fraud-analysis/references/workflow.md +0 -175
  195. package/skills/risk-compliance/tld-detection/references/chinese-polish.md +0 -55
  196. package/skills/risk-compliance/tld-detection/references/clustering.md +0 -235
  197. package/skills/risk-compliance/tld-detection/references/decision.md +0 -189
  198. package/skills/risk-compliance/tld-detection/references/streams.md +0 -141
  199. package/skills/risk-compliance/tld-detection/references/terminology.md +0 -95
  200. package/skills/risk-compliance/tld-detection/references/workflow.md +0 -95
  201. /package/skills/{engineering/contributing → core/write-report}/LICENSE +0 -0
  202. /package/skills/core/{write-doc → write-report}/references/reports/audit-report-template.md +0 -0
  203. /package/skills/core/{write-doc → write-report}/references/reports/external-assessment-template.md +0 -0
  204. /package/skills/core/{write-doc → write-report}/references/reports/management-report-template.md +0 -0
  205. /package/skills/core/{write-doc → write-report}/references/reports/vars-report-template.md +0 -0
  206. /package/skills/{engineering/contributing → core/write-report}/release-check.mjs +0 -0
  207. /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/LICENSE +0 -0
  208. /package/skills/engineering/{contributing → engineering}/references/tracker-conventions.md +0 -0
  209. /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/release-check.mjs +0 -0
  210. /package/skills/engineering/{contributing → engineering}/scripts/persist-conventions-core.mjs +0 -0
  211. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/authorized-payment-scam.md +0 -0
  212. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/mechanisms/account-takeover.md +0 -0
  213. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/taxonomy.md +0 -0
  214. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/terminology.md +0 -0
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env python3
2
+ """Detect independent patterns in a prepared dataset.
3
+
4
+ Pricing rates adapter: {"base":"USD","rates":{"YYYY-MM-DD":{"EUR":"0.9"}}}.
5
+ Rates are units of each currency per one base unit, from validated exact-date v2
6
+ quotes. Include no carried or pegged quotes. Same-currency conversion needs no quote.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import hashlib
12
+ import json
13
+ import math
14
+ import random
15
+ import sys
16
+ from collections import Counter, defaultdict
17
+ from datetime import date, datetime, timezone
18
+ from decimal import Decimal, InvalidOperation
19
+ from fractions import Fraction
20
+ from pathlib import Path
21
+ from types import SimpleNamespace
22
+
23
+ import detect_ladders as ladders
24
+ import detect_streams as streams
25
+ from tld_common import load_dataset, view_rows, scope_of, validate_groups, write_json
26
+
27
+
28
+ class ExactRates:
29
+ def __init__(self, payload):
30
+ if not isinstance(payload, dict) or set(payload) != {"base", "rates"} or not isinstance(payload["rates"], dict):
31
+ raise ValueError("pricing rates must contain only base and rates")
32
+ self.base = currency(payload["base"])
33
+ self.rates = {}
34
+ for day, quotes in payload["rates"].items():
35
+ if date.fromisoformat(day).isoformat() != day or not isinstance(quotes, dict):
36
+ raise ValueError("invalid pricing rate date or quotes")
37
+ self.rates[day] = {}
38
+ for code, value in quotes.items():
39
+ code = currency(code)
40
+ if not isinstance(value, str):
41
+ raise ValueError("pricing rates must be decimal strings")
42
+ rate = Decimal(value)
43
+ if not rate.is_finite() or rate <= 0 or (code == self.base and rate != 1):
44
+ raise ValueError("pricing rates must be positive and base must equal one")
45
+ bounded_float(rate, f"rate {day} {code}")
46
+ self.rates[day][code] = value
47
+
48
+ def rate(self, day, cur):
49
+ value = self.quote(day, cur)
50
+ return bounded_float(Decimal(value), f"rate {day} {cur}") if value is not None else None
51
+
52
+ def quote(self, day, cur):
53
+ return "1" if cur == self.base else self.rates.get(day, {}).get(cur)
54
+
55
+ def market_move(self, cur, day_a, day_b, source=None):
56
+ source = source or self.base
57
+ a, b, sa, sb = (self.rate(day_a, cur), self.rate(day_b, cur), self.rate(day_a, source), self.rate(day_b, source))
58
+ return abs((b / sb) / (a / sa) - 1) if all((a, b, sa, sb)) else None
59
+
60
+
61
+ class SourceRates:
62
+ """Rebase exact quotes on the charge currency; identity needs no quote."""
63
+ def __init__(self, rates, source):
64
+ self.rates, self.base = rates, source
65
+
66
+ def rate(self, day, cur):
67
+ if cur == self.base:
68
+ return 1.0
69
+ source = self.rates.rate(day, self.base)
70
+ target = self.rates.rate(day, cur)
71
+ if source is None or target is None:
72
+ return None
73
+ return bounded_float(Decimal(str(target)) / Decimal(str(source)), f"cross-rate {day} {self.base}/{cur}")
74
+
75
+ def market_move(self, cur, day_a, day_b, source=None):
76
+ return self.rates.market_move(cur, day_a, day_b, source or self.base)
77
+
78
+
79
+ def bounded_float(value, label):
80
+ # The legacy engine rounds peaks to cents and uses binary floats. Refuse
81
+ # values outside a conservative inference range rather than misclassify them.
82
+ if not Decimal('1e-100') <= value <= Decimal('1e100'):
83
+ raise ValueError(f"unsupported pricing inference range for {label}: {value}")
84
+ result = float(value)
85
+ if not math.isfinite(result) or result == 0:
86
+ raise ValueError(f"unsupported pricing inference range for {label}: {value}")
87
+ return result
88
+
89
+
90
+ def retail_exact(amount, constant):
91
+ value = Fraction(Decimal(amount)) / Fraction(Decimal(str(constant)))
92
+ nearest = round(value)
93
+ return any(base > 0 and abs(value - base) <= Fraction(6, 1000)
94
+ for base in (Fraction(nearest), Fraction(nearest) - Fraction(1, 100),
95
+ Fraction(nearest) - Fraction(5, 100), Fraction(nearest) - Fraction(2, 100)))
96
+
97
+
98
+ def currency(value):
99
+ if not isinstance(value, str) or len(value) != 3 or not value.isascii() or not value.isalpha() or value != value.upper():
100
+ raise ValueError("currency must be a three-letter uppercase code")
101
+ return value
102
+
103
+
104
+ def decimal_arg(value):
105
+ try:
106
+ n = Decimal(value)
107
+ if not n.is_finite() or n < 0:
108
+ raise ValueError()
109
+ return n
110
+ except (InvalidOperation, ValueError):
111
+ raise argparse.ArgumentTypeError("expected a finite non-negative decimal")
112
+
113
+
114
+ def cap_arg(value):
115
+ try:
116
+ code, amount = value.split("=", 1)
117
+ return currency(code), decimal_arg(amount)
118
+ except (ValueError, argparse.ArgumentTypeError) as exc:
119
+ raise argparse.ArgumentTypeError("expected CCY=non-negative-decimal") from exc
120
+
121
+
122
+ def jsonable(value):
123
+ if isinstance(value, Decimal):
124
+ return str(value)
125
+ if isinstance(value, set):
126
+ return [jsonable(v) for v in sorted(value)]
127
+ if isinstance(value, (tuple, list)):
128
+ return [jsonable(v) for v in value]
129
+ if isinstance(value, dict):
130
+ return {str(k): jsonable(v) for k, v in value.items()}
131
+ return value
132
+
133
+
134
+ def scope_key(row):
135
+ scope = scope_of(row)
136
+ return tuple(scope[k] for k in ("mid", "descriptor", "view", "currency"))
137
+
138
+
139
+ def main(argv=None):
140
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
141
+ p.add_argument("dataset", type=Path)
142
+ p.add_argument("output", type=Path)
143
+ p.add_argument("--pattern", action="append", choices=("concentration", "escalating", "pricing"), required=True)
144
+ p.add_argument("--concentration-min-count", type=int)
145
+ p.add_argument("--concentration-min-share", type=decimal_arg)
146
+ p.add_argument("--cap", action="append", type=cap_arg, default=[])
147
+ p.add_argument("--sensitivity-cap", action="append", type=cap_arg, default=[])
148
+ p.add_argument("--min-levels", type=int, default=3)
149
+ p.add_argument("--min-cluster-count", type=int, default=100)
150
+ p.add_argument("--multi-anchor-min-count", type=int, default=24)
151
+ p.add_argument("--pricing-rates", type=Path)
152
+ p.add_argument("--pricing-currency", action="append", default=[])
153
+ p.add_argument("--overwrite", action="store_true")
154
+ a = p.parse_args(argv)
155
+ patterns = sorted(set(a.pattern))
156
+ if "concentration" in patterns and (a.concentration_min_count is None or a.concentration_min_count < 1 or a.concentration_min_share is None or a.concentration_min_share > 1):
157
+ p.error("concentration requires --concentration-min-count >= 1 and --concentration-min-share between 0 and 1")
158
+ if a.min_levels < 3 or a.min_cluster_count < 1 or a.multi_anchor_min_count < 1:
159
+ p.error("invalid escalation thresholds")
160
+ if len(dict(a.cap)) != len(a.cap):
161
+ p.error("duplicate currency cap")
162
+ if "escalating" in patterns and not a.cap:
163
+ p.error("escalating requires per-currency --cap")
164
+ try:
165
+ candidates = sorted({currency(x) for x in a.pricing_currency})
166
+ rates = ExactRates(json.loads(a.pricing_rates.read_text(encoding="utf-8"))) if a.pricing_rates else ExactRates({"base": "USD", "rates": {}})
167
+ data = load_dataset(a.dataset)
168
+ original = {r["transaction_id"]: r for r in data["transactions"]}
169
+ rows = sorted(view_rows(data), key=lambda r: (scope_key(r), r["transaction_id"]))
170
+ partitions = defaultdict(list)
171
+ for row in rows:
172
+ partitions[scope_key(row)].append(row)
173
+ groups, diagnostics = [], []
174
+ config = ladders.LadderConfig(dict(a.cap), a.min_levels, a.min_cluster_count, a.multi_anchor_min_count)
175
+ # These are the discovery helper's defaults, not calibrated thresholds.
176
+ opt = SimpleNamespace(peak_tol=.012, peak_min_share=.02, top_k=10, jitter_lo=.02, jitter_hi=.05,
177
+ scheme_tol=.015, wallet_min_excess=.25, wallet_min_coverage=.45,
178
+ wallet_min_coverage_with_scheme=.15, scheme_min_share=.5, drift_bucket=.004,
179
+ retail_min_rows=60, retail_k_min=1., retail_k_max=1.15, retail_k_step=.0025,
180
+ retail_min_excess=.2, retail_min_share=.3, retail_strong_distinct=8,
181
+ retail_weak_share=.2, retail_min_distinct=5)
182
+ def add(pattern, scope, members, details):
183
+ ids = sorted({r["transaction_id"] for r in members})
184
+ if not ids:
185
+ return
186
+ identity = json.dumps([pattern, scope, ids, details], sort_keys=True, ensure_ascii=False, separators=(",", ":"))
187
+ groups.append({"id": "G-" + hashlib.sha256(identity.encode()).hexdigest()[:24], "pattern": pattern,
188
+ "scope": scope, "member_ids": ids, "details": details})
189
+ for key, part in sorted(partitions.items()):
190
+ scope = scope_of(part[0]); counts = Counter(Decimal(r["amount"]) for r in part)
191
+ if "concentration" in patterns:
192
+ for amount, count in sorted(counts.items()):
193
+ selected = count >= a.concentration_min_count and Decimal(count) / len(part) >= a.concentration_min_share
194
+ info = {"amount": str(amount), "count": count, "denominator": len(part), "selected": selected}
195
+ diagnostics.append({"pattern": "concentration", "scope": scope, **info})
196
+ if selected:
197
+ add("concentration", scope, [r for r in part if Decimal(r["amount"]) == amount], info)
198
+ if "escalating" in patterns:
199
+ if key[3] not in dict(a.cap):
200
+ raise ValueError(f"missing absolute residual cap for {key[3]}")
201
+ pairs = {(key[3], amount): count for amount, count in counts.items()}
202
+ found, meta = ladders.detect_ladders(pairs, len(part), config)
203
+ sensitivity = ladders.sensitivity_analysis(pairs, len(part), config, key[3], [cap for cur, cap in a.sensitivity_cap if cur == key[3]])
204
+ diagnostics.append({"pattern": "escalating", "scope": scope, "metadata": jsonable(meta), "sensitivity": jsonable(sensitivity)})
205
+ for ladder in found:
206
+ amounts = {pair[1] for pair in ladder["pairs"]}
207
+ add("escalating", scope, [r for r in part if Decimal(r["amount"]) in amounts], jsonable(ladder))
208
+ if "pricing" in patterns:
209
+ chunks = defaultdict(list)
210
+ for tx in data["transactions"]:
211
+ chunks[(tx["mid"], tx["billing_descriptor"], tx["currency"], tx.get("card_bin_country") or "")].append(tx)
212
+ for (mid, descriptor, source, country), chunk in sorted(chunks.items()):
213
+ positive = [r for r in chunk if Decimal(r["amount"]) > 0]
214
+ prepared = [{"transaction_id": r["transaction_id"],
215
+ "amount": bounded_float(Decimal(r["amount"]), f"amount {r['transaction_id']}"),
216
+ "currency": source,
217
+ "day": datetime.fromisoformat(r["transaction_time"]).astimezone(timezone.utc).date().isoformat()} for r in positive]
218
+ rng = random.Random(7)
219
+ scope_parts = [(scope_of(part[0]), part) for key, part in sorted(partitions.items()) if key[:2] == (mid, descriptor)]
220
+ diagnostic = {"pattern": "pricing", "source": source, "country": country,
221
+ "count": len(chunk), "zero_excluded": len(chunk) - len(positive)}
222
+ discoveries = []
223
+ if not prepared:
224
+ diagnostic["reason"] = "no positive pricing support"
225
+ elif len(positive) < 80:
226
+ diagnostic["reason"] = "small group"
227
+ else:
228
+ retail = streams.retail_scan(prepared, opt, rng,
229
+ exact_match=lambda r, k: retail_exact(original[r["transaction_id"]]["amount"], k))
230
+ source_rates = SourceRates(rates, source)
231
+ for r in prepared:
232
+ for target in sorted(set(candidates + [source])):
233
+ cross = source_rates.rate(r["day"], target)
234
+ if cross is not None and (not math.isfinite(r["amount"] * cross) or r["amount"] * cross < 0.005):
235
+ raise ValueError(f"unsupported pricing inference range for converted amount {r['transaction_id']} {target}")
236
+ walls = streams.infer_wallet(prepared, source_rates, sorted(set(candidates + [source])), opt, rng)
237
+ diagnostic.update({"candidates": walls, "retail": retail})
238
+ by_id = {r["transaction_id"]: r for r in positive}
239
+ if streams.retail_passes(retail, opt):
240
+ evidence = [{"transaction_id": r["transaction_id"], "date": r["day"],
241
+ "charge_amount": original[r["transaction_id"]]["amount"]} for r in prepared
242
+ if retail_exact(original[r["transaction_id"]]["amount"], retail["constant"])]
243
+ discoveries.append(({"form": "retail", "charge_currency": source, "country": country, "fit": retail}, evidence))
244
+ final_candidates = {}
245
+ diagnostic["final_candidates"] = final_candidates
246
+ for target, info in sorted(walls.items()):
247
+ if info["scheme"] not in streams.SCHEMES:
248
+ final_candidates[target] = {"accepted": False, "reason": "no scheme candidate"}
249
+ continue
250
+ # Float peaks propose centers only; membership and gates use
251
+ # the exact source amounts and quotes against those centers.
252
+ menu = streams.SCHEMES[info["scheme"]]
253
+ scale = info["scheme_scale"]
254
+ centers = [Decimal(str(x[0])) for x in info["scheme_peaks"]
255
+ if any(abs(Fraction(Decimal(str(x[0]))) - price * scale)
256
+ <= Fraction(Decimal(str(opt.scheme_tol))) * price * scale
257
+ for price in menu)]
258
+ all_centers = [Decimal(str(x[0])) for x in info["peaks"]]
259
+ evidence = []
260
+ all_matched = 0
261
+ peak_counts = Counter()
262
+ scheme_counts = Counter()
263
+ control_matched = 0
264
+ control_rng = random.Random(7)
265
+ tolerance = Fraction(Decimal(str(opt.peak_tol)))
266
+ for r in prepared:
267
+ day = r["day"]
268
+ src = rates.quote(day, source); dst = rates.quote(day, target)
269
+ if source == target:
270
+ src = dst = "1"
271
+ if src is None or dst is None:
272
+ continue
273
+ value = Fraction(Decimal(by_id[r["transaction_id"]]["amount"])) * Fraction(Decimal(dst)) / Fraction(Decimal(src))
274
+ def matches_center(v, c):
275
+ return abs(v - Fraction(c)) <= tolerance * Fraction(c)
276
+ peak_matches = [c for c in all_centers if matches_center(value, c)]
277
+ if peak_matches:
278
+ all_matched += 1
279
+ peak_counts[min(peak_matches, key=lambda c: abs(value - Fraction(c)))] += 1
280
+ factor = 1 + control_rng.choice((-1, 1)) * control_rng.uniform(opt.jitter_lo, opt.jitter_hi)
281
+ control_value = value * Fraction(Decimal(str(factor)))
282
+ control_matched += any(matches_center(control_value, c) for c in centers)
283
+ matches = [c for c in centers if matches_center(value, c)]
284
+ if not matches:
285
+ continue
286
+ center = min(matches, key=lambda c: abs(value - Fraction(c)))
287
+ scheme_counts[center] += 1
288
+ evidence.append({"transaction_id": r["transaction_id"], "date": day,
289
+ "charge_amount": original[r["transaction_id"]]["amount"],
290
+ "matched_price": str(center), "exact_rate": str(Decimal(dst) / Decimal(src)),
291
+ "rate_base": rates.base, "source_quote": src, "target_quote": dst})
292
+ total = len(prepared)
293
+ coverage = len(evidence) / total
294
+ control = control_matched / total
295
+ scheme_share = len(evidence) / all_matched if all_matched else 0.0
296
+ final = {**info, "coverage": coverage,
297
+ "usable_coverage": len(evidence) / info["usable_rows"] if info["usable_rows"] else None,
298
+ "scheme_coverage": coverage, "scheme_share": scheme_share,
299
+ "control": control, "excess": coverage - control,
300
+ "peaks": [(float(c), peak_counts[c]) for c in all_centers],
301
+ "scheme_peaks": [(float(c), scheme_counts[c]) for c in centers]}
302
+ exact_coverage = Fraction(len(evidence), total)
303
+ exact_excess = Fraction(len(evidence) - control_matched, total)
304
+ exact_share = Fraction(len(evidence), all_matched) if all_matched else Fraction(0)
305
+ minimum_share = Fraction(Decimal(str(opt.scheme_min_share)))
306
+ accepted = (exact_share >= minimum_share
307
+ and exact_excess >= Fraction(Decimal(str(opt.wallet_min_excess))) * Fraction(2, 5)
308
+ and ((exact_coverage >= Fraction(Decimal(str(opt.wallet_min_coverage)))
309
+ and exact_excess >= Fraction(Decimal(str(opt.wallet_min_excess))))
310
+ or exact_coverage >= Fraction(Decimal(str(opt.wallet_min_coverage_with_scheme)))))
311
+ final_candidates[target] = {"accepted": accepted, "fit": final, "frozen_count": len(evidence)}
312
+ if accepted:
313
+ discoveries.append(({"form": "direct" if source == target else "fx-converted", "charge_currency": source,
314
+ "pricing_currency": target, "country": country, "fit": final}, evidence))
315
+ for scope, part in scope_parts:
316
+ diagnostics.append({**diagnostic, "scope": scope})
317
+ eligible = {r["transaction_id"]: r for r in part}
318
+ for details, evidence in discoveries:
319
+ projected = [e for e in evidence if e["transaction_id"] in eligible]
320
+ excluded = [e["transaction_id"] for e in evidence if e["transaction_id"] not in eligible]
321
+ diagnostics.append({"pattern": "pricing", "scope": scope, "source": source, "country": country,
322
+ "form": details["form"], "pricing_currency": details.get("pricing_currency"),
323
+ "frozen_count": len(evidence), "projected_count": len(projected),
324
+ "projected_excluded_ids": excluded})
325
+ add("pricing", scope, [eligible[e["transaction_id"]] for e in projected], {**details, "evidence": projected})
326
+ groups.sort(key=lambda g: (g["pattern"], tuple(g["scope"][k] for k in ("mid", "descriptor", "view", "currency")), g["id"]))
327
+ payload = {"schema_version": 1, "dataset_sha256": data["dataset_sha256"], "period": data["period"], "groups": groups,
328
+ "parameters": {"patterns": patterns, "concentration_min_count": a.concentration_min_count,
329
+ "concentration_min_share": str(a.concentration_min_share) if a.concentration_min_share is not None else None,
330
+ "caps": jsonable(dict(a.cap)), "sensitivity_caps": jsonable(a.sensitivity_cap),
331
+ "min_levels": a.min_levels, "min_cluster_count": a.min_cluster_count,
332
+ "multi_anchor_min_count": a.multi_anchor_min_count, "pricing": vars(opt),
333
+ "pricing_min_group": 80, "pricing_seed": 7, "pricing_currencies": candidates,
334
+ "pricing_inference_range": "positive amounts, quotes and cross-rates from 1e-100 through 1e100 inclusive; converted candidates must be at least 0.005; heuristic candidates use float",
335
+ "pricing_rates": {"kind": "exact-date adapter", "sha256": hashlib.sha256(a.pricing_rates.read_bytes()).hexdigest()} if a.pricing_rates else None},
336
+ "census": data.get("census", {}), "diagnostics": diagnostics}
337
+ validate_groups(data, payload)
338
+ write_json(a.output, payload, overwrite=a.overwrite)
339
+ except (ValueError, OSError, KeyError, InvalidOperation) as exc:
340
+ print(f"detect_patterns: {exc}", file=sys.stderr)
341
+ return 1
342
+ return 0
343
+
344
+
345
+ if __name__ == "__main__":
346
+ sys.exit(main())
@@ -144,6 +144,8 @@ def day_drift(rows: list[dict], converted: list[float], centers: list[tuple[floa
144
144
  total = 0.0
145
145
  weight = 0
146
146
  for center, cnt in centers[:5]:
147
+ if center <= 0:
148
+ continue
147
149
  per_day: dict[str, list[float]] = defaultdict(list)
148
150
  for r, v in zip(rows, converted):
149
151
  if abs(v - center) <= tol * center:
@@ -210,32 +212,47 @@ def parse_rows(path: Path, cols: dict) -> list[dict]:
210
212
  return rows
211
213
 
212
214
 
215
+ def candidate_rows(rows: list[dict], rates: Rates, cur: str) -> tuple[list[dict], list[float], dict]:
216
+ usable_rows, vals = [], []
217
+ missing = Counter()
218
+ for r in rows:
219
+ rt = rates.rate(r["day"], cur)
220
+ src = rates.rate(r["day"], r["currency"])
221
+ if not rt or not src:
222
+ reason = "missing_candidate_and_source_rate" if not rt and not src else (
223
+ "missing_candidate_rate" if not rt else "missing_source_rate")
224
+ missing[reason] += 1
225
+ continue
226
+ usable_rows.append(r)
227
+ vals.append(r["amount"] / src * rt)
228
+ return usable_rows, vals, {"total_rows": len(rows), "usable_rows": len(vals),
229
+ "missing_rows": len(rows) - len(vals), "missing_reasons": dict(missing)}
230
+
231
+
213
232
  def infer_wallet(rows: list[dict], rates: Rates, candidates: list[str], opt, rng) -> dict:
214
233
  """Per group: coverage under each candidate currency, control against the same centres, day drift."""
215
234
  out = {}
216
235
  for cur in candidates:
217
- vals, ok = [], True
218
- for r in rows:
219
- rt = rates.rate(r["day"], cur)
220
- src = rates.rate(r["day"], r["currency"])
221
- if rt is None or src is None:
222
- ok = False
223
- break
224
- vals.append(r["amount"] / src * rt)
225
- if not ok or not vals:
236
+ usable_rows, vals, diagnostics = candidate_rows(rows, rates, cur)
237
+ if not vals:
238
+ out[cur] = {**diagnostics, "coverage": None, "usable_coverage": None, "control": None, "excess": None,
239
+ "day_drift": None, "peaks": [], "scheme": "none", "scheme_scale": 1,
240
+ "scheme_share": 0.0, "scheme_coverage": None, "scheme_peaks": []}
226
241
  continue
227
242
  cov, peaks = coverage(vals, opt.peak_tol, opt.peak_min_share, opt.top_k)
228
243
  ctrl = coverage_against(jitter(vals, rng, opt.jitter_lo, opt.jitter_hi), [c for c, _ in peaks], opt.peak_tol)
229
- drift = day_drift(rows, vals, peaks, opt.peak_tol)
244
+ drift = day_drift(usable_rows, vals, peaks, opt.peak_tol)
230
245
  name, scale, sshare, on = match_scheme_peaks(peaks, opt.scheme_tol)
231
- 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),
246
+ fraction = len(vals) / len(rows)
247
+ out[cur] = {**diagnostics, "coverage": round(cov * fraction, 4), "usable_coverage": round(cov, 4),
248
+ "control": round(ctrl * fraction, 4), "excess": round((cov - ctrl) * fraction, 4), "day_drift": None if drift is None else round(drift, 4),
232
249
  "peaks": [(round(c, 2), n) for c, n in peaks], "scheme": name, "scheme_scale": scale, "scheme_share": round(sshare, 4),
233
- "scheme_coverage": round(sum(n for _, n in on) / len(vals), 4), "scheme_peaks": [(round(c, 2), n) for c, n in on]}
250
+ "scheme_coverage": round(sum(n for _, n in on) / len(rows), 4), "scheme_peaks": [(round(c, 2), n) for c, n in on]}
234
251
  return out
235
252
 
236
253
 
237
254
  def wallet_acceptable(info: dict, opt) -> bool:
238
- if info["excess"] < opt.wallet_min_excess * 0.4:
255
+ if info["excess"] is None or info["excess"] < opt.wallet_min_excess * 0.4:
239
256
  return False
240
257
  if info["coverage"] >= opt.wallet_min_coverage and info["excess"] >= opt.wallet_min_excess:
241
258
  return True
@@ -288,7 +305,7 @@ def rate_consistency(rows: list[dict], rates: Rates, wallet: str, peaks: list, o
288
305
  return result
289
306
 
290
307
 
291
- def retail_scan(rows: list[dict], opt, rng) -> dict:
308
+ def retail_scan(rows: list[dict], opt, rng, exact_match=None) -> dict:
292
309
  """Find one constant k such that amount / k is retail-shaped for a large share of rows, with many distinct bases."""
293
310
  amounts = [r["amount"] for r in rows]
294
311
  empty = {"constant": None, "share": 0.0, "control": 0.0, "excess": 0.0, "distinct_bases": 0, "rows": len(amounts)}
@@ -297,11 +314,12 @@ def retail_scan(rows: list[dict], opt, rng) -> dict:
297
314
  best = (1.0, 0.0)
298
315
  k = opt.retail_k_min
299
316
  while k <= opt.retail_k_max + 1e-9:
300
- share = sum(retail_shaped(a / k) for a in amounts) / len(amounts)
317
+ share = sum(exact_match(r, round(k, 4)) if exact_match else retail_shaped(r["amount"] / k) for r in rows) / len(amounts)
301
318
  if share > best[1]:
302
319
  best = (round(k, 4), share)
303
320
  k += opt.retail_k_step
304
- bases = Counter(round(a / best[0], 2) for a in amounts if retail_shaped(a / best[0]))
321
+ bases = Counter(round(r["amount"] / best[0], 2) for r in rows
322
+ if (exact_match(r, best[0]) if exact_match else retail_shaped(r["amount"] / best[0])))
305
323
  distinct = sum(1 for _, c in bases.items() if c >= 3)
306
324
  ctrl = sum(retail_shaped(a / best[0]) for a in jitter(amounts, rng, 0.004, 0.02)) / len(amounts)
307
325
  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)}
@@ -383,6 +401,7 @@ def extract_substreams(grows: list[dict], legal: str | None, rates: Rates, opt,
383
401
  """Peel streams off a country group: menu (wallet + scheme), retail list, then remainder as off-menu."""
384
402
  subs = []
385
403
  residual = list(grows)
404
+ cands = [c for c in dict.fromkeys([legal, *opt.candidate_currencies]) if c]
386
405
  for _round in range(opt.max_substreams):
387
406
  if len(residual) < opt.min_group:
388
407
  break
@@ -393,7 +412,6 @@ def extract_substreams(grows: list[dict], legal: str | None, rates: Rates, opt,
393
412
  residual = [r for r in residual if not retail_shaped(r["amount"] / k)]
394
413
  subs.append({"stream": f"retail x{k}", "rows": inside, "retail": retail})
395
414
  continue
396
- cands = [c for c in dict.fromkeys([legal, *opt.candidate_currencies]) if c]
397
415
  walls = infer_wallet(residual, rates, cands, opt, rng)
398
416
  wallet = choose_wallet(walls, legal, opt)
399
417
  if wallet:
@@ -406,7 +424,8 @@ def extract_substreams(grows: list[dict], legal: str | None, rates: Rates, opt,
406
424
  label = f"{wallet} menu-{scheme if sshare >= opt.scheme_min_share else 'none'}{' frozen' if frozen else ' live'}"
407
425
  subs.append({"stream": label, "rows": inside, "wallet": wallet, "coverage": info["coverage"], "day_drift": info["day_drift"], "scheme": scheme,
408
426
  "scheme_scale": scale, "scheme_share": round(sshare, 3), "peaks": use_peaks, "all_peaks": info["peaks"], "frozen": frozen, "rate_consistency": cons,
409
- "alternatives": {c: i["coverage"] for c, i in walls.items() if c != wallet}})
427
+ "alternatives": {c: i["coverage"] for c, i in walls.items() if c != wallet},
428
+ "candidate_diagnostics": walls})
410
429
  residual = outside
411
430
  continue
412
431
  if retail_passes(retail, opt):
@@ -417,7 +436,20 @@ def extract_substreams(grows: list[dict], legal: str | None, rates: Rates, opt,
417
436
  continue
418
437
  break
419
438
  if residual:
420
- subs.append({"stream": "off-menu", "rows": residual})
439
+ unpriced, priced = [], []
440
+ for r in residual:
441
+ target = priced if any(rates.rate(r["day"], cur) and rates.rate(r["day"], r["currency"])
442
+ for cur in cands) else unpriced
443
+ target.append(r)
444
+ for label, stream_rows in (("missing FX coverage", unpriced), ("off-menu", priced)):
445
+ if stream_rows:
446
+ diagnostics = {}
447
+ for cur in cands:
448
+ _, vals, info = candidate_rows(stream_rows, rates, cur)
449
+ if not vals:
450
+ info.update({"coverage": None, "usable_coverage": None})
451
+ diagnostics[cur] = info
452
+ subs.append({"stream": label, "rows": stream_rows, "candidate_diagnostics": diagnostics})
421
453
  return subs
422
454
 
423
455
 
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env python3
2
+ """Build reproducible, same-scope group and remainder profiles."""
3
+ import argparse
4
+ import bisect
5
+ from datetime import date, timedelta
6
+ from decimal import Decimal, InvalidOperation
7
+ import json
8
+
9
+ from tld_common import load_dataset, validate_groups, view_rows, scope_of, write_json
10
+
11
+
12
+ def tier_scheme(rows, edges):
13
+ if edges is None:
14
+ values = sorted({Decimal(row['amount']) for row in rows})
15
+ return {'kind': 'exact', 'tiers': [{'label': str(x), 'amount': str(x)} for x in values] + [{'label': 'other'}]}
16
+ if not isinstance(edges, (list, tuple)) or not edges:
17
+ raise ValueError('tier edges must be a nonempty sorted list of decimal strings')
18
+ try:
19
+ if any(not isinstance(x, str) for x in edges):
20
+ raise ValueError('tier edges must be decimal strings')
21
+ values = [Decimal(x) for x in edges]
22
+ if any(not x.is_finite() or x < 0 for x in values) or any(a >= b for a, b in zip(values, values[1:])):
23
+ raise ValueError('tier edges must be finite, nonnegative and strictly increasing')
24
+ except InvalidOperation as exc:
25
+ raise ValueError('invalid tier edge') from exc
26
+ bounds = [None] + [str(x) for x in values] + [None]
27
+ tiers = [{'label': f"[{bounds[i] if bounds[i] is not None else '-∞'}, {bounds[i+1] if bounds[i+1] is not None else '∞'})", 'lower': bounds[i], 'upper': bounds[i+1]} for i in range(len(bounds)-1)]
28
+ return {'kind': 'edges', 'edges': [str(x) for x in values], 'tiers': tiers}
29
+
30
+
31
+ def tier_index(amount, scheme):
32
+ value = Decimal(amount)
33
+ if scheme['kind'] == 'edges':
34
+ return bisect.bisect_right([Decimal(x) for x in scheme['edges']], value)
35
+ for i, tier in enumerate(scheme['tiers'][:-1]):
36
+ if value == Decimal(tier['amount']):
37
+ return i
38
+ return len(scheme['tiers']) - 1
39
+
40
+
41
+ def metrics(rows, days, scheme):
42
+ def record(items):
43
+ n = len(items)
44
+ result = {'count': n}
45
+ for field, name in [('authorized', 'success'), ('suspected_fraud', 'fraud')]:
46
+ yes = sum(r.get(field) is True for r in items)
47
+ unknown = sum(r.get(field) is None for r in items)
48
+ result[name] = {'numerator': yes, 'denominator': n, 'unknown': unknown,
49
+ 'rate': yes / n if n and not unknown else None,
50
+ 'coverage': (n - unknown) / n if n else None}
51
+ return result
52
+ def hours(items):
53
+ counts = [0] * 24
54
+ for r in items:
55
+ counts[int(r['hour'])] += 1
56
+ return counts
57
+ daily = []
58
+ for day in days:
59
+ items = [r for r in rows if str(r['date']) == day]
60
+ entry = {'date': day, **record(items), 'share': len(items) / len(rows) if rows else None,
61
+ 'hours': hours(items), 'tier_counts': [0] * len(scheme['tiers'])}
62
+ for row in items:
63
+ entry['tier_counts'][tier_index(row['amount'], scheme)] += 1
64
+ daily.append(entry)
65
+ total = record(rows)
66
+ total['tier_counts'] = [sum(day['tier_counts'][i] for day in daily) for i in range(len(scheme['tiers']))]
67
+ hour_counts = hours(rows)
68
+ if sum(total['tier_counts']) != len(rows) or sum(hour_counts) != len(rows) or any(sum(day['hours']) != day['count'] or sum(day['tier_counts']) != day['count'] for day in daily):
69
+ raise ValueError('profile counts do not reconcile')
70
+ return {'total': total, 'daily': daily, 'hours': hour_counts}
71
+
72
+
73
+ def profile_dataset(data, manifest, tier_edges=None):
74
+ groups = validate_groups(data, manifest)
75
+ if tier_edges is not None:
76
+ tier_scheme([], tier_edges)
77
+ rows = list(view_rows(data))
78
+ period = data['period']
79
+ start, end = date.fromisoformat(period['start']), date.fromisoformat(period['end'])
80
+ days = [(start + timedelta(days=i)).isoformat() for i in range((end-start).days+1)]
81
+ results = []
82
+ exposed = set()
83
+ for group in sorted(groups, key=lambda g: g['id']):
84
+ members = set(group['member_ids'])
85
+ scoped = [row for row in rows if scope_of(row) == group['scope']]
86
+ selected = [row for row in scoped if row['transaction_id'] in members]
87
+ remainder = [row for row in scoped if row['transaction_id'] not in members]
88
+ exposed.update(members)
89
+ scheme = tier_scheme(selected, tier_edges)
90
+ results.append({'id': group['id'], 'pattern': group['pattern'], 'scope': group['scope'],
91
+ 'member_ids': sorted(members), 'out_group_member_ids': sorted(r['transaction_id'] for r in remainder),
92
+ 'tiers': scheme, 'group': metrics(selected, days, scheme), 'out_group': metrics(remainder, days, scheme)})
93
+ return {'schema_version': 1, 'dataset_sha256': data['dataset_sha256'], 'period': period,
94
+ 'census': data['census'], 'combined_exposure': {'count': len(exposed), 'member_ids': sorted(exposed)}, 'groups': results}
95
+
96
+
97
+ def main():
98
+ parser = argparse.ArgumentParser(description=__doc__)
99
+ parser.add_argument('dataset'); parser.add_argument('manifest'); parser.add_argument('output')
100
+ parser.add_argument('--tier-edges', nargs='+', help='strictly increasing decimal boundaries')
101
+ parser.add_argument('--overwrite', action='store_true')
102
+ args = parser.parse_args()
103
+ with open(args.manifest, encoding='utf-8') as f:
104
+ manifest = json.load(f)
105
+ write_json(args.output, profile_dataset(load_dataset(args.dataset), manifest, args.tier_edges), overwrite=args.overwrite)
106
+
107
+
108
+ if __name__ == '__main__':
109
+ main()