Skip to content

Commit d671319

Browse files
authored
Merge pull request #667 from 0xfaan/feat/issues
feat: build migration scaffolding for evolving data schemas
2 parents 90ee3d7 + c926e36 commit d671319

31 files changed

Lines changed: 3535 additions & 6 deletions

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: install lint format test run scale-workers typecheck mutation-test threshold-sweep anonymization-check check-env check-schema-compatibility check-review-gates ops-check ops-validate static-analysis benchmark verify-lockfile regenerate-lockfile partition-write partition-read retention-scan snapshot-freeze snapshot-list snapshot-verify run-compare run-compare-all check-cycles probe-deps probe-deps-json validate-readme validate-readme-warn validate-notebooks validate-notebooks-strict validate-notebooks-ci validate-all check-integrity dead-path-report env-docs env-docs-check
1+
.PHONY: install lint format test run scale-workers typecheck mutation-test threshold-sweep anonymization-check check-env check-schema-compatibility check-review-gates ops-check ops-validate static-analysis benchmark verify-lockfile regenerate-lockfile partition-write partition-read retention-scan snapshot-freeze snapshot-list snapshot-verify run-compare run-compare-all check-cycles probe-deps probe-deps-json validate-readme validate-readme-warn validate-notebooks validate-notebooks-strict validate-notebooks-ci validate-all check-integrity dead-path-report env-docs env-docs-check migrate migrate-status migrate-dry-run new-migration onboard onboard-fix onboard-json
22
.ONESHELL:
33

44
VENV_BIN := $(abspath .venv/bin)

data/reproducibility.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,16 @@ def __init__(
223223

224224
if ledgerlens_version is None:
225225
try:
226-
from config import config # type: ignore
226+
from utils.version_stamp import get_version
227227

228-
ledgerlens_version = getattr(config, "LEDGERLENS_VERSION", "unknown")
228+
ledgerlens_version = get_version()
229229
except Exception:
230-
ledgerlens_version = "unknown"
230+
try:
231+
from config import config # type: ignore
232+
233+
ledgerlens_version = getattr(config, "LEDGERLENS_VERSION", "unknown")
234+
except Exception:
235+
ledgerlens_version = "unknown"
231236
self.ledgerlens_version = ledgerlens_version
232237

233238
self._signing_key = None

detection/forensic_report.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,11 @@ def _to_dict_without_hash(self) -> dict:
145145
return d
146146

147147
def to_dict(self) -> dict:
148+
from utils.version_stamp import stamp_artifact
149+
148150
d = self._to_dict_without_hash()
149151
d["report_sha256"] = self.report_sha256
150-
return d
152+
return stamp_artifact(d, include_git=False)
151153

152154
def verify_integrity(self) -> bool:
153155
"""Recompute the SHA-256 and assert it matches the stored value."""

detection/model_inference.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,14 @@ def score(
483483
span.set_attribute("wallet.id", hash_span_id(wallet) if wallet else "unknown")
484484
result = self._score_impl(feature_row, labelled_count, caller_id)
485485
span.set_attribute("model.score", result.get("score", -1))
486+
# Embed a lightweight version stamp so every score output carries
487+
# its provenance (Issue #4).
488+
try:
489+
from utils.version_stamp import get_version as _ll_version
490+
491+
result["ledgerlens_version"] = _ll_version()
492+
except Exception:
493+
pass
486494
return result
487495

488496
def _score_impl(

detection/model_training.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
compute_feature_contract_hash,
4747
)
4848
from utils.logging import get_logger
49+
from utils.version_stamp import get_version as _get_ledgerlens_version
4950

5051
logger = get_logger(__name__)
5152

@@ -987,7 +988,7 @@ def save_training_artifacts(
987988
),
988989
"model_names": list(results.keys()),
989990
"python_version": sys.version.split()[0],
990-
"ledgerlens_version": "0.2.0",
991+
"ledgerlens_version": _get_ledgerlens_version(),
991992
"feature_distributions": feature_distributions,
992993
}
993994

examples/__init__.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""End-to-end detection workflow examples for LedgerLens.
2+
3+
This package contains runnable examples that exercise the full detection
4+
pipeline from raw trade data through to a LedgerLens Risk Score, without
5+
requiring a live Stellar Horizon connection. Each example:
6+
7+
- Generates synthetic trade data that mimics a specific on-chain pattern
8+
- Runs the full detection stack (Benford engine, feature engineering, model
9+
inference, SHAP explainer)
10+
- Prints the resulting risk score and top SHAP attributions
11+
12+
These examples double as integration smoke-tests: ``pytest examples/`` runs
13+
all of them in CI to ensure the pipeline does not regress.
14+
15+
Run any example directly::
16+
17+
python -m examples.e2e_clean_trading
18+
python -m examples.e2e_wash_trading_ring
19+
python -m examples.e2e_benford_anomaly
20+
python -m examples.e2e_cross_venue_coordination
21+
python -m examples.e2e_full_pipeline
22+
"""

examples/_helpers.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""Shared helpers used by the end-to-end detection examples.
2+
3+
Provides ``build_trades``, ``run_detection``, and ``print_result`` so each
4+
example module stays focused on its scenario rather than boilerplate.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import os
10+
import sys
11+
import tempfile
12+
from datetime import UTC, datetime, timedelta
13+
from typing import Any
14+
15+
import numpy as np
16+
import pandas as pd
17+
18+
# ---------------------------------------------------------------------------
19+
# Make imports work when running from the repo root with ``python -m examples.*``
20+
# ---------------------------------------------------------------------------
21+
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
22+
if _REPO_ROOT not in sys.path:
23+
sys.path.insert(0, _REPO_ROOT)
24+
25+
from config import config
26+
from detection.benford_engine import BenfordEngine
27+
from detection.feature_engineering import build_feature_matrix
28+
from utils.logging import get_logger
29+
30+
logger = get_logger(__name__)
31+
32+
PAIR_ID = "USDC:GA5ZSEJYBY3RJRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN/XLM:native"
33+
EXAMPLE_WALLET = "GEXAMPLEWALLET000000000000000000000000000000000000000000001"
34+
35+
36+
# ---------------------------------------------------------------------------
37+
# Trade-row builder
38+
# ---------------------------------------------------------------------------
39+
40+
41+
def make_trade(
42+
*,
43+
wallet: str = EXAMPLE_WALLET,
44+
counterparty: str = "GCOUNTERPARTY0000000000000000000000000000000000000000000001",
45+
amount: float,
46+
timestamp: datetime | None = None,
47+
pair_id: str = PAIR_ID,
48+
) -> dict[str, Any]:
49+
ts = timestamp or datetime.now(UTC)
50+
return {
51+
"wallet": wallet,
52+
"counterparty": counterparty,
53+
"amount": amount,
54+
"pair_id": pair_id,
55+
"timestamp": ts,
56+
"trade_type": "buy",
57+
"base_asset_code": "USDC",
58+
"counter_asset_code": "XLM",
59+
}
60+
61+
62+
def build_trades_df(trade_dicts: list[dict[str, Any]]) -> pd.DataFrame:
63+
"""Convert a list of trade dicts to the DataFrame shape expected by the
64+
detection pipeline."""
65+
df = pd.DataFrame(trade_dicts)
66+
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
67+
df["amount"] = df["amount"].astype(float)
68+
return df
69+
70+
71+
# ---------------------------------------------------------------------------
72+
# Detection pipeline runner
73+
# ---------------------------------------------------------------------------
74+
75+
76+
def run_detection(
77+
trades_df: pd.DataFrame,
78+
*,
79+
wallet: str = EXAMPLE_WALLET,
80+
pair_id: str = PAIR_ID,
81+
print_summary: bool = True,
82+
) -> dict[str, Any]:
83+
"""Run the full detection stack on *trades_df* and return a result dict.
84+
85+
Steps:
86+
1. Compute Benford metrics.
87+
2. Build the ML feature matrix.
88+
3. Score with the trained ensemble (falls back gracefully if no models are
89+
present by using the Benford signal only).
90+
4. Return a dict with ``score``, ``benford_flag``, ``features``, and
91+
``shap_values`` (empty dict when models not present).
92+
"""
93+
benford = BenfordEngine()
94+
amounts = trades_df["amount"].dropna().tolist()
95+
96+
if len(amounts) < 5:
97+
logger.warning("Only %d trade amounts — Benford metrics will be unreliable", len(amounts))
98+
99+
benford_result = benford.compute_all(amounts)
100+
mad = benford_result.get("mad", 0.0)
101+
benford_flag = mad >= 0.015
102+
103+
# Build feature matrix
104+
features: dict[str, float] = {}
105+
try:
106+
feature_df = build_feature_matrix(trades_df)
107+
wallet_row = feature_df[feature_df["wallet"] == wallet] if "wallet" in feature_df.columns else feature_df
108+
if not wallet_row.empty:
109+
features = wallet_row.iloc[0].to_dict()
110+
except Exception as exc:
111+
logger.debug("Feature matrix build error (non-fatal): %s", exc)
112+
113+
# Model inference (best-effort — models may not be trained locally)
114+
score: float = 0.0
115+
shap_values: dict[str, float] = {}
116+
ml_flag = False
117+
118+
try:
119+
from detection.model_inference import RiskScorer
120+
121+
scorer = RiskScorer()
122+
if features:
123+
feature_row = pd.Series(features)
124+
result = scorer.score(feature_row)
125+
score = float(result.get("score", 0.0))
126+
ml_flag = result.get("ml_flag", False)
127+
else:
128+
# Synthesise a minimal feature row from Benford output
129+
feature_row = _benford_to_feature_row(benford_result)
130+
result = scorer.score(feature_row)
131+
score = float(result.get("score", 0.0))
132+
ml_flag = result.get("ml_flag", False)
133+
except Exception as exc:
134+
logger.debug("RiskScorer unavailable (%s) — using Benford-only score", exc)
135+
# Fallback: derive a rough score from Benford MAD
136+
score = min(100.0, mad * 2000.0)
137+
ml_flag = False
138+
139+
output = {
140+
"wallet": wallet,
141+
"pair_id": pair_id,
142+
"score": score,
143+
"benford_flag": benford_flag,
144+
"ml_flag": ml_flag,
145+
"benford": benford_result,
146+
"features": features,
147+
"shap_values": shap_values,
148+
"n_trades": len(trades_df),
149+
}
150+
151+
if print_summary:
152+
print_result(output)
153+
154+
return output
155+
156+
157+
def _benford_to_feature_row(benford_result: dict[str, Any]) -> pd.Series:
158+
"""Build a minimal feature Series from Benford output for scoring when
159+
a full feature matrix cannot be computed."""
160+
windows = ["1h", "4h", "24h", "168h", "720h"]
161+
row: dict[str, float] = {}
162+
for w in windows:
163+
row[f"benford_chi_square_{w}"] = float(benford_result.get("chi_square", 0.0))
164+
row[f"benford_mad_{w}"] = float(benford_result.get("mad", 0.0))
165+
row[f"benford_z_max_{w}"] = float(benford_result.get("z_max", 0.0))
166+
167+
# Zero-fill all other expected features so the model doesn't error
168+
from detection.model_training import FEATURE_COLUMNS_EXCLUDE
169+
170+
try:
171+
import joblib
172+
173+
rf_path = os.path.join(config.MODEL_DIR, "random_forest.joblib")
174+
if os.path.exists(rf_path):
175+
rf = joblib.load(rf_path)
176+
for fname in rf.feature_names_in_:
177+
if fname not in row:
178+
row[fname] = 0.0
179+
except Exception:
180+
pass
181+
182+
return pd.Series(row)
183+
184+
185+
# ---------------------------------------------------------------------------
186+
# Result printer
187+
# ---------------------------------------------------------------------------
188+
189+
190+
def print_result(result: dict[str, Any], *, label: str = "") -> None:
191+
sep = "─" * 60
192+
header = f" LedgerLens Detection Result {'─ ' + label if label else ''}".rstrip()
193+
print(f"\n{sep}")
194+
print(header)
195+
print(sep)
196+
print(f" Wallet : {result['wallet']}")
197+
print(f" Pair : {result['pair_id']}")
198+
print(f" Trades : {result['n_trades']}")
199+
print(f" Score : {result['score']:.1f} / 100")
200+
print(f" Benford : {'⚠ ANOMALY' if result['benford_flag'] else '✓ Normal'}")
201+
print(f" ML flag : {'⚠ FLAGGED' if result['ml_flag'] else '✓ Clean'}")
202+
b = result.get("benford", {})
203+
if b:
204+
print(f" MAD : {b.get('mad', 0):.4f} (threshold: 0.015)")
205+
print(f" χ² : {b.get('chi_square', 0):.2f}")
206+
if result.get("shap_values"):
207+
print(" Top SHAP contributions:")
208+
top = sorted(result["shap_values"].items(), key=lambda x: abs(x[1]), reverse=True)[:5]
209+
for feat, val in top:
210+
sign = "▲" if val > 0 else "▼"
211+
print(f" {sign} {feat}: {val:+.4f}")
212+
print(sep + "\n")

0 commit comments

Comments
 (0)