@1aboveio/skills 1.0.9 → 1.0.11
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/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/resources.json +297 -216
- package/README.md +3 -3
- package/package.json +10 -8
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/android-foundation/SKILL.md +35 -37
- package/skills/app-dev/android-foundation/references/delivery-focus.md +4 -2
- package/skills/app-dev/android-foundation/references/feature-work.md +50 -0
- package/skills/app-dev/android-foundation/references/setup.md +24 -9
- package/skills/app-dev/android-foundation/references/workspace.md +81 -0
- package/skills/app-dev/android-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
- package/skills/app-dev/android-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
- package/skills/app-dev/android-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
- package/skills/app-dev/app-debug/SKILL.md +1 -1
- package/skills/app-dev/better-auth/SKILL.md +1 -1
- package/skills/app-dev/data-catalog/SKILL.md +1 -1
- package/skills/app-dev/data-quality/SKILL.md +1 -1
- package/skills/app-dev/ios-foundation/SKILL.md +15 -10
- package/skills/app-dev/ios-foundation/references/setup.md +25 -9
- package/skills/app-dev/ios-foundation/references/workspace.md +82 -0
- package/skills/app-dev/ios-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
- package/skills/app-dev/ios-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
- package/skills/app-dev/ios-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
- package/skills/app-dev/monorepo/SKILL.md +1 -1
- package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
- package/skills/app-dev/prisma-setup/SKILL.md +1 -1
- package/skills/app-dev/pyspark/SKILL.md +1 -1
- package/skills/app-dev/python-backend/SKILL.md +1 -1
- package/skills/app-dev/shadcn/SKILL.md +1 -1
- package/skills/app-dev/zod-v4/SKILL.md +1 -1
- package/skills/cicd/cloud-build/SKILL.md +1 -1
- package/skills/cicd/cloud-debug/SKILL.md +1 -1
- package/skills/cicd/cloud-deploy/SKILL.md +1 -1
- package/skills/cicd/docker/SKILL.md +1 -1
- package/skills/cicd/github-actions/SKILL.md +1 -1
- package/skills/cicd/github-flow/SKILL.md +1 -1
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/mergify/SKILL.md +1 -1
- package/skills/cicd/podman/SKILL.md +1 -1
- package/skills/content/article-review/SKILL.md +1 -1
- package/skills/content/article-to-image/SKILL.md +1 -1
- package/skills/content/flowchart-svg/SKILL.md +1 -1
- package/skills/content/image-gen/SKILL.md +1 -1
- package/skills/content/mermaid/SKILL.md +1 -1
- package/skills/content/wechat-article-writer/SKILL.md +1 -1
- package/skills/content/wechat-publisher/SKILL.md +1 -1
- package/skills/content/wechat-search/SKILL.md +1 -1
- package/skills/content/xai-search/SKILL.md +1 -1
- package/skills/core/distill-writing/SKILL.md +3 -3
- package/skills/core/distill-writing/references/extraction.md +1 -1
- package/skills/core/doc-to-md/SKILL.md +1 -1
- package/skills/core/editorial-review/SKILL.md +82 -12
- package/skills/core/editorial-review/references/checklist.md +26 -1
- package/skills/core/house-style/SKILL.md +8 -3
- package/skills/core/house-style/references/analytical-reporting.md +123 -0
- package/skills/core/house-style/references/analytical-writing-patterns.md +79 -0
- package/skills/core/house-style/references/analytical-writing-research.md +129 -0
- package/skills/core/{write-doc → house-style}/references/bilingual.md +7 -5
- package/skills/core/house-style/references/definitions-and-metrics.md +86 -0
- package/skills/core/house-style/references/document-workflow.md +193 -0
- package/skills/core/house-style/references/initialization.md +66 -0
- package/skills/core/house-style/references/persistence.md +9 -4
- package/skills/core/house-style/references/rules.md +11 -4
- package/skills/core/house-style/scripts/init-guidance.mjs +213 -0
- package/skills/core/house-style/templates/analytical-report.md +54 -0
- package/skills/core/house-style/templates/gap-analysis.md +1 -1
- package/skills/core/plain-language/SKILL.md +2 -2
- package/skills/core/polish-cn/SKILL.md +9 -2
- package/skills/core/reg-parser/SKILL.md +3 -3
- package/skills/core/terminology/SKILL.md +8 -4
- package/skills/core/write-doc/SKILL.md +40 -151
- package/skills/core/write-report/SKILL.md +129 -0
- package/skills/core/{write-doc → write-report}/references/compliance-reports.md +4 -4
- package/skills/engineering/better-review/SKILL.md +37 -37
- package/skills/engineering/better-review/references/passes.md +35 -41
- package/skills/engineering/better-review/references/result.md +10 -9
- package/skills/engineering/e2e-test/SKILL.md +17 -17
- package/skills/engineering/e2e-test/references/authoring/playwright-config.md +11 -13
- package/skills/engineering/e2e-test/references/presentation-sweep.md +7 -7
- package/skills/engineering/e2e-test/references/reviewing.md +9 -11
- package/skills/engineering/e2e-test/references/route-manifest.md +1 -1
- package/skills/engineering/engineering/SKILL.md +124 -0
- package/skills/engineering/engineering/references/engineering-conventions.md +32 -0
- package/skills/engineering/engineering/references/feature-delivery.md +8 -0
- package/skills/engineering/engineering/references/init-scope.md +37 -0
- package/skills/engineering/engineering/scripts/init.mjs +160 -0
- package/skills/engineering/{contributing → engineering}/scripts/persist-conventions.mjs +1 -1
- package/skills/engineering/engineering-runtime/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +19 -19
- package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
- package/skills/engineering/ensure-coverage/SKILL.md +22 -109
- package/skills/engineering/ensure-coverage/references/breadth/coverage-ledger.md +7 -5
- package/skills/engineering/ensure-coverage/references/breadth/surface-baseline.md +4 -4
- package/skills/engineering/ensure-coverage/references/breadth/surface-discovery.md +3 -3
- package/skills/engineering/ensure-coverage/references/depth/characterization.md +4 -65
- package/skills/engineering/ensure-coverage/references/depth/grading.md +5 -7
- package/skills/engineering/ensure-coverage/references/depth/mock-policy.md +6 -12
- package/skills/engineering/ensure-coverage/references/enforcement/ci-contract.md +1 -1
- package/skills/engineering/ensure-coverage/references/presentation/axis.md +6 -6
- package/skills/engineering/ensure-coverage/references/process/audit-mode.md +7 -23
- package/skills/engineering/ensure-coverage/references/process/output-template.md +18 -133
- package/skills/engineering/ensure-coverage/references/process/test-strategy.md +7 -12
- package/skills/engineering/ensure-coverage/scripts/README.md +12 -4
- package/skills/engineering/golden-path/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/SKILL.md +3 -1
- package/skills/engineering/harness-runtime/bin/workspaces.mjs +52 -0
- package/skills/engineering/harness-runtime/native-workspace.mjs +239 -0
- package/skills/engineering/harness-runtime/references/workspaces.md +78 -0
- package/skills/engineering/harness-runtime/workspace-lifecycle.mjs +2154 -0
- package/skills/engineering/resolve-issues/SKILL.md +40 -61
- package/skills/engineering/resolve-issues/references/ci.md +13 -3
- package/skills/engineering/resolve-issues/references/delivery-progress.md +4 -3
- package/skills/engineering/resolve-issues/references/done.md +11 -2
- package/skills/engineering/resolve-issues/references/expire.md +9 -2
- package/skills/engineering/resolve-issues/references/implementation.md +33 -17
- package/skills/engineering/resolve-issues/references/preflight.md +3 -1
- package/skills/engineering/resolve-issues/references/principles.md +52 -0
- package/skills/engineering/resolve-issues/references/review.md +25 -15
- package/skills/engineering/resolve-issues/references/task-plan.md +64 -27
- package/skills/engineering/resolve-issues/references/workspaces.md +164 -0
- package/skills/engineering/resolve-issues/scripts/cleanup-runs.mjs +185 -317
- package/skills/engineering/resolve-issues/scripts/next-actions.mjs +44 -7
- package/skills/engineering/resolve-issues/scripts/rehydrate.mjs +36 -2
- package/skills/engineering/resolve-issues/scripts/review-verdict.mjs +9 -8
- package/skills/engineering/resolve-issues/scripts/workspace-lifecycle.mjs +1 -0
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/rush-issues/SKILL.md +7 -5
- package/skills/engineering/rush-release/SKILL.md +1 -1
- package/skills/engineering/test-quality/SKILL.md +19 -4
- package/skills/engineering/test-quality/references/lint.md +1 -1
- package/skills/engineering/test-quality/references/principles.md +2 -2
- package/skills/engineering/test-quality/references/testing-conventions.md +1 -1
- package/skills/risk-compliance/compliance-audit/SKILL.md +4 -8
- package/skills/risk-compliance/compliance-audit/references/audience-and-reports.md +12 -0
- package/skills/risk-compliance/contract-compare/SKILL.md +17 -1
- package/skills/risk-compliance/contract-review/SKILL.md +6 -1
- package/skills/risk-compliance/contract-review/references/report-stage.md +17 -0
- package/skills/risk-compliance/doc-authenticity/SKILL.md +11 -1
- package/skills/risk-compliance/fraud-detection/LICENSE +3 -0
- package/skills/risk-compliance/fraud-detection/SKILL.md +148 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/first-party-fraud.md +3 -2
- package/skills/risk-compliance/fraud-detection/references/archetypes/third-party-fraud.md +47 -0
- package/skills/risk-compliance/fraud-detection/references/collection-payout-features.md +151 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/contexts/bank-transfer.md +4 -3
- package/skills/risk-compliance/fraud-detection/references/contexts/card-payment.md +70 -0
- package/skills/risk-compliance/fraud-detection/references/contexts/payment-collection.md +34 -0
- package/skills/risk-compliance/fraud-detection/references/contexts/payout.md +34 -0
- package/skills/risk-compliance/fraud-detection/references/enrichment.md +74 -0
- package/skills/risk-compliance/fraud-detection/references/entities.md +48 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/feature-engineering.md +69 -33
- package/skills/risk-compliance/fraud-detection/references/feature-sql.md +150 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-rationale.md +4 -2
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-template.md +45 -23
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/review-checklist.md +60 -20
- package/skills/risk-compliance/fraud-detection/references/workflow.md +216 -0
- package/skills/risk-compliance/fraud-detection/release-check.mjs +95 -0
- package/skills/risk-compliance/payment-analysis/SKILL.md +36 -17
- package/skills/risk-compliance/payment-analysis/references/auth-rate-actions.md +2 -2
- package/skills/risk-compliance/payment-analysis/references/report-template.md +10 -2
- package/skills/risk-compliance/payment-analysis/references/visualization.md +11 -1
- package/skills/risk-compliance/portfolio-payment-analysis/LICENSE +3 -0
- package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +170 -0
- package/skills/risk-compliance/portfolio-payment-analysis/references/identity-and-reconciliation.md +46 -0
- package/skills/risk-compliance/portfolio-payment-analysis/references/metrics-and-reporting.md +170 -0
- package/skills/risk-compliance/portfolio-payment-analysis/references/risk-history.md +89 -0
- package/skills/risk-compliance/portfolio-payment-analysis/release-check.mjs +95 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/analyze.py +636 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/build_report.py +386 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/comparison_charts.py +425 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/csv_export.py +52 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/render.py +160 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/risk_history.py +435 -0
- package/skills/risk-compliance/similar-domain/SKILL.md +9 -4
- package/skills/risk-compliance/tld-detection/SKILL.md +124 -239
- package/skills/risk-compliance/tld-detection/agents/openai.yaml +1 -1
- package/skills/risk-compliance/tld-detection/references/clean.md +18 -51
- package/skills/risk-compliance/tld-detection/references/fx-conversion.md +56 -219
- package/skills/risk-compliance/tld-detection/references/metric-contract.md +109 -0
- package/skills/risk-compliance/tld-detection/references/pattern.md +99 -0
- package/skills/risk-compliance/tld-detection/references/report-template.md +49 -272
- package/skills/risk-compliance/tld-detection/scripts/detect_ladders.py +46 -13
- package/skills/risk-compliance/tld-detection/scripts/detect_patterns.py +346 -0
- package/skills/risk-compliance/tld-detection/scripts/detect_streams.py +51 -19
- package/skills/risk-compliance/tld-detection/scripts/profile_groups.py +109 -0
- package/skills/risk-compliance/tld-detection/scripts/render_charts.py +218 -0
- package/skills/risk-compliance/tld-detection/scripts/tld_common.py +243 -0
- package/skills/engineering/contributing/SKILL.md +0 -70
- package/skills/risk-compliance/fraud-analysis/SKILL.md +0 -114
- package/skills/risk-compliance/fraud-analysis/references/archetypes/third-party-fraud.md +0 -27
- package/skills/risk-compliance/fraud-analysis/references/contexts/card-payment.md +0 -30
- package/skills/risk-compliance/fraud-analysis/references/contexts/payment-collection.md +0 -20
- package/skills/risk-compliance/fraud-analysis/references/contexts/payout.md +0 -20
- package/skills/risk-compliance/fraud-analysis/references/workflow.md +0 -175
- package/skills/risk-compliance/tld-detection/references/chinese-polish.md +0 -55
- package/skills/risk-compliance/tld-detection/references/clustering.md +0 -235
- package/skills/risk-compliance/tld-detection/references/decision.md +0 -189
- package/skills/risk-compliance/tld-detection/references/streams.md +0 -141
- package/skills/risk-compliance/tld-detection/references/terminology.md +0 -95
- package/skills/risk-compliance/tld-detection/references/workflow.md +0 -95
- /package/skills/{engineering/contributing → core/write-report}/LICENSE +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/audit-report-template.md +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/external-assessment-template.md +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/management-report-template.md +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/vars-report-template.md +0 -0
- /package/skills/{engineering/contributing → core/write-report}/release-check.mjs +0 -0
- /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/LICENSE +0 -0
- /package/skills/engineering/{contributing → engineering}/references/tracker-conventions.md +0 -0
- /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/release-check.mjs +0 -0
- /package/skills/engineering/{contributing → engineering}/scripts/persist-conventions-core.mjs +0 -0
- /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/authorized-payment-scam.md +0 -0
- /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/mechanisms/account-takeover.md +0 -0
- /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/taxonomy.md +0 -0
- /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,
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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()
|