Skip to content

Commit 56a76a5

Browse files
authored
Merge pull request #63 from Anichris-koded/feat/025-active-learning
feat: implement active learning pipeline
2 parents 8ba1045 + db8496a commit 56a76a5

24 files changed

Lines changed: 2978 additions & 71 deletions

.env.example

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ WATCHED_ASSET_PAIRS=USDC:GA5Z...,XLM:native
1010
# Rolling window sizes (hours) used by the Benford engine
1111
BENFORD_WINDOWS_HOURS=1,4,24,168,720
1212

13+
# Time window used when comparing transactions across asset pairs
14+
CROSS_PAIR_SYNCHRONY_WINDOW_SECONDS=30
15+
16+
# Minimum trades required before scoring a wallet
17+
MIN_TRADES_FOR_SCORING=20
18+
1319
# Risk score thresholds
1420
RISK_SCORE_FLAG_THRESHOLD=70
1521

@@ -57,6 +63,8 @@ POISON_LABEL_RATIO_THRESHOLD=0.15
5763
# WARNING: Never commit this value to version control.
5864
ANNOTATION_HMAC_SECRET=
5965

66+
ANNOTATION_HMAC_SECRET=
67+
6068
# ---------------------------------------------------------------------------
6169
# Adversarial training (disabled by default)
6270
# ---------------------------------------------------------------------------
@@ -65,3 +73,23 @@ ANNOTATION_HMAC_SECRET=
6573
# --adversarial-augmentation is passed to detection/model_training.py.
6674
# 0.0 = disabled (default). Typical useful range: 0.25–1.0.
6775
ADVERSARIAL_AUG_RATIO=0.0
76+
77+
# ---------------------------------------------------------------------------
78+
# Active learning
79+
# ---------------------------------------------------------------------------
80+
81+
# Query strategy for selecting wallets to annotate.
82+
# Options: least_confidence, margin, entropy, coreset, badge, committee_disagreement
83+
AL_QUERY_STRATEGY=committee_disagreement
84+
85+
# Number of wallets to select per active learning run
86+
AL_BATCH_SIZE=20
87+
88+
# Minimum new labelled samples to trigger a full retrain (vs warm-start)
89+
AL_RETRAIN_THRESHOLD=50
90+
91+
# Maximum allowed AUC-ROC drop before rolling back a model update
92+
AL_ROLLBACK_AUC_DROP=0.01
93+
94+
# Path to the annotation queue JSON file
95+
AL_QUEUE_PATH=data/annotation_queue.json
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
name: Active Learning Pipeline
2+
3+
on:
4+
schedule:
5+
# Run every Monday at 08:00 UTC
6+
- cron: "0 8 * * 1"
7+
workflow_dispatch:
8+
inputs:
9+
strategy:
10+
description: "Query strategy"
11+
default: "committee_disagreement"
12+
required: false
13+
batch_size:
14+
description: "Number of wallets to select"
15+
default: "20"
16+
required: false
17+
pool_path:
18+
description: "Path to unscored wallet parquet"
19+
default: "data/unscored_wallets.parquet"
20+
required: false
21+
22+
jobs:
23+
active-learning:
24+
runs-on: ubuntu-latest
25+
26+
steps:
27+
- uses: actions/checkout@v4
28+
29+
- name: Set up Python 3.11
30+
uses: actions/setup-python@v5
31+
with:
32+
python-version: "3.11"
33+
cache: pip
34+
35+
- name: Install dependencies
36+
run: pip install -r requirements.txt
37+
38+
- name: Run active learning loop
39+
env:
40+
AL_QUERY_STRATEGY: ${{ github.event.inputs.strategy || 'committee_disagreement' }}
41+
AL_BATCH_SIZE: ${{ github.event.inputs.batch_size || '20' }}
42+
AL_QUEUE_PATH: data/annotation_queue.json
43+
ANNOTATION_HMAC_SECRET: ${{ secrets.ANNOTATION_HMAC_SECRET }}
44+
MODEL_DIR: ${{ vars.MODEL_DIR || './models' }}
45+
run: |
46+
python -m scripts.run_active_learning \
47+
--pool "${{ github.event.inputs.pool_path || 'data/unscored_wallets.parquet' }}" \
48+
--strategy "$AL_QUERY_STRATEGY" \
49+
--batch-size "$AL_BATCH_SIZE" \
50+
--queue "$AL_QUEUE_PATH"
51+
52+
- name: Upload annotation queue artifact
53+
uses: actions/upload-artifact@v4
54+
with:
55+
name: annotation-queue-${{ github.run_id }}
56+
path: data/annotation_queue.json
57+
retention-days: 30

.github/workflows/ci.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,29 @@ jobs:
3131
ruff check .
3232
black --check .
3333
34+
- name: Check verify_chain called after every joblib.load in detection/
35+
run: |
36+
python - <<'EOF'
37+
import re, sys, pathlib
38+
39+
issues = []
40+
for f in pathlib.Path("detection").rglob("*.py"):
41+
lines = f.read_text().splitlines()
42+
for i, line in enumerate(lines):
43+
if re.search(r"joblib\.load\(", line):
44+
# Check the next 5 lines for verify_chain
45+
window = "\n".join(lines[i : i + 6])
46+
if "verify_chain" not in window:
47+
issues.append(f"{f}:{i+1}: joblib.load without nearby verify_chain call")
48+
49+
if issues:
50+
print("verify_chain enforcement failures:")
51+
for issue in issues:
52+
print(" ", issue)
53+
sys.exit(1)
54+
else:
55+
print("verify_chain check passed.")
56+
EOF
57+
3458
- name: Test
3559
run: pytest -q

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,36 @@ See [`scripts/README.md`](scripts/README.md) for detailed usage of:
328328
- `retrain_if_drifted.py` — automated drift detection and retraining trigger
329329
- `list_model_versions.py` — list archived models with training dates and metrics
330330

331+
## Active Learning
332+
333+
LedgerLens includes an active learning pipeline that intelligently selects the most informative
334+
wallets for analyst annotation, minimising labelling effort while maximising model improvement.
335+
336+
```bash
337+
# Populate the annotation queue (selects 20 wallets by committee disagreement):
338+
python -m scripts.run_active_learning --pool data/unscored_wallets.parquet
339+
340+
# Annotate wallets interactively:
341+
python -m scripts.annotate --annotator-id yourname
342+
343+
# Export annotations and update models:
344+
python -m scripts.annotate --export data/annotated.parquet
345+
python -m scripts.run_active_learning \
346+
--pool data/unscored_wallets.parquet \
347+
--update data/annotated.parquet
348+
```
349+
350+
The pipeline runs automatically every Monday at 08:00 UTC via
351+
`.github/workflows/active_learning.yml`. See [`docs/active_learning.md`](docs/active_learning.md)
352+
for the full query strategy comparison, annotation workflow, and incremental update policy.
353+
354+
| Variable | Default | Description |
355+
|---|---|---|
356+
| `AL_QUERY_STRATEGY` | `committee_disagreement` | Query strategy |
357+
| `AL_BATCH_SIZE` | `20` | Wallets selected per run |
358+
| `AL_RETRAIN_THRESHOLD` | `50` | Min labels for full retrain |
359+
| `AL_ROLLBACK_AUC_DROP` | `0.01` | Max AUC drop before rollback |
360+
331361
## Development
332362

333363
```bash
@@ -514,6 +544,33 @@ defines the relevant shared type before inventing a new one.
514544
- [ ] SDK for protocol integrations (Python + JavaScript)
515545
- [x] Open dataset release: labelled SDEX wash trade patterns — see [`data/dataset_card.md`](data/dataset_card.md)
516546

547+
## Security
548+
549+
LedgerLens includes a hardened inference stack to protect against adversarial attacks on the model layer itself. See [`docs/security.md`](docs/security.md) for full details.
550+
551+
### Artifact Integrity (Ed25519 Trust Chain)
552+
553+
Every trained model artifact is verified through a four-step chain before loading:
554+
555+
1. SHA-256 of the `.joblib` file matches the value recorded in `metrics.json`
556+
2. `metrics.json` carries a valid Ed25519 detached signature (`metrics.json.sig`)
557+
3. The signing key fingerprint matches `TRUSTED_SIGNING_KEY_FINGERPRINT`
558+
4. The training dataset SHA-256 matches the recorded provenance (optional)
559+
560+
`ModelIntegrityError` is raised on any failure. A CI grep check enforces that every `joblib.load` in `detection/` is immediately followed by `verify_chain`.
561+
562+
### Byzantine-Fault-Tolerant Ensemble Voting
563+
564+
The three models (RF, XGBoost, LightGBM) vote using a **trimmed mean / median** scheme. If the spread across model scores exceeds `BFT_SCORE_DIVERGENCE_THRESHOLD` (default 30 points), the outlier scores are trimmed and the median is used — ensuring a single compromised model cannot shift the final score by more than ~17 points. Divergence events are logged, counted in a Prometheus counter (`bft_divergence_detected_total`), and surfaced in the score response as `bft_divergence: true`.
565+
566+
### Label Poisoning Detection
567+
568+
Each training run records the SHA-256 of the input dataset and the label distribution. If the wash-trade ratio has shifted more than `POISON_LABEL_RATIO_THRESHOLD` (default 15%) from the stored baseline, training is aborted and an alert is written to `reports/poisoning_alert_{timestamp}.json`.
569+
570+
### Annotation Queue Integrity
571+
572+
Each annotation in `data/annotation_queue.json` is protected by an HMAC-SHA256 computed over `wallet|label|annotator_id|annotated_at`, keyed by `ANNOTATION_HMAC_SECRET`. Tampered annotations are rejected before they can influence a training run.
573+
517574
## Why This Matters
518575

519576
A DEX where volume figures cannot be trusted is one that institutional participants and serious traders will avoid. LedgerLens is an **open-source public good** — its scores, methodology, and training data are fully transparent and auditable, and will always be free to query.

config.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,24 @@ def validate(self, require_onchain: bool = True) -> None:
6565
raise ValueError("LEDGERLENS_CONTRACT_ID is not configured")
6666

6767
# Adversarial training augmentation
68-
# Ratio of adversarially-perturbed copies to add per clean training sample.
69-
# Only active when --adversarial-augmentation flag is passed to model_training.py.
7068
ADVERSARIAL_AUG_RATIO: float = float(os.getenv("ADVERSARIAL_AUG_RATIO", "0.0"))
7169

70+
# Model integrity & BFT voting
71+
MODEL_SIGNING_PRIVATE_KEY_PATH: str = os.getenv("MODEL_SIGNING_PRIVATE_KEY_PATH", "")
72+
TRUSTED_SIGNING_KEY_FINGERPRINT: str = os.getenv("TRUSTED_SIGNING_KEY_FINGERPRINT", "")
73+
BFT_SCORE_DIVERGENCE_THRESHOLD: int = int(os.getenv("BFT_SCORE_DIVERGENCE_THRESHOLD", "30"))
74+
BFT_MIN_CONSENSUS: int = int(os.getenv("BFT_MIN_CONSENSUS", "2"))
75+
POISON_LABEL_RATIO_THRESHOLD: float = float(os.getenv("POISON_LABEL_RATIO_THRESHOLD", "0.15"))
76+
77+
# Annotation integrity
78+
ANNOTATION_HMAC_SECRET: str = os.getenv("ANNOTATION_HMAC_SECRET", "")
79+
80+
# Active learning
81+
AL_QUERY_STRATEGY: str = os.getenv("AL_QUERY_STRATEGY", "committee_disagreement")
82+
AL_BATCH_SIZE: int = int(os.getenv("AL_BATCH_SIZE", "20"))
83+
AL_RETRAIN_THRESHOLD: int = int(os.getenv("AL_RETRAIN_THRESHOLD", "50"))
84+
AL_ROLLBACK_AUC_DROP: float = float(os.getenv("AL_ROLLBACK_AUC_DROP", "0.01"))
85+
AL_QUEUE_PATH: str = os.getenv("AL_QUEUE_PATH", "data/annotation_queue.json")
86+
7287

7388
config = Config()
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Active learning package for LedgerLens."""
2+
3+
from detection.active_learning.annotation_queue import AnnotationQueue
4+
from detection.active_learning.incremental_trainer import IncrementalTrainer
5+
from detection.active_learning.query_strategies import STRATEGY_REGISTRY, get_strategy
6+
7+
__all__ = ["AnnotationQueue", "IncrementalTrainer", "STRATEGY_REGISTRY", "get_strategy"]

0 commit comments

Comments
 (0)