From 8ffd55f8b0fe25302b74845655e846d347b4b418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Fernandes=20=E2=80=94=20Systems=20=26=20AI=20Engi?= =?UTF-8?q?neer?= Date: Sun, 15 Feb 2026 19:32:49 -0300 Subject: [PATCH] feat: add hosted playground and public packaging/authority artifacts --- .github/workflows/sdk-release.yml | 26 +++ .pre-commit-config.yaml | 19 ++ CHANGELOG.md | 20 +- FAILURE_SCENARIOS.md | 17 ++ README.md | 123 +++++++++++ SCALING_STRATEGY.md | 17 ++ SECURITY.md | 29 +++ SLA.md | 17 ++ SYSTEM_DESIGN.md | 15 ++ THREAT_MODEL.md | 30 +++ VERSIONING.md | 18 ++ app/analytics/__init__.py | 5 + app/analytics/risk_propagation.py | 67 ++++++ app/api/dependencies.py | 21 +- app/api/routes/demo.py | 78 +++++++ app/api/routes/playground.py | 38 ++++ app/api/routes/professional.py | 59 ++++++ app/backends/__init__.py | 5 + app/backends/graph_backend.py | 119 +++++++++++ app/core/config.py | 31 ++- app/core/enterprise.py | 93 ++++++++ app/core/exceptions.py | 31 ++- app/core/security.py | 40 +++- app/main.py | 152 +++++++++++-- app/metrics/__init__.py | 7 + app/metrics/graph_stats.py | 13 ++ app/metrics/latency.py | 23 ++ app/metrics/tracing_stats.py | 33 +++ app/schemas/simulate.py | 10 + app/services/risk_service.py | 159 ++++++++++++-- app/services/trace_service.py | 86 +++++--- bridge_trace_sdk.py | 40 ++++ data/processed/.gitkeep | 0 ...trace_synthetic_financial_dataset_v1.jsonl | 200 ++++++++++++++++++ data/raw/.gitkeep | 0 docs/ALGORITHM.md | 40 ++++ docs/API_REFERENCE.md | 82 +++++++ docs/ARCHITECTURE.md | 25 +++ docs/COMPETITIVE_COMPARISON.md | 10 + docs/COMPLIANCE_READINESS.md | 21 ++ docs/DISTRIBUTION_ROADMAP.md | 11 + docs/FORTUNE500_TIER1_ENHANCEMENTS.md | 188 ++++++++++++++++ docs/INSTALLATION.md | 30 +++ docs/PERFORMANCE_PROOF.md | 15 ++ docs/QUICKSTART_5MIN.md | 24 +++ docs/SDK_PUBLISHING.md | 28 +++ .../BRIDGETRACE_SYNTHETIC_DATASET_V1.md | 32 +++ docs/papers/BRIDGETRACE_TECHNICAL_PAPER_V1.md | 40 ++++ docs/papers/SCIENTIFIC_CHANGELOG.md | 6 + docs/papers/WHITEPAPER_FORMAL_V1_0.md | 39 ++++ examples/scripts/basic_trace.py | 29 +++ frontend/playground.html | 50 +++++ frontend/tier1_dashboard.html | 111 ++++++++++ scripts/benchmark_risk_engine.py | 158 ++++++++++++++ scripts/bt_cli.py | 46 ++++ scripts/generate_public_dataset_v1.py | 69 ++++++ scripts/generate_synthetic_data.py | 53 +++++ scripts/run_external_proof.sh | 15 ++ sdk/js/index.js | 23 ++ sdk/js/package.json | 11 + sdk/python/bridgetrace_sdk/__init__.py | 5 + sdk/python/pyproject.toml | 17 ++ sdk/rust/Cargo.toml | 12 ++ sdk/rust/src/lib.rs | 35 +++ tests/unit/test_demo_and_dataset.py | 43 ++++ tests/unit/test_enterprise_controls.py | 28 +++ tests/unit/test_exceptions.py | 11 + tests/unit/test_external_proof.py | 13 ++ tests/unit/test_playground.py | 19 ++ tests/unit/test_professional_api.py | 51 +++++ tests/unit/test_risk_propagation.py | 19 ++ tests/unit/test_services.py | 22 +- 72 files changed, 2972 insertions(+), 100 deletions(-) create mode 100644 .github/workflows/sdk-release.yml create mode 100644 .pre-commit-config.yaml create mode 100644 FAILURE_SCENARIOS.md create mode 100644 SCALING_STRATEGY.md create mode 100644 SECURITY.md create mode 100644 SLA.md create mode 100644 SYSTEM_DESIGN.md create mode 100644 THREAT_MODEL.md create mode 100644 VERSIONING.md create mode 100644 app/analytics/__init__.py create mode 100644 app/analytics/risk_propagation.py create mode 100644 app/api/routes/demo.py create mode 100644 app/api/routes/playground.py create mode 100644 app/api/routes/professional.py create mode 100644 app/backends/__init__.py create mode 100644 app/backends/graph_backend.py create mode 100644 app/core/enterprise.py create mode 100644 app/metrics/__init__.py create mode 100644 app/metrics/graph_stats.py create mode 100644 app/metrics/latency.py create mode 100644 app/metrics/tracing_stats.py create mode 100644 app/schemas/simulate.py create mode 100644 bridge_trace_sdk.py create mode 100644 data/processed/.gitkeep create mode 100644 data/public/bridgetrace_synthetic_financial_dataset_v1.jsonl create mode 100644 data/raw/.gitkeep create mode 100644 docs/ALGORITHM.md create mode 100644 docs/API_REFERENCE.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/COMPETITIVE_COMPARISON.md create mode 100644 docs/COMPLIANCE_READINESS.md create mode 100644 docs/DISTRIBUTION_ROADMAP.md create mode 100644 docs/FORTUNE500_TIER1_ENHANCEMENTS.md create mode 100644 docs/INSTALLATION.md create mode 100644 docs/PERFORMANCE_PROOF.md create mode 100644 docs/QUICKSTART_5MIN.md create mode 100644 docs/SDK_PUBLISHING.md create mode 100644 docs/datasets/BRIDGETRACE_SYNTHETIC_DATASET_V1.md create mode 100644 docs/papers/BRIDGETRACE_TECHNICAL_PAPER_V1.md create mode 100644 docs/papers/SCIENTIFIC_CHANGELOG.md create mode 100644 docs/papers/WHITEPAPER_FORMAL_V1_0.md create mode 100644 examples/scripts/basic_trace.py create mode 100644 frontend/playground.html create mode 100644 frontend/tier1_dashboard.html create mode 100644 scripts/benchmark_risk_engine.py create mode 100644 scripts/bt_cli.py create mode 100644 scripts/generate_public_dataset_v1.py create mode 100644 scripts/generate_synthetic_data.py create mode 100755 scripts/run_external_proof.sh create mode 100644 sdk/js/index.js create mode 100644 sdk/js/package.json create mode 100644 sdk/python/bridgetrace_sdk/__init__.py create mode 100644 sdk/python/pyproject.toml create mode 100644 sdk/rust/Cargo.toml create mode 100644 sdk/rust/src/lib.rs create mode 100644 tests/unit/test_demo_and_dataset.py create mode 100644 tests/unit/test_enterprise_controls.py create mode 100644 tests/unit/test_exceptions.py create mode 100644 tests/unit/test_external_proof.py create mode 100644 tests/unit/test_playground.py create mode 100644 tests/unit/test_professional_api.py create mode 100644 tests/unit/test_risk_propagation.py diff --git a/.github/workflows/sdk-release.yml b/.github/workflows/sdk-release.yml new file mode 100644 index 0000000..3515f4a --- /dev/null +++ b/.github/workflows/sdk-release.yml @@ -0,0 +1,26 @@ +name: SDK Release Preparation + +on: + workflow_dispatch: + +jobs: + package-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Build python sdk package + run: | + pip install build + cd sdk/python + python -m build + - name: Validate js package metadata + run: | + cd sdk/js + node -e "const p=require('./package.json'); if(!p.name) process.exit(1);" + - name: Validate rust crate metadata + run: | + cd sdk/rust + grep '^name = ' Cargo.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8a7b55f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + + - repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.7.4 + hooks: + - id: ruff + args: [--fix] diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b089e5..7212272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,24 @@ # Changelog -All notable changes to BridgeTrace AI will be documented in this file. +All notable changes to BridgeTrace AI are documented in this file. + +The format follows Keep a Changelog and Semantic Versioning. + +## [Unreleased] + +### Added +- Enterprise credibility docs: `SECURITY.md`, `SLA.md`, `VERSIONING.md`, `THREAT_MODEL.md`. +- Compliance readiness and architecture artifacts: `docs/COMPLIANCE_READINESS.md`, `SYSTEM_DESIGN.md`, `SCALING_STRATEGY.md`, `FAILURE_SCENARIOS.md`. +- Business metrics endpoint `GET /metrics/business` and audit endpoint `GET /audit/logs`. +- Tenant-aware quota controls and audit logging primitives. + +### Changed +- Main middleware now supports tenant headers, optional auth enforcement, and enterprise audit trails. +- Security module now supports API key validation and bearer-token based request authentication. + +### Documentation +- Added `docs/PERFORMANCE_PROOF.md` with engine comparison matrix. +- Added `docs/DISTRIBUTION_ROADMAP.md` for adoption strategy. ## [2.0.0] - 2026-02-08 diff --git a/FAILURE_SCENARIOS.md b/FAILURE_SCENARIOS.md new file mode 100644 index 0000000..bc45270 --- /dev/null +++ b/FAILURE_SCENARIOS.md @@ -0,0 +1,17 @@ +# Failure Scenarios + +## Scenario 1: Traffic Spike / DoS-like burst +- symptom: elevated 429, latency spikes +- response: enforce stricter quota/rate limits, autoscale API + +## Scenario 2: Graph backend latency regression +- symptom: trace p95 exceeds SLO +- response: degrade to cached responses, trigger incident policy + +## Scenario 3: Cross-tenant data risk +- symptom: incorrect tenant headers or mixed audit entries +- response: block suspect requests, run audit replay, rotate keys + +## Scenario 4: Key compromise +- symptom: unusual authenticated traffic +- response: revoke/rotate API keys, force JWT invalidation diff --git a/README.md b/README.md index c9438e1..dd2025e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,9 @@ ![Docker](https://img.shields.io/badge/Docker-Ready-blue?style=for-the-badge&logo=docker) ![License](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge) ![CI](https://img.shields.io/github/workflow/status/felipeofdev-ai/BridgeTrace-AI/CI?style=for-the-badge) +![Benchmark Reproducible](https://img.shields.io/badge/Benchmark-Reproducible-success?style=for-the-badge) +![Deterministic Engine](https://img.shields.io/badge/Deterministic%20Engine-Yes-success?style=for-the-badge) +![External Validation](https://img.shields.io/badge/External%20Validation-Pending-orange?style=for-the-badge) **Enterprise-Grade Financial Traceability Engine** @@ -20,6 +23,13 @@ Unified platform for tracing financial flows across banking systems (PIX), block ## ✨ Features +## 🎯 Why BridgeTrace-AI + +**BridgeTrace-AI combines graph-native risk propagation with explainable outputs so compliance teams can act in minutes, not days.** + +--- + + ### Core Capabilities - 🔗 **Unified Graph Model** - Banking + PIX + Crypto in one graph - 🔍 **Multi-Hop Tracing** - Follow money through complex transaction paths @@ -85,16 +95,120 @@ alembic upgrade head uvicorn app.main:app --reload ``` +### Generate Demo Data +```bash +python scripts/generate_synthetic_data.py --count 1000 +python examples/scripts/basic_trace.py +``` + + +### Generate Public Dataset v1 +```bash +python scripts/generate_public_dataset_v1.py --rows 500 --seed 42 +``` + ### Access Services - **API**: http://localhost:8000 - **API Docs**: http://localhost:8000/api/v2/docs - **Grafana**: http://localhost:3000 (admin/admin) - **Prometheus**: http://localhost:9090 +- **Dashboard Demo**: http://localhost:8000/dashboard +- **Business Metrics**: http://localhost:8000/metrics/business +- **Hosted Playground**: http://localhost:8000/playground + +--- + + +## ⚡ 5-Minute Integration + +```bash +# 1) Start API +uvicorn app.main:app --reload + +# 2) Health check +curl http://localhost:8000/api/v2/health + +# 3) Functional trace call +curl -X POST http://localhost:8000/api/v2/trace \ + -H "Content-Type: application/json" \ + -H "X-API-Key: dev-key-1" \ + -H "X-Tenant-ID: demo" \ + -d '{"source_id":"bank_001","max_hops":5,"min_amount":0}' + +# 4) Risk by entity +curl "http://localhost:8000/api/v2/risk/entity_001?days=30" \ + -H "X-API-Key: dev-key-1" \ + -H "X-Tenant-ID: demo" +``` + +Python SDK minimal example: +```python +from bridge_trace_sdk import BridgeTraceSDK + +sdk = BridgeTraceSDK("http://localhost:8000", api_key="dev-key-1", tenant_id="demo") +print(sdk.trace("bank_001")) +print(sdk.risk("entity_001")) +``` + +CLI example: +```bash +python scripts/bt_cli.py --base-url http://localhost:8000 --api-key dev-key-1 --tenant demo trace --source bank_001 +python scripts/bt_cli.py --base-url http://localhost:8000 --api-key dev-key-1 --tenant demo risk --entity entity_001 +``` + +--- + +## 🔬 External Reproducibility Proof + +Run the exact public proof pipeline (dataset + benchmark + checksums): + +```bash +./scripts/run_external_proof.sh +``` + +This generates reproducible artifacts and SHA256 fingerprints under `artifacts/`. + +--- + +## 🌐 Hosted Playground (Public Mode) + +Open instantly in browser: + +- `GET /playground` +- `GET /api/v2/playground/ping` +- `GET /api/v2/playground/sample-trace` +- `GET /api/v2/playground/sample-risk` +- `GET /api/v2/demo/replay` --- ## 📖 Documentation +- [Fortune 500 Tier-1 Enhancements Roadmap](docs/FORTUNE500_TIER1_ENHANCEMENTS.md) +- [Development Guide](docs/DEVELOPMENT.md) +- [Deployment Guide](docs/DEPLOYMENT.md) +- [Installation Guide](docs/INSTALLATION.md) +- [API Reference](docs/API_REFERENCE.md) +- [Architecture Decisions](docs/ARCHITECTURE.md) +- [Algorithm Spec](docs/ALGORITHM.md) +- [BridgeTrace Technical Paper v1](docs/papers/BRIDGETRACE_TECHNICAL_PAPER_V1.md) +- [BridgeTrace Synthetic Dataset v1](docs/datasets/BRIDGETRACE_SYNTHETIC_DATASET_V1.md) +- [Security Policy](SECURITY.md) +- [SLA](SLA.md) +- [Versioning Policy](VERSIONING.md) +- [Threat Model](THREAT_MODEL.md) +- [Compliance Readiness](docs/COMPLIANCE_READINESS.md) +- [System Design](SYSTEM_DESIGN.md) +- [Scaling Strategy](SCALING_STRATEGY.md) +- [Failure Scenarios](FAILURE_SCENARIOS.md) +- [Performance Proof](docs/PERFORMANCE_PROOF.md) +- [Distribution Roadmap](docs/DISTRIBUTION_ROADMAP.md) +- [5-Minute Quickstart](docs/QUICKSTART_5MIN.md) +- [SDK Publishing Plan](docs/SDK_PUBLISHING.md) +- [Formal Whitepaper v1.0.0](docs/papers/WHITEPAPER_FORMAL_V1_0.md) +- [Scientific Changelog](docs/papers/SCIENTIFIC_CHANGELOG.md) +- [Official Competitive Comparison](docs/COMPETITIVE_COMPARISON.md) + ### API Endpoints #### Health Checks @@ -127,6 +241,15 @@ Content-Type: application/json } ``` +#### Professional API (Tier-1) +```http +POST /api/v2/trace +GET /api/v2/risk/{entity_id} +GET /api/v2/risk/propagation-map/{entity_id} +GET /api/v2/graph/{entity_id} +POST /api/v2/simulate +``` + #### AI Explanations ```http POST /api/v2/ai/explain diff --git a/SCALING_STRATEGY.md b/SCALING_STRATEGY.md new file mode 100644 index 0000000..d7bd21a --- /dev/null +++ b/SCALING_STRATEGY.md @@ -0,0 +1,17 @@ +# Scaling Strategy + +## Horizontal API Scaling +- stateless API workers +- externalize quota/audit stores (Redis/Postgres) + +## Compute Scaling +- offload heavy simulations to worker queue +- batch risk recomputation by tenant + +## Graph Scaling +- in-memory for dev +- migrate to Neo4j/TigerGraph backend for production + +## Observability Scaling +- Prometheus scraping + remote write +- business KPIs streamed to warehouse diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b31e16c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security Policy + +## Supported Versions +- `2.x` receives active security updates. + +## Reporting a Vulnerability +Please report vulnerabilities privately to: `security@bridgetrace.ai`. + +Include: +- affected component and version +- proof of concept or reproduction steps +- impact assessment (CIA) + +We follow coordinated disclosure: +- acknowledgement in 48h +- triage in 5 business days +- remediation timeline based on severity (Critical: 72h target) + +## Security Controls (Current) +- JWT and API key authentication support +- Request ID propagation and audit logs +- Tenant quotas and rate limiting baseline +- Structured logging and metrics endpoints + +## Hardening Roadmap +- key rotation automation +- secret manager integration +- distributed rate limiting +- mTLS for service-to-service communication diff --git a/SLA.md b/SLA.md new file mode 100644 index 0000000..cd3bb6f --- /dev/null +++ b/SLA.md @@ -0,0 +1,17 @@ +# Service Level Agreement (SLA) + +## Availability Targets +- API availability: **99.9%** monthly +- Critical endpoints (`/api/v2/trace`, `/api/v2/risk/*`): **99.95%** objective (enterprise tier) + +## Performance Targets +- `POST /api/v2/trace`: p95 < 1.5s +- `GET /api/v2/risk/{entity}`: p95 < 800ms +- `GET /metrics/business`: p95 < 300ms + +## Support Windows +- P1 incidents: 24/7 response, first response within 30 minutes +- P2 incidents: business hours, first response within 4 hours + +## Exclusions +Planned maintenance windows and upstream cloud outages are excluded and communicated in advance. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md new file mode 100644 index 0000000..dfc083d --- /dev/null +++ b/SYSTEM_DESIGN.md @@ -0,0 +1,15 @@ +# System Design + +BridgeTrace-AI is structured as: +- FastAPI API layer +- service layer for trace/risk/simulation +- analytics engine for risk propagation +- pluggable graph backend abstraction +- observability + business metrics + +Data flow: +1. request enters middleware (request-id/auth/quota/audit) +2. routed to service method +3. graph traversal/propagation executed +4. metrics and audit entries emitted +5. response returned with tenant/request headers diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md new file mode 100644 index 0000000..94acbbd --- /dev/null +++ b/THREAT_MODEL.md @@ -0,0 +1,30 @@ +# Threat Model + +## Assets +- financial transaction metadata +- risk scores and propagation maps +- tenant usage/audit logs + +## Trust Boundaries +- external client -> API gateway +- API -> graph engine / storage +- API -> monitoring / logging + +## Main Threats (STRIDE) +- **Spoofing**: stolen API keys/JWT +- **Tampering**: request payload manipulation +- **Repudiation**: missing or forged audit trail +- **Information Disclosure**: tenant data leakage +- **Denial of Service**: request floods or expensive graph traversals +- **Elevation of Privilege**: cross-tenant access via weak isolation + +## Mitigations (Current) +- request ID + audit logs +- baseline auth support (JWT/API key) +- quota controls per tenant and global rate limiting +- structured logging and observability + +## Priority Mitigations (Next) +- enforced auth in production profiles +- distributed quotas and WAF +- data encryption at rest and in transit hardening diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000..ceb4bca --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,18 @@ +# Versioning Policy + +BridgeTrace-AI follows **Semantic Versioning 2.0.0**. + +## Format +`MAJOR.MINOR.PATCH` + +- **MAJOR**: incompatible API or behavior changes +- **MINOR**: backward-compatible features +- **PATCH**: backward-compatible bug fixes + +## API Compatibility +- API version prefix (`/api/v2`) is stable for all `2.x` releases. +- Breaking API changes require a new prefix (`/api/v3`). + +## Deprecation Policy +- Deprecated endpoints are announced in `CHANGELOG.md`. +- Minimum deprecation window: 2 minor releases. diff --git a/app/analytics/__init__.py b/app/analytics/__init__.py new file mode 100644 index 0000000..22d5d22 --- /dev/null +++ b/app/analytics/__init__.py @@ -0,0 +1,5 @@ +"""Analytics modules for advanced risk intelligence.""" + +from app.analytics.risk_propagation import RiskPropagationEngine, PropagationResult + +__all__ = ["RiskPropagationEngine", "PropagationResult"] diff --git a/app/analytics/risk_propagation.py b/app/analytics/risk_propagation.py new file mode 100644 index 0000000..35e8ad8 --- /dev/null +++ b/app/analytics/risk_propagation.py @@ -0,0 +1,67 @@ +"""Risk propagation logic for graph-based financial risk scoring.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Iterable + +import networkx as nx + + +@dataclass(frozen=True) +class PropagationResult: + """Result object with propagated scores and dominant paths.""" + + scores: Dict[str, float] + dominant_source: Dict[str, str] + + +class RiskPropagationEngine: + """Propagate initial risk across a directed graph using edge weights and decay.""" + + def __init__(self, decay: float = 0.7, min_signal: float = 0.01): + self.decay = decay + self.min_signal = min_signal + + def run( + self, + graph: nx.DiGraph, + seed_scores: Dict[str, float], + max_hops: int = 4, + ) -> PropagationResult: + """Propagate risk from seed nodes up to max_hops. + + Edge attribute `risk_transfer` is used as weight when present. + """ + + scores: Dict[str, float] = {node: 0.0 for node in graph.nodes} + dominant_source: Dict[str, str] = {} + + frontier: Iterable[tuple[str, float, str, int]] = [ + (seed, score, seed, 0) for seed, score in seed_scores.items() if seed in graph + ] + + for seed, score in seed_scores.items(): + if seed in scores: + scores[seed] = max(scores[seed], score) + dominant_source[seed] = seed + + pending = list(frontier) + while pending: + node, incoming_risk, source, depth = pending.pop(0) + if depth >= max_hops: + continue + + for nxt in graph.successors(node): + edge = graph[node][nxt] + transfer = float(edge.get("risk_transfer", 0.8)) + propagated = incoming_risk * transfer * self.decay + if propagated < self.min_signal: + continue + + if propagated > scores.get(nxt, 0.0): + scores[nxt] = round(propagated, 4) + dominant_source[nxt] = source + pending.append((nxt, propagated, source, depth + 1)) + + return PropagationResult(scores=scores, dominant_source=dominant_source) diff --git a/app/api/dependencies.py b/app/api/dependencies.py index 53247b8..5bc7883 100644 --- a/app/api/dependencies.py +++ b/app/api/dependencies.py @@ -1,14 +1,23 @@ """API dependencies.""" -from fastapi import Depends -from app.services.trace_service import TraceService -from app.services.risk_service import RiskService + +from app.backends import InMemoryGraphBackend from app.services.ai_service import AIService +from app.services.risk_service import RiskService +from app.services.trace_service import TraceService + +_graph_backend = InMemoryGraphBackend() +_trace_service = TraceService(backend=_graph_backend) +_risk_service = RiskService() +_ai_service = AIService() + def get_trace_service() -> TraceService: - return TraceService() + return _trace_service + def get_risk_service() -> RiskService: - return RiskService() + return _risk_service + def get_ai_service() -> AIService: - return AIService() + return _ai_service diff --git a/app/api/routes/demo.py b/app/api/routes/demo.py new file mode 100644 index 0000000..0cbe1b3 --- /dev/null +++ b/app/api/routes/demo.py @@ -0,0 +1,78 @@ +"""Interactive demo endpoints for graph and timeline visualization.""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from fastapi import APIRouter + +router = APIRouter(prefix="/demo", tags=["Demo"]) + + +def _demo_links() -> list[dict]: + return [ + {"source": "wallet_sanctioned_01", "target": "mixer_01", "amount": 92000}, + {"source": "mixer_01", "target": "entity_001", "amount": 85000}, + {"source": "entity_001", "target": "merchant_991", "amount": 42000}, + {"source": "entity_001", "target": "exchange_004", "amount": 21000}, + ] + + +@router.get("/graph") +async def demo_graph() -> dict: + nodes = [ + {"id": "wallet_sanctioned_01", "risk": 0.95, "kind": "sanctioned"}, + {"id": "mixer_01", "risk": 0.78, "kind": "mixer"}, + {"id": "entity_001", "risk": 0.64, "kind": "entity"}, + {"id": "merchant_991", "risk": 0.32, "kind": "merchant"}, + {"id": "exchange_004", "risk": 0.41, "kind": "exchange"}, + ] + return {"nodes": nodes, "links": _demo_links()} + + +@router.get("/timeline") +async def demo_timeline() -> dict: + now = datetime.utcnow() + events = [] + for idx, evt in enumerate( + [ + ("wallet_sanctioned_01", "mixer_01", 92000, "layering-start"), + ("mixer_01", "entity_001", 87000, "mixing-hop"), + ("entity_001", "merchant_991", 42000, "cashout"), + ("entity_001", "exchange_004", 21000, "exchange-transfer"), + ] + ): + src, dst, amount, label = evt + events.append( + { + "at": (now - timedelta(minutes=(len(events) - idx) * 9)).isoformat() + "Z", + "source": src, + "target": dst, + "amount": amount, + "label": label, + "step": idx + 1, + } + ) + + return {"events": events} + + +@router.get("/replay") +async def demo_replay() -> dict: + """Return ordered risk cascade replay steps for animated explainability.""" + + steps = [] + cumulative_risk = 0.0 + for idx, link in enumerate(_demo_links(), start=1): + risk_delta = round((link["amount"] / 100000) * (0.25 / idx), 4) + cumulative_risk = round(min(cumulative_risk + risk_delta, 0.99), 4) + steps.append( + { + "step": idx, + "source": link["source"], + "target": link["target"], + "risk_delta": risk_delta, + "cumulative_risk": cumulative_risk, + } + ) + return {"replay": steps} diff --git a/app/api/routes/playground.py b/app/api/routes/playground.py new file mode 100644 index 0000000..6b0ecda --- /dev/null +++ b/app/api/routes/playground.py @@ -0,0 +1,38 @@ +"""Public hosted playground routes.""" + +from __future__ import annotations + +from fastapi import APIRouter + +router = APIRouter(prefix="/playground", tags=["Playground"]) + + +@router.get("/ping") +async def ping() -> dict: + return {"status": "ok", "message": "BridgeTrace hosted playground is online"} + + +@router.get("/sample-trace") +async def sample_trace() -> dict: + return { + "source_id": "bank_001", + "paths": [ + {"from": "bank_001", "to": "pix_001", "data": {"amount": 5000, "channel": "pix"}}, + {"from": "pix_001", "to": "crypto_001", "data": {"amount": 4800, "channel": "bridge"}}, + ], + "total_paths": 2, + } + + +@router.get("/sample-risk") +async def sample_risk() -> dict: + return { + "entity_id": "entity_001", + "risk_level": "MEDIUM", + "risk_score": 0.64, + "explanations": [ + "propagated_risk_from=wallet_sanctioned_01", + "temporal_decay=0.92", + "risk_cascade_detected=true", + ], + } diff --git a/app/api/routes/professional.py b/app/api/routes/professional.py new file mode 100644 index 0000000..e530823 --- /dev/null +++ b/app/api/routes/professional.py @@ -0,0 +1,59 @@ +"""Professional API endpoints for tier-1 workflows.""" + +from fastapi import APIRouter, Depends + +from app.api.dependencies import get_risk_service, get_trace_service +from app.schemas.simulate import SimulationRequest +from app.schemas.trace import TraceRequest +from app.services.risk_service import RiskService +from app.services.trace_service import TraceService + +router = APIRouter(tags=["Professional API"]) + + +@router.post("/trace") +async def trace_alias( + request: TraceRequest, + service: TraceService = Depends(get_trace_service), +): + """Alias endpoint without trailing slash for external integrations.""" + + return await service.trace_flow(request.source_id, request.max_hops, request.min_amount) + + +@router.get("/risk/{entity_id}") +async def get_risk( + entity_id: str, + days: int = 30, + service: RiskService = Depends(get_risk_service), +): + return await service.analyze_entity_risk(entity_id, time_range_days=days) + + +@router.get("/risk/propagation-map/{entity_id}") +async def get_propagation_map( + entity_id: str, + service: RiskService = Depends(get_risk_service), +): + return await service.propagation_map(entity_id) + + +@router.get("/graph/{entity_id}") +async def get_graph_entity( + entity_id: str, + service: TraceService = Depends(get_trace_service), +): + return await service.graph_snapshot(entity_id) + + +@router.post("/simulate") +async def simulate_transfer( + request: SimulationRequest, + service: RiskService = Depends(get_risk_service), +): + return await service.simulate_transfer( + source_id=request.source_id, + target_id=request.target_id, + amount=request.amount, + risk_transfer=request.risk_transfer, + ) diff --git a/app/backends/__init__.py b/app/backends/__init__.py new file mode 100644 index 0000000..7d5c983 --- /dev/null +++ b/app/backends/__init__.py @@ -0,0 +1,5 @@ +"""Graph backend implementations.""" + +from app.backends.graph_backend import GraphBackend, InMemoryGraphBackend, MockGraphBackend, Neo4jGraphBackend + +__all__ = ["GraphBackend", "InMemoryGraphBackend", "MockGraphBackend", "Neo4jGraphBackend"] diff --git a/app/backends/graph_backend.py b/app/backends/graph_backend.py new file mode 100644 index 0000000..2a32a51 --- /dev/null +++ b/app/backends/graph_backend.py @@ -0,0 +1,119 @@ +"""Pluggable graph backend abstractions.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional + +import networkx as nx + + +class GraphBackend(ABC): + """Generic interface for graph backend implementations.""" + + @abstractmethod + def add_node(self, node_id: str, **attrs: Any) -> None: + raise NotImplementedError + + @abstractmethod + def add_edge(self, source_id: str, target_id: str, **attrs: Any) -> None: + raise NotImplementedError + + @abstractmethod + def successors(self, node_id: str) -> List[str]: + raise NotImplementedError + + @abstractmethod + def get_edge(self, source_id: str, target_id: str) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def has_node(self, node_id: str) -> bool: + raise NotImplementedError + + @abstractmethod + def size(self) -> Dict[str, int]: + raise NotImplementedError + + @abstractmethod + def neighbors_with_edges(self, node_id: str) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def to_networkx(self) -> nx.DiGraph: + raise NotImplementedError + + +class InMemoryGraphBackend(GraphBackend): + """NetworkX-backed in-memory implementation.""" + + def __init__(self): + self.graph = nx.DiGraph() + + def add_node(self, node_id: str, **attrs: Any) -> None: + self.graph.add_node(node_id, **attrs) + + def add_edge(self, source_id: str, target_id: str, **attrs: Any) -> None: + self.graph.add_edge(source_id, target_id, **attrs) + + def successors(self, node_id: str) -> List[str]: + return list(self.graph.successors(node_id)) + + def get_edge(self, source_id: str, target_id: str) -> Dict[str, Any]: + return dict(self.graph[source_id][target_id]) + + def has_node(self, node_id: str) -> bool: + return node_id in self.graph + + def size(self) -> Dict[str, int]: + return {"nodes": self.graph.number_of_nodes(), "edges": self.graph.number_of_edges()} + + def neighbors_with_edges(self, node_id: str) -> Dict[str, Any]: + outgoing = [] + for target in self.graph.successors(node_id): + outgoing.append({"target": target, "edge": dict(self.graph[node_id][target])}) + return {"entity": node_id, "outgoing": outgoing} + + def to_networkx(self) -> nx.DiGraph: + return self.graph + + +class MockGraphBackend(InMemoryGraphBackend): + """Mock backend for tests; currently aliases in-memory behavior.""" + + +class Neo4jGraphBackend(GraphBackend): + """Placeholder for Neo4j implementation. + + This class defines the contract for production graph DB integration. + """ + + def __init__(self, uri: Optional[str] = None): + self.uri = uri + + def _not_implemented(self) -> None: + raise NotImplementedError("Neo4j backend integration is planned for production deployments") + + def add_node(self, node_id: str, **attrs: Any) -> None: + self._not_implemented() + + def add_edge(self, source_id: str, target_id: str, **attrs: Any) -> None: + self._not_implemented() + + def successors(self, node_id: str) -> List[str]: + self._not_implemented() + + def get_edge(self, source_id: str, target_id: str) -> Dict[str, Any]: + self._not_implemented() + + def has_node(self, node_id: str) -> bool: + self._not_implemented() + + def size(self) -> Dict[str, int]: + self._not_implemented() + + def neighbors_with_edges(self, node_id: str) -> Dict[str, Any]: + self._not_implemented() + + def to_networkx(self) -> nx.DiGraph: + self._not_implemented() diff --git a/app/core/config.py b/app/core/config.py index 6c04afa..c2c3e13 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,51 +1,62 @@ """Application configuration management.""" + from functools import lru_cache from typing import Optional -from pydantic import Field, PostgresDsn, RedisDsn + +from pydantic import PostgresDsn, RedisDsn from pydantic_settings import BaseSettings, SettingsConfigDict + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", case_sensitive=False) - + # App app_name: str = "BridgeTrace AI" app_version: str = "2.0.0" app_env: str = "development" debug: bool = False - + # API api_prefix: str = "/api/v2" api_host: str = "0.0.0.0" api_port: int = 8000 - + # Security secret_key: str = "CHANGE_IN_PRODUCTION" algorithm: str = "HS256" access_token_expire_minutes: int = 30 - + api_keys_csv: str = "dev-key-1,dev-key-2" + api_key_rotation_days: int = 30 + enterprise_auth_enforced: bool = False + + # Enterprise tenancy + default_tenant_quota_per_minute: int = 120 + # Database database_url: Optional[PostgresDsn] = None - + # Redis redis_url: Optional[RedisDsn] = None - + # Logging log_level: str = "INFO" log_format: str = "json" - + # AI openai_api_key: Optional[str] = None llm_model: str = "gpt-4" - + # Tracing max_trace_hops: int = 10 - + @property def is_production(self) -> bool: return self.app_env == "production" + @lru_cache() def get_settings() -> Settings: return Settings() + settings = get_settings() diff --git a/app/core/enterprise.py b/app/core/enterprise.py new file mode 100644 index 0000000..28c2814 --- /dev/null +++ b/app/core/enterprise.py @@ -0,0 +1,93 @@ +"""Enterprise controls: tenant quotas, audit logs, and business metrics.""" + +from __future__ import annotations + +import time +from collections import defaultdict, deque +from dataclasses import dataclass, field +from typing import Any, Dict, List + + +@dataclass +class TenantContext: + tenant_id: str + plan: str = "free" + quota_per_minute: int = 120 + + +@dataclass +class AuditEntry: + at: float + tenant_id: str + action: str + request_id: str + outcome: str + metadata: Dict[str, Any] = field(default_factory=dict) + + +class TenantQuotaManager: + def __init__(self): + self._tenant_calls: Dict[str, deque] = defaultdict(deque) + + def allow(self, tenant: TenantContext, now: float | None = None) -> bool: + now = now or time.time() + queue = self._tenant_calls[tenant.tenant_id] + while queue and queue[0] < now - 60: + queue.popleft() + if len(queue) >= tenant.quota_per_minute: + return False + queue.append(now) + return True + + +class AuditLogger: + def __init__(self): + self._entries: List[AuditEntry] = [] + + def log(self, entry: AuditEntry) -> None: + self._entries.append(entry) + + def tail(self, limit: int = 100) -> List[Dict[str, Any]]: + return [e.__dict__ for e in self._entries[-limit:]] + + +class BusinessMetrics: + def __init__(self): + self.trace_count = 0 + self.risk_count = 0 + self.false_positive_count = 0 + self.detected_count = 0 + self.total_trace_seconds = 0.0 + self.total_operations = 0 + + def record_trace(self, duration_seconds: float) -> None: + self.trace_count += 1 + self.total_operations += 1 + self.total_trace_seconds += duration_seconds + + def record_risk(self, detected: bool = False, false_positive: bool = False) -> None: + self.risk_count += 1 + self.total_operations += 1 + if detected: + self.detected_count += 1 + if false_positive: + self.false_positive_count += 1 + + def snapshot(self) -> Dict[str, Any]: + avg_trace = self.total_trace_seconds / self.trace_count if self.trace_count else 0.0 + detection_rate = self.detected_count / self.risk_count if self.risk_count else 0.0 + false_positive_rate = self.false_positive_count / self.risk_count if self.risk_count else 0.0 + throughput_per_sec = self.total_operations / max(self.total_trace_seconds, 1.0) + cost_per_million_ops = 12.5 # placeholder estimate for planning + return { + "avg_trace_seconds": round(avg_trace, 6), + "detection_rate": round(detection_rate, 6), + "false_positive_rate": round(false_positive_rate, 6), + "throughput_per_second": round(throughput_per_sec, 4), + "cost_per_1m_operations_usd": cost_per_million_ops, + } + + +tenant_quota_manager = TenantQuotaManager() +audit_logger = AuditLogger() +business_metrics = BusinessMetrics() diff --git a/app/core/exceptions.py b/app/core/exceptions.py index 1101e11..f5fe0a5 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -1,28 +1,47 @@ -"""Custom exceptions.""" -from typing import Any, Optional +"""Custom domain exceptions for BridgeTrace.""" + +from typing import Optional + from fastapi import HTTPException, status + class BridgeTraceException(Exception): + """Base exception for known application failures.""" + def __init__(self, message: str, code: Optional[str] = None, details: Optional[dict] = None): self.message = message self.code = code or self.__class__.__name__ self.details = details or {} super().__init__(self.message) + class ValidationError(BridgeTraceException): - pass + """Raised when request/domain validation fails.""" + class NotFoundError(BridgeTraceException): - pass + """Raised when expected resources are not found.""" + class AuthenticationError(BridgeTraceException): - pass + """Raised for authentication failures.""" + + +class GraphTraversalError(BridgeTraceException): + """Raised when graph traversal cannot be completed safely.""" + def exception_to_http(exc: BridgeTraceException) -> HTTPException: + """Map domain exceptions to HTTP-friendly errors.""" + status_map = { ValidationError: status.HTTP_400_BAD_REQUEST, NotFoundError: status.HTTP_404_NOT_FOUND, AuthenticationError: status.HTTP_401_UNAUTHORIZED, + GraphTraversalError: status.HTTP_422_UNPROCESSABLE_ENTITY, } code = status_map.get(type(exc), status.HTTP_500_INTERNAL_SERVER_ERROR) - return HTTPException(status_code=code, detail={"message": exc.message, "code": exc.code}) + return HTTPException( + status_code=code, + detail={"message": exc.message, "code": exc.code, "details": exc.details}, + ) diff --git a/app/core/security.py b/app/core/security.py index c4f12fe..689e237 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -1,20 +1,56 @@ """Security utilities.""" + +from __future__ import annotations + from datetime import datetime, timedelta -from typing import Optional -from jose import jwt +from typing import Any, Optional + +from jose import JWTError, jwt from passlib.context import CryptContext + from app.core.config import settings pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: to_encode = data.copy() expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.access_token_expire_minutes)) to_encode.update({"exp": expire}) return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) + def verify_password(plain: str, hashed: str) -> bool: return pwd_context.verify(plain, hashed) + def get_password_hash(password: str) -> str: return pwd_context.hash(password) + + +def valid_api_keys() -> list[str]: + return [k.strip() for k in settings.api_keys_csv.split(",") if k.strip()] + + +def validate_api_key(api_key: str | None) -> bool: + return bool(api_key and api_key in valid_api_keys()) + + +def decode_bearer_token(token: str | None) -> dict[str, Any] | None: + if not token: + return None + try: + return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + except JWTError: + return None + + +def authenticate_request(api_key: str | None, authorization: str | None) -> bool: + if validate_api_key(api_key): + return True + + if authorization and authorization.lower().startswith("bearer "): + token = authorization.split(" ", 1)[1].strip() + return decode_bearer_token(token) is not None + + return False diff --git a/app/main.py b/app/main.py index e1f409e..658f7a0 100644 --- a/app/main.py +++ b/app/main.py @@ -1,14 +1,23 @@ """FastAPI application entrypoint.""" -from fastapi import FastAPI + +from __future__ import annotations + +import time +import uuid +from collections import defaultdict, deque + +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from prometheus_client import generate_latest, CONTENT_TYPE_LATEST -from starlette.responses import Response +from prometheus_client import CONTENT_TYPE_LATEST, generate_latest +from starlette.responses import FileResponse, Response +from app.api.routes import ai, demo, health, playground, professional, risk, trace from app.core.config import settings -from app.core.logging import setup_logging, get_logger +from app.core.enterprise import AuditEntry, TenantContext, audit_logger, business_metrics, tenant_quota_manager from app.core.exceptions import BridgeTraceException, exception_to_http -from app.api.routes import health, trace, risk, ai +from app.core.logging import get_logger, setup_logging +from app.core.security import authenticate_request # Setup logging setup_logging() @@ -21,7 +30,7 @@ description="Enterprise Financial Traceability Engine", docs_url=f"{settings.api_prefix}/docs", redoc_url=f"{settings.api_prefix}/redoc", - openapi_url=f"{settings.api_prefix}/openapi.json" + openapi_url=f"{settings.api_prefix}/openapi.json", ) # CORS @@ -33,26 +42,139 @@ allow_headers=["*"], ) +_REQUEST_LIMIT = 120 +_REQUEST_WINDOW_SECONDS = 60 +_request_counters: dict[str, deque] = defaultdict(deque) + + +@app.middleware("http") +async def request_id_and_rate_limit(request: Request, call_next): + request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())) + client_ip = request.client.host if request.client else "unknown" + tenant_id = request.headers.get("X-Tenant-ID", "public") + tenant = TenantContext(tenant_id=tenant_id, quota_per_minute=settings.default_tenant_quota_per_minute) + + now = time.time() + queue = _request_counters[client_ip] + while queue and queue[0] < now - _REQUEST_WINDOW_SECONDS: + queue.popleft() + if len(queue) >= _REQUEST_LIMIT: + return JSONResponse( + status_code=429, + content={"message": "Rate limit exceeded", "request_id": request_id}, + headers={"X-Request-ID": request_id}, + ) + + protected_path = request.url.path.startswith(settings.api_prefix) + is_public_endpoint = any( + request.url.path.startswith(p) + for p in [ + f"{settings.api_prefix}/health", + f"{settings.api_prefix}/docs", + f"{settings.api_prefix}/openapi.json", + f"{settings.api_prefix}/demo", + f"{settings.api_prefix}/playground", + ] + ) + + if protected_path and not is_public_endpoint: + api_key = request.headers.get("X-API-Key") + auth_header = request.headers.get("Authorization") + is_authenticated = authenticate_request(api_key, auth_header) + if settings.enterprise_auth_enforced and not is_authenticated: + return JSONResponse( + status_code=401, + content={"message": "Authentication required", "request_id": request_id}, + headers={"X-Request-ID": request_id}, + ) + + if not tenant_quota_manager.allow(tenant): + audit_logger.log( + AuditEntry( + at=time.time(), + tenant_id=tenant_id, + action="quota_check", + request_id=request_id, + outcome="denied", + metadata={"path": request.url.path}, + ) + ) + return JSONResponse( + status_code=429, + content={"message": "Tenant quota exceeded", "request_id": request_id}, + headers={"X-Request-ID": request_id}, + ) + + queue.append(now) + started = time.perf_counter() + response = await call_next(request) + elapsed = time.perf_counter() - started + + if request.url.path.startswith(f"{settings.api_prefix}/trace"): + business_metrics.record_trace(elapsed) + if request.url.path.startswith(f"{settings.api_prefix}/risk"): + business_metrics.record_risk(detected=response.status_code == 200) + + audit_logger.log( + AuditEntry( + at=time.time(), + tenant_id=tenant_id, + action="api_call", + request_id=request_id, + outcome=str(response.status_code), + metadata={"path": request.url.path, "method": request.method}, + ) + ) + + response.headers["X-Request-ID"] = request_id + response.headers["X-Tenant-ID"] = tenant_id + return response + + # Include routers app.include_router(health.router, prefix=settings.api_prefix) app.include_router(trace.router, prefix=settings.api_prefix) app.include_router(risk.router, prefix=settings.api_prefix) app.include_router(ai.router, prefix=settings.api_prefix) +app.include_router(professional.router, prefix=settings.api_prefix) +app.include_router(demo.router, prefix=settings.api_prefix) +app.include_router(playground.router, prefix=settings.api_prefix) + # Exception handler @app.exception_handler(BridgeTraceException) async def bridgetrace_exception_handler(request, exc: BridgeTraceException): logger.error("application_error", error=exc.message, code=exc.code) - return JSONResponse( - status_code=400, - content={"error": exc.message, "code": exc.code} - ) + http_exc = exception_to_http(exc) + return JSONResponse(status_code=http_exc.status_code, content=http_exc.detail) + # Metrics endpoint @app.get("/metrics") async def metrics(): return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) + +@app.get("/metrics/business") +async def business_metrics_snapshot(): + return business_metrics.snapshot() + + +@app.get("/audit/logs") +async def audit_logs(limit: int = 50): + return {"entries": audit_logger.tail(limit)} + + +@app.get("/dashboard") +async def dashboard(): + return FileResponse("frontend/tier1_dashboard.html") + + +@app.get("/playground") +async def hosted_playground(): + return FileResponse("frontend/playground.html") + + # Root endpoint @app.get("/") async def root(): @@ -60,9 +182,10 @@ async def root(): "name": settings.app_name, "version": settings.app_version, "environment": settings.app_env, - "docs": f"{settings.api_prefix}/docs" + "docs": f"{settings.api_prefix}/docs", } + # Startup event @app.on_event("startup") async def startup_event(): @@ -70,19 +193,22 @@ async def startup_event(): "application_startup", app_name=settings.app_name, version=settings.app_version, - environment=settings.app_env + environment=settings.app_env, ) + # Shutdown event @app.on_event("shutdown") async def shutdown_event(): logger.info("application_shutdown") + if __name__ == "__main__": import uvicorn + uvicorn.run( "app.main:app", host=settings.api_host, port=settings.api_port, - reload=settings.debug + reload=settings.debug, ) diff --git a/app/metrics/__init__.py b/app/metrics/__init__.py new file mode 100644 index 0000000..4a38f3e --- /dev/null +++ b/app/metrics/__init__.py @@ -0,0 +1,7 @@ +"""Observability metrics modules.""" + +from app.metrics.graph_stats import update_graph_size +from app.metrics.latency import track_latency +from app.metrics.tracing_stats import record_trace_result + +__all__ = ["track_latency", "update_graph_size", "record_trace_result"] diff --git a/app/metrics/graph_stats.py b/app/metrics/graph_stats.py new file mode 100644 index 0000000..5713324 --- /dev/null +++ b/app/metrics/graph_stats.py @@ -0,0 +1,13 @@ +"""Graph statistics metrics.""" + +from __future__ import annotations + +from prometheus_client import Gauge + +GRAPH_NODES_TOTAL = Gauge("graph_nodes_total", "Current number of graph nodes") +GRAPH_EDGES_TOTAL = Gauge("graph_edges_total", "Current number of graph edges") + + +def update_graph_size(nodes: int, edges: int) -> None: + GRAPH_NODES_TOTAL.set(nodes) + GRAPH_EDGES_TOTAL.set(edges) diff --git a/app/metrics/latency.py b/app/metrics/latency.py new file mode 100644 index 0000000..507bf48 --- /dev/null +++ b/app/metrics/latency.py @@ -0,0 +1,23 @@ +"""Latency metrics helpers.""" + +from __future__ import annotations + +import time +from contextlib import contextmanager + +from prometheus_client import Histogram + +TRACE_LATENCY_SECONDS = Histogram( + "trace_latency_seconds", + "Latency of trace and simulation operations", + ["operation"], +) + + +@contextmanager +def track_latency(operation: str): + start = time.perf_counter() + try: + yield + finally: + TRACE_LATENCY_SECONDS.labels(operation=operation).observe(time.perf_counter() - start) diff --git a/app/metrics/tracing_stats.py b/app/metrics/tracing_stats.py new file mode 100644 index 0000000..d71de69 --- /dev/null +++ b/app/metrics/tracing_stats.py @@ -0,0 +1,33 @@ +"""Tracing operation metrics.""" + +from __future__ import annotations + +from prometheus_client import Counter, Gauge + +TRACE_REQUESTS_TOTAL = Counter( + "trace_requests_total", + "Total trace requests by status", + ["status"], +) + +TRACE_AVG_HOPS = Gauge("trace_avg_hops", "Average hops in trace responses") +TRACE_ERROR_RATE = Gauge("trace_error_rate", "Trace error rate over process lifetime") + +_total_requests = 0 +_total_errors = 0 +_total_hops = 0.0 + + +def record_trace_result(hops: int, success: bool) -> None: + global _total_requests, _total_errors, _total_hops + + _total_requests += 1 + if success: + TRACE_REQUESTS_TOTAL.labels(status="success").inc() + else: + _total_errors += 1 + TRACE_REQUESTS_TOTAL.labels(status="error").inc() + + _total_hops += float(hops) + TRACE_AVG_HOPS.set(_total_hops / _total_requests) + TRACE_ERROR_RATE.set(_total_errors / _total_requests) diff --git a/app/schemas/simulate.py b/app/schemas/simulate.py new file mode 100644 index 0000000..df56621 --- /dev/null +++ b/app/schemas/simulate.py @@ -0,0 +1,10 @@ +"""Simulation schemas.""" + +from pydantic import BaseModel, Field + + +class SimulationRequest(BaseModel): + source_id: str + target_id: str + amount: float = Field(gt=0) + risk_transfer: float = Field(default=0.7, ge=0.0, le=1.0) diff --git a/app/services/risk_service.py b/app/services/risk_service.py index 77e6b85..56713eb 100644 --- a/app/services/risk_service.py +++ b/app/services/risk_service.py @@ -1,43 +1,162 @@ """Risk analysis service.""" -from typing import Dict, Any, List + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict + +import networkx as nx + +from app.analytics import RiskPropagationEngine from app.core.logging import get_logger +from app.metrics import track_latency logger = get_logger(__name__) + class RiskService: + """Service responsible for entity risk analysis and explainability.""" + def __init__(self): self.risk_thresholds = {"high": 0.7, "medium": 0.4} - + self.graph = self._build_reference_graph() + self.propagation = RiskPropagationEngine(decay=0.75, min_signal=0.02) + self._cache: Dict[str, Dict[str, Any]] = {} + async def analyze_entity_risk( self, entity_id: str, - time_range_days: int = 30 + time_range_days: int = 30, ) -> Dict[str, Any]: """Analyze risk for an entity.""" + logger.info("risk_analysis_started", entity_id=entity_id, days=time_range_days) - - # Simulated analysis - risk_score = 0.35 - risk_level = self._calculate_risk_level(risk_score) - + cache_key = f"{entity_id}:{time_range_days}" + if cache_key in self._cache: + cached = dict(self._cache[cache_key]) + cached["cache_hit"] = True + return cached + + with track_latency("risk_analysis"): + seeds = self._seed_scores_for_entity(entity_id) + propagation = self.propagation.run(self.graph, seed_scores=seeds, max_hops=4) + propagated_score = propagation.scores.get(entity_id, 0.2) + temporal_decay = self._temporal_decay_factor(time_range_days) + behavioral_component = 0.15 + risk_score = min(round((propagated_score * temporal_decay * 0.7) + behavioral_component, 4), 0.99) + risk_level = self._calculate_risk_level(risk_score) + + dominant_source = propagation.dominant_source.get(entity_id, "unknown") + reasons = [ + f"propagated_risk_from={dominant_source}", + f"time_window_days={time_range_days}", + f"temporal_decay={temporal_decay}", + ] + + result = { + "entity_id": entity_id, + "risk_level": risk_level, + "risk_score": risk_score, + "metrics": { + "transaction_count": 15, + "total_volume": 75000.0, + "average_risk_score": round((risk_score + propagated_score) / 2, 4), + "high_risk_count": 2, + "channels_used": ["PIX", "CRYPTO_BRIDGE"], + }, + "recommendations": [ + "Monitor large transactions", + "Verify beneficiary identity", + "Enable enhanced due diligence", + ], + "explanations": reasons, + "cache_hit": False, + } + + self._cache[cache_key] = dict(result) + return result + + async def propagation_map(self, entity_id: str) -> Dict[str, Any]: + """Return risk influence map for explainability.""" + + seeds = self._seed_scores_for_entity(entity_id) + propagation = self.propagation.run(self.graph, seed_scores=seeds, max_hops=5) + + influence = { + node: score + for node, score in propagation.scores.items() + if score >= self._adaptive_threshold() + } + return { "entity_id": entity_id, - "risk_level": risk_level, - "risk_score": risk_score, - "metrics": { - "transaction_count": 15, - "total_volume": 75000.0, - "high_risk_count": 2, + "generated_at": datetime.utcnow().isoformat() + "Z", + "adaptive_threshold": self._adaptive_threshold(), + "influence": influence, + "dominant_source": propagation.dominant_source, + } + + async def simulate_transfer( + self, + source_id: str, + target_id: str, + amount: float, + risk_transfer: float = 0.7, + ) -> Dict[str, Any]: + """Simulate a transfer and report projected risk for source and target.""" + + sandbox_graph = self.graph.copy() + sandbox_graph.add_node(source_id) + sandbox_graph.add_node(target_id) + sandbox_graph.add_edge(source_id, target_id, amount=amount, risk_transfer=risk_transfer) + + seeds = self._seed_scores_for_entity(source_id) + projection = self.propagation.run(sandbox_graph, seed_scores=seeds, max_hops=5) + + return { + "simulation": { + "source_id": source_id, + "target_id": target_id, + "amount": amount, + "risk_transfer": risk_transfer, + }, + "projected_risk": { + source_id: projection.scores.get(source_id, 0.0), + target_id: projection.scores.get(target_id, 0.0), }, - "recommendations": [ - "Monitor large transactions", - "Verify beneficiary identity" - ] } - + + def _seed_scores_for_entity(self, entity_id: str) -> Dict[str, float]: + """Multi-source seeds to represent sanctions + behavior based alerts.""" + + seeds: Dict[str, float] = {"wallet_sanctioned_01": 0.92} + if entity_id.startswith("entity_"): + seeds["mixer_01"] = 0.55 + return seeds + + def _temporal_decay_factor(self, time_range_days: int) -> float: + """Decay risk contribution as analysis window increases.""" + + return max(0.55, round(1.0 - (time_range_days / 3650), 4)) + + def _adaptive_threshold(self) -> float: + """Adjust influence threshold by graph density.""" + + nodes = max(self.graph.number_of_nodes(), 1) + density = self.graph.number_of_edges() / nodes + return 0.05 if density > 1 else 0.02 + def _calculate_risk_level(self, score: float) -> str: if score >= self.risk_thresholds["high"]: return "HIGH" - elif score >= self.risk_thresholds["medium"]: + if score >= self.risk_thresholds["medium"]: return "MEDIUM" return "LOW" + + def _build_reference_graph(self) -> nx.DiGraph: + graph = nx.DiGraph() + graph.add_edge("wallet_sanctioned_01", "mixer_01", risk_transfer=0.9) + graph.add_edge("mixer_01", "entity_001", risk_transfer=0.85) + graph.add_edge("entity_001", "merchant_991", risk_transfer=0.5) + graph.add_edge("wallet_watchlist_77", "entity_001", risk_transfer=0.45) + return graph diff --git a/app/services/trace_service.py b/app/services/trace_service.py index 17a4ed1..5c6b98a 100644 --- a/app/services/trace_service.py +++ b/app/services/trace_service.py @@ -1,60 +1,80 @@ """Trace service business logic.""" -from typing import List, Dict, Any -import networkx as nx -from app.core.logging import get_logger + +from __future__ import annotations + +from typing import Any, Dict + +from app.backends import GraphBackend, InMemoryGraphBackend from app.core.exceptions import GraphTraversalError, NotFoundError +from app.core.logging import get_logger +from app.metrics import record_trace_result, track_latency, update_graph_size logger = get_logger(__name__) + class TraceService: - def __init__(self): - self.graph = nx.DiGraph() + """Service that resolves financial traces and graph views.""" + + def __init__(self, backend: GraphBackend | None = None): + self.backend = backend or InMemoryGraphBackend() self._initialize_sample_graph() - - def _initialize_sample_graph(self): + + def _initialize_sample_graph(self) -> None: """Initialize with sample data.""" + nodes = [ ("bank_001", {"type": "bank_account", "name": "Banco X"}), ("pix_001", {"type": "pix_key", "name": "PIX ***123"}), ("crypto_001", {"type": "crypto_wallet", "name": "BTC Wallet"}), ] for node_id, attrs in nodes: - self.graph.add_node(node_id, **attrs) - + self.backend.add_node(node_id, **attrs) + edges = [ - ("bank_001", "pix_001", {"amount": 5000, "channel": "pix", "risk": 0.2}), - ("pix_001", "crypto_001", {"amount": 4800, "channel": "bridge", "risk": 0.5}), + ("bank_001", "pix_001", {"amount": 5000, "channel": "pix", "risk": 0.2, "risk_transfer": 0.9}), + ("pix_001", "crypto_001", {"amount": 4800, "channel": "bridge", "risk": 0.5, "risk_transfer": 0.8}), ] for src, dst, attrs in edges: - self.graph.add_edge(src, dst, **attrs) - + self.backend.add_edge(src, dst, **attrs) + + size = self.backend.size() + update_graph_size(size["nodes"], size["edges"]) + async def trace_flow( self, source_id: str, max_hops: int = 5, - min_amount: float = 0.0 + min_amount: float = 0.0, ) -> Dict[str, Any]: """Trace financial flow from source.""" + logger.info("trace_flow_started", source_id=source_id, max_hops=max_hops) - - if source_id not in self.graph: + + if not self.backend.has_node(source_id): + record_trace_result(hops=0, success=False) raise NotFoundError(f"Node {source_id} not found") - + paths = [] try: - for target in self.graph.successors(source_id): - edge_data = self.graph[source_id][target] - if edge_data.get("amount", 0) >= min_amount: - paths.append({ - "from": source_id, - "to": target, - "data": edge_data - }) - except Exception as e: - raise GraphTraversalError(f"Failed to traverse graph: {str(e)}") - - return { - "source_id": source_id, - "paths": paths, - "total_paths": len(paths) - } + with track_latency("trace"): + for target in self.backend.successors(source_id): + edge_data = self.backend.get_edge(source_id, target) + if edge_data.get("amount", 0) >= min_amount: + paths.append({"from": source_id, "to": target, "data": edge_data}) + except Exception as exc: + record_trace_result(hops=0, success=False) + raise GraphTraversalError(f"Failed to traverse graph: {str(exc)}") from exc + + record_trace_result(hops=len(paths), success=True) + + return {"source_id": source_id, "paths": paths, "total_paths": len(paths), "max_hops": max_hops} + + async def graph_snapshot(self, entity_id: str) -> Dict[str, Any]: + """Return graph neighborhood and graph size for an entity.""" + + if not self.backend.has_node(entity_id): + raise NotFoundError(f"Node {entity_id} not found") + + neighborhood = self.backend.neighbors_with_edges(entity_id) + size = self.backend.size() + return {"graph": neighborhood, "graph_size": size} diff --git a/bridge_trace_sdk.py b/bridge_trace_sdk.py new file mode 100644 index 0000000..3a04fd4 --- /dev/null +++ b/bridge_trace_sdk.py @@ -0,0 +1,40 @@ +"""Minimal Python SDK for BridgeTrace API quick integrations.""" + +from __future__ import annotations + +from typing import Any, Dict + +import httpx + + +class BridgeTraceSDK: + def __init__(self, base_url: str, api_key: str | None = None, tenant_id: str = "public"): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.tenant_id = tenant_id + + def _headers(self) -> Dict[str, str]: + headers = {"X-Tenant-ID": self.tenant_id} + if self.api_key: + headers["X-API-Key"] = self.api_key + return headers + + def trace(self, source_id: str, max_hops: int = 5, min_amount: float = 0.0) -> Dict[str, Any]: + response = httpx.post( + f"{self.base_url}/api/v2/trace", + json={"source_id": source_id, "max_hops": max_hops, "min_amount": min_amount}, + headers=self._headers(), + timeout=10, + ) + response.raise_for_status() + return response.json() + + def risk(self, entity_id: str, days: int = 30) -> Dict[str, Any]: + response = httpx.get( + f"{self.base_url}/api/v2/risk/{entity_id}", + params={"days": days}, + headers=self._headers(), + timeout=10, + ) + response.raise_for_status() + return response.json() diff --git a/data/processed/.gitkeep b/data/processed/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/public/bridgetrace_synthetic_financial_dataset_v1.jsonl b/data/public/bridgetrace_synthetic_financial_dataset_v1.jsonl new file mode 100644 index 0000000..f9b0df7 --- /dev/null +++ b/data/public/bridgetrace_synthetic_financial_dataset_v1.jsonl @@ -0,0 +1,200 @@ +{"tx_id": "BTX_0000000", "source": "wallet_2025", "target": "entity_394", "amount": 41616.88, "timestamp": "2026-01-02T08:46:00Z", "pattern": "layering", "risk_transfer": 0.312} +{"tx_id": "BTX_0000001", "source": "wallet_2692", "target": "entity_394", "amount": 133880.85, "timestamp": "2026-01-27T04:09:00Z", "pattern": "layering", "risk_transfer": 0.672} +{"tx_id": "BTX_0000002", "source": "wallet_2030", "target": "entity_311", "amount": 33186.38, "timestamp": "2025-11-12T00:04:00Z", "pattern": "layering", "risk_transfer": 0.682} +{"tx_id": "BTX_0000003", "source": "entity_191", "target": "entity_169", "amount": 63218.21, "timestamp": "2025-11-22T05:22:00Z", "pattern": "loop", "risk_transfer": 0.671} +{"tx_id": "BTX_0000004", "source": "wallet_2777", "target": "entity_320", "amount": 104871.84, "timestamp": "2025-12-12T01:25:00Z", "pattern": "layering", "risk_transfer": 0.422} +{"tx_id": "BTX_0000005", "source": "entity_197", "target": "entity_111", "amount": 57299.13, "timestamp": "2025-12-08T15:35:00Z", "pattern": "loop", "risk_transfer": 0.878} +{"tx_id": "BTX_0000006", "source": "wallet_1826", "target": "mixer_1", "amount": 109594.9, "timestamp": "2025-11-06T09:11:00Z", "pattern": "mixer", "risk_transfer": 0.3} +{"tx_id": "BTX_0000007", "source": "entity_210", "target": "merchant_36", "amount": 44330.15, "timestamp": "2025-10-20T13:25:00Z", "pattern": "cashout", "risk_transfer": 0.695} +{"tx_id": "BTX_0000008", "source": "wallet_1591", "target": "mixer_4", "amount": 105833.49, "timestamp": "2026-02-03T15:47:00Z", "pattern": "mixer", "risk_transfer": 0.729} +{"tx_id": "BTX_0000009", "source": "wallet_1081", "target": "mixer_4", "amount": 130039.31, "timestamp": "2025-12-04T19:12:00Z", "pattern": "mixer", "risk_transfer": 0.422} +{"tx_id": "BTX_0000010", "source": "wallet_1166", "target": "mixer_6", "amount": 53612.97, "timestamp": "2025-12-25T09:33:00Z", "pattern": "mixer", "risk_transfer": 0.761} +{"tx_id": "BTX_0000011", "source": "wallet_2623", "target": "entity_381", "amount": 26085.23, "timestamp": "2025-12-29T10:24:00Z", "pattern": "layering", "risk_transfer": 0.331} +{"tx_id": "BTX_0000012", "source": "entity_234", "target": "merchant_41", "amount": 103380.21, "timestamp": "2026-01-03T00:29:00Z", "pattern": "cashout", "risk_transfer": 0.748} +{"tx_id": "BTX_0000013", "source": "wallet_2234", "target": "entity_304", "amount": 120854.35, "timestamp": "2025-11-30T23:17:00Z", "pattern": "layering", "risk_transfer": 0.414} +{"tx_id": "BTX_0000014", "source": "entity_172", "target": "entity_140", "amount": 32287.67, "timestamp": "2025-11-13T02:49:00Z", "pattern": "loop", "risk_transfer": 0.517} +{"tx_id": "BTX_0000015", "source": "entity_218", "target": "merchant_17", "amount": 21374.73, "timestamp": "2025-11-01T19:20:00Z", "pattern": "cashout", "risk_transfer": 0.631} +{"tx_id": "BTX_0000016", "source": "entity_274", "target": "merchant_26", "amount": 54618.47, "timestamp": "2026-01-17T19:37:00Z", "pattern": "cashout", "risk_transfer": 0.608} +{"tx_id": "BTX_0000017", "source": "wallet_2773", "target": "entity_306", "amount": 129234.88, "timestamp": "2026-01-15T04:13:00Z", "pattern": "layering", "risk_transfer": 0.702} +{"tx_id": "BTX_0000018", "source": "entity_276", "target": "merchant_5", "amount": 58022.71, "timestamp": "2025-10-26T12:31:00Z", "pattern": "cashout", "risk_transfer": 0.997} +{"tx_id": "BTX_0000019", "source": "wallet_1994", "target": "mixer_9", "amount": 129186.57, "timestamp": "2026-02-09T21:50:00Z", "pattern": "mixer", "risk_transfer": 0.744} +{"tx_id": "BTX_0000020", "source": "wallet_2698", "target": "entity_368", "amount": 112756.23, "timestamp": "2025-10-18T07:35:00Z", "pattern": "layering", "risk_transfer": 0.472} +{"tx_id": "BTX_0000021", "source": "wallet_1445", "target": "mixer_3", "amount": 68331.69, "timestamp": "2025-12-26T01:14:00Z", "pattern": "mixer", "risk_transfer": 0.978} +{"tx_id": "BTX_0000022", "source": "entity_164", "target": "entity_180", "amount": 45117.5, "timestamp": "2025-10-18T16:23:00Z", "pattern": "loop", "risk_transfer": 0.606} +{"tx_id": "BTX_0000023", "source": "entity_119", "target": "entity_120", "amount": 81137.17, "timestamp": "2025-11-07T10:50:00Z", "pattern": "loop", "risk_transfer": 0.935} +{"tx_id": "BTX_0000024", "source": "wallet_1500", "target": "mixer_1", "amount": 17224.22, "timestamp": "2025-12-07T22:06:00Z", "pattern": "mixer", "risk_transfer": 0.903} +{"tx_id": "BTX_0000025", "source": "wallet_1245", "target": "mixer_1", "amount": 36510.3, "timestamp": "2025-10-31T17:10:00Z", "pattern": "mixer", "risk_transfer": 0.958} +{"tx_id": "BTX_0000026", "source": "wallet_2749", "target": "entity_362", "amount": 122495.48, "timestamp": "2025-11-07T00:34:00Z", "pattern": "layering", "risk_transfer": 0.813} +{"tx_id": "BTX_0000027", "source": "entity_184", "target": "entity_170", "amount": 25186.48, "timestamp": "2025-11-07T22:33:00Z", "pattern": "loop", "risk_transfer": 0.898} +{"tx_id": "BTX_0000028", "source": "entity_227", "target": "merchant_35", "amount": 113412.15, "timestamp": "2026-01-06T09:09:00Z", "pattern": "cashout", "risk_transfer": 0.77} +{"tx_id": "BTX_0000029", "source": "entity_285", "target": "merchant_42", "amount": 56326.37, "timestamp": "2025-11-09T18:40:00Z", "pattern": "cashout", "risk_transfer": 0.561} +{"tx_id": "BTX_0000030", "source": "entity_128", "target": "entity_128", "amount": 3644.62, "timestamp": "2025-11-03T03:52:00Z", "pattern": "loop", "risk_transfer": 0.384} +{"tx_id": "BTX_0000031", "source": "entity_100", "target": "entity_100", "amount": 94849.89, "timestamp": "2026-01-01T07:44:00Z", "pattern": "loop", "risk_transfer": 0.254} +{"tx_id": "BTX_0000032", "source": "wallet_2880", "target": "entity_342", "amount": 11093.17, "timestamp": "2025-12-30T16:08:00Z", "pattern": "layering", "risk_transfer": 0.423} +{"tx_id": "BTX_0000033", "source": "entity_227", "target": "merchant_35", "amount": 20280.62, "timestamp": "2025-10-31T01:04:00Z", "pattern": "cashout", "risk_transfer": 0.661} +{"tx_id": "BTX_0000034", "source": "entity_160", "target": "entity_124", "amount": 14601.71, "timestamp": "2025-10-15T00:51:00Z", "pattern": "loop", "risk_transfer": 0.545} +{"tx_id": "BTX_0000035", "source": "entity_252", "target": "merchant_30", "amount": 129643.59, "timestamp": "2026-02-02T03:19:00Z", "pattern": "cashout", "risk_transfer": 0.739} +{"tx_id": "BTX_0000036", "source": "wallet_2062", "target": "entity_351", "amount": 109367.93, "timestamp": "2026-01-23T02:35:00Z", "pattern": "layering", "risk_transfer": 0.399} +{"tx_id": "BTX_0000037", "source": "entity_168", "target": "entity_154", "amount": 27931.46, "timestamp": "2025-11-19T18:44:00Z", "pattern": "loop", "risk_transfer": 0.4} +{"tx_id": "BTX_0000038", "source": "wallet_2453", "target": "entity_370", "amount": 15137.35, "timestamp": "2025-10-16T06:44:00Z", "pattern": "layering", "risk_transfer": 0.999} +{"tx_id": "BTX_0000039", "source": "wallet_2992", "target": "entity_311", "amount": 138991.86, "timestamp": "2025-12-30T23:14:00Z", "pattern": "layering", "risk_transfer": 0.333} +{"tx_id": "BTX_0000040", "source": "entity_261", "target": "merchant_14", "amount": 129765.23, "timestamp": "2026-02-01T07:49:00Z", "pattern": "cashout", "risk_transfer": 0.332} +{"tx_id": "BTX_0000041", "source": "wallet_2399", "target": "entity_333", "amount": 139014.45, "timestamp": "2025-11-21T04:02:00Z", "pattern": "layering", "risk_transfer": 0.428} +{"tx_id": "BTX_0000042", "source": "entity_219", "target": "merchant_13", "amount": 44857.82, "timestamp": "2026-02-01T08:28:00Z", "pattern": "cashout", "risk_transfer": 0.663} +{"tx_id": "BTX_0000043", "source": "wallet_2765", "target": "entity_340", "amount": 9046.21, "timestamp": "2025-10-28T15:41:00Z", "pattern": "layering", "risk_transfer": 0.581} +{"tx_id": "BTX_0000044", "source": "entity_107", "target": "entity_110", "amount": 127775.71, "timestamp": "2026-01-30T12:37:00Z", "pattern": "loop", "risk_transfer": 0.676} +{"tx_id": "BTX_0000045", "source": "entity_151", "target": "entity_151", "amount": 133597.95, "timestamp": "2025-12-29T04:16:00Z", "pattern": "loop", "risk_transfer": 0.663} +{"tx_id": "BTX_0000046", "source": "wallet_2634", "target": "entity_310", "amount": 63174.12, "timestamp": "2025-10-28T17:53:00Z", "pattern": "layering", "risk_transfer": 0.652} +{"tx_id": "BTX_0000047", "source": "wallet_1957", "target": "mixer_5", "amount": 31036.75, "timestamp": "2025-12-16T19:18:00Z", "pattern": "mixer", "risk_transfer": 0.391} +{"tx_id": "BTX_0000048", "source": "entity_216", "target": "merchant_43", "amount": 97002.37, "timestamp": "2025-11-20T18:21:00Z", "pattern": "cashout", "risk_transfer": 0.453} +{"tx_id": "BTX_0000049", "source": "wallet_2009", "target": "entity_358", "amount": 93363.31, "timestamp": "2025-11-01T12:14:00Z", "pattern": "layering", "risk_transfer": 0.997} +{"tx_id": "BTX_0000050", "source": "wallet_2550", "target": "entity_327", "amount": 76129.76, "timestamp": "2026-01-18T21:17:00Z", "pattern": "layering", "risk_transfer": 0.947} +{"tx_id": "BTX_0000051", "source": "wallet_2900", "target": "entity_331", "amount": 55744.3, "timestamp": "2026-01-14T06:47:00Z", "pattern": "layering", "risk_transfer": 0.551} +{"tx_id": "BTX_0000052", "source": "wallet_1626", "target": "mixer_9", "amount": 1669.55, "timestamp": "2025-11-03T00:54:00Z", "pattern": "mixer", "risk_transfer": 0.44} +{"tx_id": "BTX_0000053", "source": "wallet_2961", "target": "entity_317", "amount": 40037.66, "timestamp": "2026-01-23T12:21:00Z", "pattern": "layering", "risk_transfer": 0.794} +{"tx_id": "BTX_0000054", "source": "entity_134", "target": "entity_126", "amount": 107783.02, "timestamp": "2026-01-05T22:28:00Z", "pattern": "loop", "risk_transfer": 0.75} +{"tx_id": "BTX_0000055", "source": "wallet_1517", "target": "mixer_8", "amount": 38042.63, "timestamp": "2026-02-02T18:03:00Z", "pattern": "mixer", "risk_transfer": 0.274} +{"tx_id": "BTX_0000056", "source": "entity_235", "target": "merchant_3", "amount": 1030.08, "timestamp": "2026-01-19T04:26:00Z", "pattern": "cashout", "risk_transfer": 0.71} +{"tx_id": "BTX_0000057", "source": "wallet_1165", "target": "mixer_8", "amount": 82976.22, "timestamp": "2025-11-26T03:24:00Z", "pattern": "mixer", "risk_transfer": 0.649} +{"tx_id": "BTX_0000058", "source": "wallet_2077", "target": "entity_388", "amount": 135637.32, "timestamp": "2025-11-04T16:16:00Z", "pattern": "layering", "risk_transfer": 0.229} +{"tx_id": "BTX_0000059", "source": "wallet_1596", "target": "mixer_9", "amount": 22640.02, "timestamp": "2026-01-19T19:10:00Z", "pattern": "mixer", "risk_transfer": 0.233} +{"tx_id": "BTX_0000060", "source": "wallet_1920", "target": "mixer_1", "amount": 134889.23, "timestamp": "2026-01-04T18:09:00Z", "pattern": "mixer", "risk_transfer": 0.746} +{"tx_id": "BTX_0000061", "source": "wallet_2362", "target": "entity_399", "amount": 84202.59, "timestamp": "2025-11-30T00:30:00Z", "pattern": "layering", "risk_transfer": 0.979} +{"tx_id": "BTX_0000062", "source": "entity_130", "target": "entity_122", "amount": 132271.72, "timestamp": "2026-02-07T11:42:00Z", "pattern": "loop", "risk_transfer": 0.343} +{"tx_id": "BTX_0000063", "source": "wallet_1801", "target": "mixer_7", "amount": 120434.15, "timestamp": "2025-12-28T19:44:00Z", "pattern": "mixer", "risk_transfer": 0.413} +{"tx_id": "BTX_0000064", "source": "wallet_2391", "target": "entity_304", "amount": 128859.69, "timestamp": "2026-01-02T12:10:00Z", "pattern": "layering", "risk_transfer": 0.36} +{"tx_id": "BTX_0000065", "source": "entity_244", "target": "merchant_20", "amount": 123170.19, "timestamp": "2026-01-01T13:37:00Z", "pattern": "cashout", "risk_transfer": 0.378} +{"tx_id": "BTX_0000066", "source": "entity_151", "target": "entity_108", "amount": 145049.92, "timestamp": "2025-12-23T04:29:00Z", "pattern": "loop", "risk_transfer": 0.481} +{"tx_id": "BTX_0000067", "source": "entity_286", "target": "merchant_35", "amount": 50006.91, "timestamp": "2026-02-06T23:24:00Z", "pattern": "cashout", "risk_transfer": 0.292} +{"tx_id": "BTX_0000068", "source": "wallet_1182", "target": "mixer_5", "amount": 6219.45, "timestamp": "2025-10-26T09:32:00Z", "pattern": "mixer", "risk_transfer": 0.548} +{"tx_id": "BTX_0000069", "source": "wallet_1446", "target": "mixer_9", "amount": 17789.04, "timestamp": "2025-10-30T00:50:00Z", "pattern": "mixer", "risk_transfer": 0.352} +{"tx_id": "BTX_0000070", "source": "wallet_2725", "target": "entity_355", "amount": 752.85, "timestamp": "2025-11-05T23:28:00Z", "pattern": "layering", "risk_transfer": 0.749} +{"tx_id": "BTX_0000071", "source": "entity_146", "target": "entity_185", "amount": 138155.24, "timestamp": "2025-10-21T13:23:00Z", "pattern": "loop", "risk_transfer": 0.451} +{"tx_id": "BTX_0000072", "source": "wallet_2737", "target": "entity_338", "amount": 76314.81, "timestamp": "2025-11-29T15:42:00Z", "pattern": "layering", "risk_transfer": 0.461} +{"tx_id": "BTX_0000073", "source": "wallet_1567", "target": "mixer_3", "amount": 29180.09, "timestamp": "2025-12-04T23:29:00Z", "pattern": "mixer", "risk_transfer": 0.742} +{"tx_id": "BTX_0000074", "source": "entity_178", "target": "entity_151", "amount": 82416.61, "timestamp": "2026-02-11T22:13:00Z", "pattern": "loop", "risk_transfer": 0.443} +{"tx_id": "BTX_0000075", "source": "entity_155", "target": "entity_177", "amount": 98378.49, "timestamp": "2025-11-19T08:26:00Z", "pattern": "loop", "risk_transfer": 0.553} +{"tx_id": "BTX_0000076", "source": "entity_165", "target": "entity_194", "amount": 25868.86, "timestamp": "2026-01-27T13:31:00Z", "pattern": "loop", "risk_transfer": 0.427} +{"tx_id": "BTX_0000077", "source": "wallet_1095", "target": "mixer_4", "amount": 101082.95, "timestamp": "2026-01-02T02:31:00Z", "pattern": "mixer", "risk_transfer": 0.845} +{"tx_id": "BTX_0000078", "source": "entity_103", "target": "entity_103", "amount": 147598.44, "timestamp": "2025-10-23T17:18:00Z", "pattern": "loop", "risk_transfer": 0.88} +{"tx_id": "BTX_0000079", "source": "wallet_2466", "target": "entity_353", "amount": 132977.47, "timestamp": "2025-10-30T04:51:00Z", "pattern": "layering", "risk_transfer": 0.356} +{"tx_id": "BTX_0000080", "source": "entity_263", "target": "merchant_26", "amount": 36975.67, "timestamp": "2025-10-15T13:38:00Z", "pattern": "cashout", "risk_transfer": 0.75} +{"tx_id": "BTX_0000081", "source": "wallet_2797", "target": "entity_354", "amount": 33216.75, "timestamp": "2025-11-09T17:00:00Z", "pattern": "layering", "risk_transfer": 0.572} +{"tx_id": "BTX_0000082", "source": "entity_115", "target": "entity_159", "amount": 100302.87, "timestamp": "2025-11-02T06:01:00Z", "pattern": "loop", "risk_transfer": 0.676} +{"tx_id": "BTX_0000083", "source": "entity_278", "target": "merchant_47", "amount": 133857.03, "timestamp": "2025-11-26T07:32:00Z", "pattern": "cashout", "risk_transfer": 0.864} +{"tx_id": "BTX_0000084", "source": "entity_220", "target": "merchant_48", "amount": 129293.49, "timestamp": "2025-11-22T01:36:00Z", "pattern": "cashout", "risk_transfer": 0.407} +{"tx_id": "BTX_0000085", "source": "entity_181", "target": "entity_199", "amount": 78434.32, "timestamp": "2025-10-20T21:41:00Z", "pattern": "loop", "risk_transfer": 0.391} +{"tx_id": "BTX_0000086", "source": "entity_209", "target": "merchant_46", "amount": 43216.36, "timestamp": "2025-12-24T12:51:00Z", "pattern": "cashout", "risk_transfer": 0.469} +{"tx_id": "BTX_0000087", "source": "wallet_2141", "target": "entity_319", "amount": 35073.59, "timestamp": "2026-01-15T04:23:00Z", "pattern": "layering", "risk_transfer": 0.765} +{"tx_id": "BTX_0000088", "source": "wallet_2424", "target": "entity_352", "amount": 49966.77, "timestamp": "2025-11-19T04:20:00Z", "pattern": "layering", "risk_transfer": 0.533} +{"tx_id": "BTX_0000089", "source": "entity_153", "target": "entity_198", "amount": 87819.87, "timestamp": "2026-02-08T10:39:00Z", "pattern": "loop", "risk_transfer": 0.885} +{"tx_id": "BTX_0000090", "source": "entity_261", "target": "merchant_1", "amount": 141439.88, "timestamp": "2025-12-19T15:20:00Z", "pattern": "cashout", "risk_transfer": 0.803} +{"tx_id": "BTX_0000091", "source": "entity_268", "target": "merchant_48", "amount": 110327.79, "timestamp": "2025-10-25T04:20:00Z", "pattern": "cashout", "risk_transfer": 0.918} +{"tx_id": "BTX_0000092", "source": "entity_228", "target": "merchant_18", "amount": 65657.48, "timestamp": "2026-02-06T17:11:00Z", "pattern": "cashout", "risk_transfer": 0.511} +{"tx_id": "BTX_0000093", "source": "entity_292", "target": "merchant_11", "amount": 126155.6, "timestamp": "2026-01-19T18:23:00Z", "pattern": "cashout", "risk_transfer": 0.985} +{"tx_id": "BTX_0000094", "source": "wallet_2928", "target": "entity_350", "amount": 88987.97, "timestamp": "2026-02-07T01:35:00Z", "pattern": "layering", "risk_transfer": 0.267} +{"tx_id": "BTX_0000095", "source": "entity_217", "target": "merchant_30", "amount": 27668.49, "timestamp": "2025-12-26T15:21:00Z", "pattern": "cashout", "risk_transfer": 0.503} +{"tx_id": "BTX_0000096", "source": "entity_158", "target": "entity_197", "amount": 132006.18, "timestamp": "2025-12-23T08:16:00Z", "pattern": "loop", "risk_transfer": 0.802} +{"tx_id": "BTX_0000097", "source": "entity_232", "target": "merchant_6", "amount": 70810.87, "timestamp": "2025-11-05T19:14:00Z", "pattern": "cashout", "risk_transfer": 0.242} +{"tx_id": "BTX_0000098", "source": "wallet_1229", "target": "mixer_2", "amount": 117294.34, "timestamp": "2025-10-16T08:26:00Z", "pattern": "mixer", "risk_transfer": 0.232} +{"tx_id": "BTX_0000099", "source": "wallet_2972", "target": "entity_331", "amount": 30304.42, "timestamp": "2026-02-08T06:57:00Z", "pattern": "layering", "risk_transfer": 0.697} +{"tx_id": "BTX_0000100", "source": "entity_116", "target": "entity_114", "amount": 84813.45, "timestamp": "2026-01-03T07:41:00Z", "pattern": "loop", "risk_transfer": 0.572} +{"tx_id": "BTX_0000101", "source": "wallet_1785", "target": "mixer_6", "amount": 25584.48, "timestamp": "2025-10-24T10:53:00Z", "pattern": "mixer", "risk_transfer": 0.971} +{"tx_id": "BTX_0000102", "source": "wallet_2796", "target": "entity_320", "amount": 144725.76, "timestamp": "2026-01-23T07:42:00Z", "pattern": "layering", "risk_transfer": 0.663} +{"tx_id": "BTX_0000103", "source": "wallet_1589", "target": "mixer_7", "amount": 59799.84, "timestamp": "2026-01-06T21:29:00Z", "pattern": "mixer", "risk_transfer": 0.261} +{"tx_id": "BTX_0000104", "source": "entity_113", "target": "entity_138", "amount": 127618.84, "timestamp": "2025-10-25T16:45:00Z", "pattern": "loop", "risk_transfer": 0.844} +{"tx_id": "BTX_0000105", "source": "wallet_2355", "target": "entity_368", "amount": 64542.51, "timestamp": "2025-12-06T12:57:00Z", "pattern": "layering", "risk_transfer": 0.255} +{"tx_id": "BTX_0000106", "source": "wallet_1012", "target": "mixer_7", "amount": 123438.47, "timestamp": "2026-01-23T18:53:00Z", "pattern": "mixer", "risk_transfer": 0.547} +{"tx_id": "BTX_0000107", "source": "wallet_1650", "target": "mixer_8", "amount": 106242.64, "timestamp": "2025-11-24T17:19:00Z", "pattern": "mixer", "risk_transfer": 0.341} +{"tx_id": "BTX_0000108", "source": "wallet_1630", "target": "mixer_9", "amount": 116308.9, "timestamp": "2025-11-19T08:56:00Z", "pattern": "mixer", "risk_transfer": 0.548} +{"tx_id": "BTX_0000109", "source": "wallet_1330", "target": "mixer_4", "amount": 124694.29, "timestamp": "2026-01-27T05:21:00Z", "pattern": "mixer", "risk_transfer": 0.423} +{"tx_id": "BTX_0000110", "source": "entity_231", "target": "merchant_49", "amount": 69973.92, "timestamp": "2025-10-23T21:25:00Z", "pattern": "cashout", "risk_transfer": 0.735} +{"tx_id": "BTX_0000111", "source": "wallet_1029", "target": "mixer_8", "amount": 127717.45, "timestamp": "2026-01-09T21:31:00Z", "pattern": "mixer", "risk_transfer": 0.59} +{"tx_id": "BTX_0000112", "source": "wallet_1816", "target": "mixer_5", "amount": 51380.66, "timestamp": "2025-10-26T11:21:00Z", "pattern": "mixer", "risk_transfer": 0.761} +{"tx_id": "BTX_0000113", "source": "wallet_1569", "target": "mixer_1", "amount": 77735.77, "timestamp": "2026-01-08T05:15:00Z", "pattern": "mixer", "risk_transfer": 0.268} +{"tx_id": "BTX_0000114", "source": "entity_262", "target": "merchant_36", "amount": 113845.62, "timestamp": "2025-11-17T07:55:00Z", "pattern": "cashout", "risk_transfer": 0.717} +{"tx_id": "BTX_0000115", "source": "entity_257", "target": "merchant_2", "amount": 14411.48, "timestamp": "2026-01-02T15:49:00Z", "pattern": "cashout", "risk_transfer": 0.524} +{"tx_id": "BTX_0000116", "source": "entity_139", "target": "entity_147", "amount": 71252.44, "timestamp": "2025-11-07T08:22:00Z", "pattern": "loop", "risk_transfer": 0.475} +{"tx_id": "BTX_0000117", "source": "wallet_1360", "target": "mixer_8", "amount": 41002.01, "timestamp": "2025-12-28T05:36:00Z", "pattern": "mixer", "risk_transfer": 0.384} +{"tx_id": "BTX_0000118", "source": "entity_140", "target": "entity_140", "amount": 80611.66, "timestamp": "2026-01-09T07:04:00Z", "pattern": "loop", "risk_transfer": 0.353} +{"tx_id": "BTX_0000119", "source": "entity_235", "target": "merchant_47", "amount": 88643.24, "timestamp": "2025-11-08T11:49:00Z", "pattern": "cashout", "risk_transfer": 0.677} +{"tx_id": "BTX_0000120", "source": "wallet_2852", "target": "entity_324", "amount": 44788.85, "timestamp": "2025-12-08T07:17:00Z", "pattern": "layering", "risk_transfer": 0.344} +{"tx_id": "BTX_0000121", "source": "wallet_2725", "target": "entity_368", "amount": 19424.15, "timestamp": "2026-02-03T17:08:00Z", "pattern": "layering", "risk_transfer": 0.979} +{"tx_id": "BTX_0000122", "source": "wallet_1714", "target": "mixer_3", "amount": 95861.51, "timestamp": "2025-11-14T15:19:00Z", "pattern": "mixer", "risk_transfer": 0.282} +{"tx_id": "BTX_0000123", "source": "wallet_2587", "target": "entity_336", "amount": 70674.84, "timestamp": "2025-11-23T19:33:00Z", "pattern": "layering", "risk_transfer": 0.473} +{"tx_id": "BTX_0000124", "source": "wallet_2258", "target": "entity_361", "amount": 17556.03, "timestamp": "2026-01-31T02:30:00Z", "pattern": "layering", "risk_transfer": 0.521} +{"tx_id": "BTX_0000125", "source": "wallet_2590", "target": "entity_380", "amount": 103124.43, "timestamp": "2026-01-15T09:05:00Z", "pattern": "layering", "risk_transfer": 0.319} +{"tx_id": "BTX_0000126", "source": "wallet_1087", "target": "mixer_4", "amount": 18208.51, "timestamp": "2025-11-28T05:42:00Z", "pattern": "mixer", "risk_transfer": 0.685} +{"tx_id": "BTX_0000127", "source": "entity_199", "target": "entity_157", "amount": 136321.4, "timestamp": "2025-12-19T20:50:00Z", "pattern": "loop", "risk_transfer": 0.888} +{"tx_id": "BTX_0000128", "source": "entity_239", "target": "merchant_37", "amount": 93343.58, "timestamp": "2025-10-24T00:28:00Z", "pattern": "cashout", "risk_transfer": 0.968} +{"tx_id": "BTX_0000129", "source": "wallet_2970", "target": "entity_397", "amount": 31564.29, "timestamp": "2026-01-04T14:01:00Z", "pattern": "layering", "risk_transfer": 0.412} +{"tx_id": "BTX_0000130", "source": "wallet_2160", "target": "entity_330", "amount": 26485.13, "timestamp": "2026-01-29T08:02:00Z", "pattern": "layering", "risk_transfer": 0.325} +{"tx_id": "BTX_0000131", "source": "entity_257", "target": "merchant_45", "amount": 89274.77, "timestamp": "2025-12-20T23:28:00Z", "pattern": "cashout", "risk_transfer": 0.226} +{"tx_id": "BTX_0000132", "source": "wallet_1723", "target": "mixer_5", "amount": 105596.64, "timestamp": "2025-11-21T08:18:00Z", "pattern": "mixer", "risk_transfer": 0.257} +{"tx_id": "BTX_0000133", "source": "entity_133", "target": "entity_180", "amount": 88673.54, "timestamp": "2026-01-06T23:42:00Z", "pattern": "loop", "risk_transfer": 0.54} +{"tx_id": "BTX_0000134", "source": "entity_182", "target": "entity_182", "amount": 40212.05, "timestamp": "2026-01-17T02:33:00Z", "pattern": "loop", "risk_transfer": 0.257} +{"tx_id": "BTX_0000135", "source": "entity_139", "target": "entity_172", "amount": 138218.28, "timestamp": "2025-11-24T01:18:00Z", "pattern": "loop", "risk_transfer": 0.299} +{"tx_id": "BTX_0000136", "source": "wallet_1716", "target": "mixer_7", "amount": 141430.1, "timestamp": "2025-11-12T21:24:00Z", "pattern": "mixer", "risk_transfer": 0.632} +{"tx_id": "BTX_0000137", "source": "entity_210", "target": "merchant_39", "amount": 6459.19, "timestamp": "2025-11-25T08:26:00Z", "pattern": "cashout", "risk_transfer": 0.788} +{"tx_id": "BTX_0000138", "source": "wallet_1026", "target": "mixer_2", "amount": 34724.2, "timestamp": "2025-10-30T06:55:00Z", "pattern": "mixer", "risk_transfer": 0.67} +{"tx_id": "BTX_0000139", "source": "wallet_2783", "target": "entity_386", "amount": 123302.48, "timestamp": "2025-10-30T02:20:00Z", "pattern": "layering", "risk_transfer": 0.232} +{"tx_id": "BTX_0000140", "source": "entity_160", "target": "entity_156", "amount": 137452.88, "timestamp": "2026-01-09T23:03:00Z", "pattern": "loop", "risk_transfer": 0.998} +{"tx_id": "BTX_0000141", "source": "entity_281", "target": "merchant_32", "amount": 145373.76, "timestamp": "2025-11-18T10:29:00Z", "pattern": "cashout", "risk_transfer": 0.478} +{"tx_id": "BTX_0000142", "source": "wallet_1328", "target": "mixer_2", "amount": 128731.1, "timestamp": "2025-12-13T23:03:00Z", "pattern": "mixer", "risk_transfer": 0.529} +{"tx_id": "BTX_0000143", "source": "entity_236", "target": "merchant_43", "amount": 141806.43, "timestamp": "2025-11-03T20:35:00Z", "pattern": "cashout", "risk_transfer": 0.229} +{"tx_id": "BTX_0000144", "source": "wallet_2322", "target": "entity_332", "amount": 48829.43, "timestamp": "2025-11-30T10:07:00Z", "pattern": "layering", "risk_transfer": 0.892} +{"tx_id": "BTX_0000145", "source": "wallet_2673", "target": "entity_369", "amount": 69565.46, "timestamp": "2026-02-02T03:10:00Z", "pattern": "layering", "risk_transfer": 0.35} +{"tx_id": "BTX_0000146", "source": "wallet_1637", "target": "mixer_8", "amount": 93998.61, "timestamp": "2026-02-02T14:30:00Z", "pattern": "mixer", "risk_transfer": 0.363} +{"tx_id": "BTX_0000147", "source": "entity_136", "target": "entity_189", "amount": 72964.41, "timestamp": "2026-02-06T17:52:00Z", "pattern": "loop", "risk_transfer": 0.979} +{"tx_id": "BTX_0000148", "source": "entity_190", "target": "entity_190", "amount": 82850.43, "timestamp": "2025-11-03T11:10:00Z", "pattern": "loop", "risk_transfer": 0.526} +{"tx_id": "BTX_0000149", "source": "entity_114", "target": "entity_115", "amount": 97332.6, "timestamp": "2026-01-14T23:17:00Z", "pattern": "loop", "risk_transfer": 0.599} +{"tx_id": "BTX_0000150", "source": "wallet_1521", "target": "mixer_5", "amount": 62615.81, "timestamp": "2025-11-16T03:55:00Z", "pattern": "mixer", "risk_transfer": 0.984} +{"tx_id": "BTX_0000151", "source": "entity_158", "target": "entity_149", "amount": 28996.25, "timestamp": "2025-10-25T21:16:00Z", "pattern": "loop", "risk_transfer": 0.607} +{"tx_id": "BTX_0000152", "source": "entity_108", "target": "entity_153", "amount": 51311.35, "timestamp": "2025-11-11T13:39:00Z", "pattern": "loop", "risk_transfer": 0.414} +{"tx_id": "BTX_0000153", "source": "wallet_2289", "target": "entity_392", "amount": 45134.1, "timestamp": "2025-10-28T03:37:00Z", "pattern": "layering", "risk_transfer": 0.664} +{"tx_id": "BTX_0000154", "source": "entity_219", "target": "merchant_29", "amount": 81017.8, "timestamp": "2025-12-11T04:07:00Z", "pattern": "cashout", "risk_transfer": 0.466} +{"tx_id": "BTX_0000155", "source": "entity_258", "target": "merchant_21", "amount": 130505.45, "timestamp": "2025-12-30T12:38:00Z", "pattern": "cashout", "risk_transfer": 0.657} +{"tx_id": "BTX_0000156", "source": "entity_199", "target": "entity_140", "amount": 111833.42, "timestamp": "2025-12-04T14:22:00Z", "pattern": "loop", "risk_transfer": 0.509} +{"tx_id": "BTX_0000157", "source": "entity_163", "target": "entity_116", "amount": 75592.53, "timestamp": "2025-10-27T13:27:00Z", "pattern": "loop", "risk_transfer": 0.466} +{"tx_id": "BTX_0000158", "source": "wallet_2895", "target": "entity_356", "amount": 15406.9, "timestamp": "2025-11-20T19:39:00Z", "pattern": "layering", "risk_transfer": 0.212} +{"tx_id": "BTX_0000159", "source": "entity_152", "target": "entity_119", "amount": 11686.82, "timestamp": "2025-12-25T18:05:00Z", "pattern": "loop", "risk_transfer": 0.471} +{"tx_id": "BTX_0000160", "source": "entity_283", "target": "merchant_6", "amount": 127838.91, "timestamp": "2025-11-06T20:34:00Z", "pattern": "cashout", "risk_transfer": 0.504} +{"tx_id": "BTX_0000161", "source": "wallet_1641", "target": "mixer_8", "amount": 130921.26, "timestamp": "2026-02-05T11:09:00Z", "pattern": "mixer", "risk_transfer": 0.694} +{"tx_id": "BTX_0000162", "source": "entity_180", "target": "entity_136", "amount": 149615.18, "timestamp": "2026-01-26T13:07:00Z", "pattern": "loop", "risk_transfer": 0.547} +{"tx_id": "BTX_0000163", "source": "wallet_2778", "target": "entity_381", "amount": 105731.68, "timestamp": "2026-01-24T16:52:00Z", "pattern": "layering", "risk_transfer": 0.555} +{"tx_id": "BTX_0000164", "source": "wallet_1925", "target": "mixer_1", "amount": 7375.65, "timestamp": "2026-02-01T18:48:00Z", "pattern": "mixer", "risk_transfer": 0.435} +{"tx_id": "BTX_0000165", "source": "wallet_1441", "target": "mixer_3", "amount": 37007.52, "timestamp": "2025-11-28T23:43:00Z", "pattern": "mixer", "risk_transfer": 0.653} +{"tx_id": "BTX_0000166", "source": "entity_121", "target": "entity_121", "amount": 91619.02, "timestamp": "2025-12-04T08:38:00Z", "pattern": "loop", "risk_transfer": 0.696} +{"tx_id": "BTX_0000167", "source": "entity_163", "target": "entity_118", "amount": 35212.17, "timestamp": "2025-10-18T20:57:00Z", "pattern": "loop", "risk_transfer": 0.403} +{"tx_id": "BTX_0000168", "source": "wallet_1682", "target": "mixer_1", "amount": 134886.83, "timestamp": "2025-11-19T07:16:00Z", "pattern": "mixer", "risk_transfer": 0.921} +{"tx_id": "BTX_0000169", "source": "entity_109", "target": "entity_144", "amount": 148153.93, "timestamp": "2025-12-19T12:58:00Z", "pattern": "loop", "risk_transfer": 0.711} +{"tx_id": "BTX_0000170", "source": "entity_288", "target": "merchant_17", "amount": 68796.95, "timestamp": "2025-12-19T00:00:00Z", "pattern": "cashout", "risk_transfer": 0.359} +{"tx_id": "BTX_0000171", "source": "entity_261", "target": "merchant_7", "amount": 35962.25, "timestamp": "2025-10-30T21:13:00Z", "pattern": "cashout", "risk_transfer": 0.487} +{"tx_id": "BTX_0000172", "source": "wallet_1716", "target": "mixer_5", "amount": 3779.97, "timestamp": "2025-10-15T04:09:00Z", "pattern": "mixer", "risk_transfer": 0.517} +{"tx_id": "BTX_0000173", "source": "wallet_2579", "target": "entity_387", "amount": 116857.59, "timestamp": "2026-02-03T01:43:00Z", "pattern": "layering", "risk_transfer": 0.928} +{"tx_id": "BTX_0000174", "source": "entity_236", "target": "merchant_50", "amount": 119929.59, "timestamp": "2025-10-24T11:41:00Z", "pattern": "cashout", "risk_transfer": 0.842} +{"tx_id": "BTX_0000175", "source": "entity_181", "target": "entity_181", "amount": 37968.71, "timestamp": "2026-01-18T02:24:00Z", "pattern": "loop", "risk_transfer": 0.703} +{"tx_id": "BTX_0000176", "source": "wallet_2316", "target": "entity_356", "amount": 5488.0, "timestamp": "2025-12-07T13:53:00Z", "pattern": "layering", "risk_transfer": 0.786} +{"tx_id": "BTX_0000177", "source": "wallet_2931", "target": "entity_337", "amount": 49344.51, "timestamp": "2025-11-28T08:38:00Z", "pattern": "layering", "risk_transfer": 0.341} +{"tx_id": "BTX_0000178", "source": "entity_169", "target": "entity_146", "amount": 79862.43, "timestamp": "2025-12-24T09:18:00Z", "pattern": "loop", "risk_transfer": 0.864} +{"tx_id": "BTX_0000179", "source": "wallet_1935", "target": "mixer_8", "amount": 145123.92, "timestamp": "2025-12-20T06:30:00Z", "pattern": "mixer", "risk_transfer": 0.797} +{"tx_id": "BTX_0000180", "source": "wallet_1823", "target": "mixer_2", "amount": 70515.54, "timestamp": "2026-01-29T07:00:00Z", "pattern": "mixer", "risk_transfer": 0.313} +{"tx_id": "BTX_0000181", "source": "entity_186", "target": "entity_150", "amount": 145106.83, "timestamp": "2025-11-02T13:03:00Z", "pattern": "loop", "risk_transfer": 0.493} +{"tx_id": "BTX_0000182", "source": "entity_201", "target": "merchant_17", "amount": 80717.56, "timestamp": "2025-11-21T05:00:00Z", "pattern": "cashout", "risk_transfer": 0.495} +{"tx_id": "BTX_0000183", "source": "wallet_1598", "target": "mixer_7", "amount": 123451.39, "timestamp": "2025-12-06T09:00:00Z", "pattern": "mixer", "risk_transfer": 0.287} +{"tx_id": "BTX_0000184", "source": "entity_160", "target": "entity_160", "amount": 132693.11, "timestamp": "2025-11-01T19:10:00Z", "pattern": "loop", "risk_transfer": 0.462} +{"tx_id": "BTX_0000185", "source": "entity_182", "target": "entity_182", "amount": 123666.38, "timestamp": "2025-12-18T23:42:00Z", "pattern": "loop", "risk_transfer": 0.719} +{"tx_id": "BTX_0000186", "source": "wallet_2143", "target": "entity_305", "amount": 141966.1, "timestamp": "2025-12-18T14:12:00Z", "pattern": "layering", "risk_transfer": 0.997} +{"tx_id": "BTX_0000187", "source": "wallet_2099", "target": "entity_330", "amount": 133145.27, "timestamp": "2026-01-18T07:16:00Z", "pattern": "layering", "risk_transfer": 0.511} +{"tx_id": "BTX_0000188", "source": "wallet_1686", "target": "mixer_9", "amount": 63159.45, "timestamp": "2026-01-14T20:52:00Z", "pattern": "mixer", "risk_transfer": 0.908} +{"tx_id": "BTX_0000189", "source": "wallet_2853", "target": "entity_362", "amount": 92540.01, "timestamp": "2025-12-23T01:47:00Z", "pattern": "layering", "risk_transfer": 0.226} +{"tx_id": "BTX_0000190", "source": "wallet_1222", "target": "mixer_8", "amount": 66982.03, "timestamp": "2025-12-31T00:24:00Z", "pattern": "mixer", "risk_transfer": 0.884} +{"tx_id": "BTX_0000191", "source": "wallet_2702", "target": "entity_347", "amount": 81897.65, "timestamp": "2025-10-17T14:42:00Z", "pattern": "layering", "risk_transfer": 0.487} +{"tx_id": "BTX_0000192", "source": "entity_235", "target": "merchant_13", "amount": 145246.93, "timestamp": "2025-11-21T05:18:00Z", "pattern": "cashout", "risk_transfer": 0.273} +{"tx_id": "BTX_0000193", "source": "entity_182", "target": "entity_102", "amount": 8061.49, "timestamp": "2025-12-13T06:52:00Z", "pattern": "loop", "risk_transfer": 0.395} +{"tx_id": "BTX_0000194", "source": "entity_172", "target": "entity_197", "amount": 83354.06, "timestamp": "2025-10-28T06:03:00Z", "pattern": "loop", "risk_transfer": 0.373} +{"tx_id": "BTX_0000195", "source": "entity_142", "target": "entity_176", "amount": 923.91, "timestamp": "2026-01-16T15:50:00Z", "pattern": "loop", "risk_transfer": 0.99} +{"tx_id": "BTX_0000196", "source": "wallet_1817", "target": "mixer_3", "amount": 16933.99, "timestamp": "2026-02-07T07:21:00Z", "pattern": "mixer", "risk_transfer": 0.305} +{"tx_id": "BTX_0000197", "source": "wallet_1808", "target": "mixer_4", "amount": 88533.51, "timestamp": "2026-02-09T03:03:00Z", "pattern": "mixer", "risk_transfer": 0.339} +{"tx_id": "BTX_0000198", "source": "wallet_2129", "target": "entity_394", "amount": 63433.6, "timestamp": "2026-01-22T07:27:00Z", "pattern": "layering", "risk_transfer": 0.797} +{"tx_id": "BTX_0000199", "source": "entity_257", "target": "merchant_50", "amount": 54624.83, "timestamp": "2025-10-26T22:14:00Z", "pattern": "cashout", "risk_transfer": 0.287} diff --git a/data/raw/.gitkeep b/data/raw/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/ALGORITHM.md b/docs/ALGORITHM.md new file mode 100644 index 0000000..0c56b6f --- /dev/null +++ b/docs/ALGORITHM.md @@ -0,0 +1,40 @@ +# Risk Propagation Algorithm (BridgeTrace-AI) + +## 1. Formula +For an edge `u -> v` with transfer weight `w(u,v)` and global decay `d`, the propagated risk at hop `k+1` is: + +`R(v, k+1) = max(R(v, k+1), R(u, k) * w(u,v) * d * T)` + +Where: +- `R(node, k)` = risk reaching `node` at hop `k` +- `w(u,v)` = `risk_transfer` edge weight in `[0,1]` +- `d` = base decay factor +- `T` = temporal decay factor in `[0,1]` + +## 2. Variables +- `seed_scores`: dictionary of initial high-risk entities +- `max_hops`: traversal depth limit +- `min_signal`: minimum propagated signal accepted +- `adaptive_threshold`: dynamic explainability cutoff based on graph density + +## 3. Complexity +- Time: `O(V + E)` for bounded BFS-style traversal in sparse graphs +- Space: `O(V)` for risk score and frontier state + +## 4. Guarantees +- Monotonic update rule: node score only updates when a stronger signal appears. +- Bounded propagation: no traversal beyond `max_hops`. +- Noise control: signals under `min_signal` are ignored. + +## 5. Edge Cases +- Missing seed in graph: ignored safely. +- Cycles: allowed, but bounded by `max_hops` and monotonic score overwrite. +- Dense graph explosion: mitigated via `min_signal` + adaptive threshold for maps. + +## 6. Explainability Outputs +The propagation map endpoint returns: +- `influence`: node -> propagated score +- `dominant_source`: node -> strongest source seed +- `adaptive_threshold`: threshold used to filter weak signals + +This keeps outputs auditable and human-review friendly. diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md new file mode 100644 index 0000000..86765c2 --- /dev/null +++ b/docs/API_REFERENCE.md @@ -0,0 +1,82 @@ +# API Reference (v2) + +Base path: `/api/v2` + +## Health +- `GET /health` +- `GET /health/ready` +- `GET /health/live` + +## Trace +### `POST /trace/` +Request: +```json +{ + "source_id": "bank_001", + "max_hops": 5, + "min_amount": 1000 +} +``` + +## Risk +### `POST /risk/analyze` +Request: +```json +{ + "entity_id": "entity_001", + "time_range_days": 30 +} +``` + +Response highlights: +- `risk_level` +- `risk_score` +- `metrics` +- `recommendations` +- `explanations` + +## AI +### `POST /ai/explain` +Request body: generic trace payload to receive narrative explanation. + + +## Professional Endpoints +- `POST /trace` +- `GET /risk/{entity_id}` +- `GET /risk/propagation-map/{entity_id}` +- `GET /graph/{entity_id}` +- `POST /simulate` + +Headers: +- `X-Request-ID` is accepted and echoed in responses. + + +## Demo +- `GET /demo/graph` +- `GET /demo/timeline` +- `GET /demo/replay` + +UI: +- `GET /dashboard` + + +## Business & Audit +- `GET /metrics/business` +- `GET /audit/logs?limit=50` + +## Enterprise Headers +- `X-Tenant-ID`: tenant isolation context +- `X-API-Key`: rotating API key authentication +- `Authorization: Bearer `: OAuth2/JWT compatible auth + + +## SDK & CLI +- Python SDK: `bridge_trace_sdk.py` +- CLI: `python scripts/bt_cli.py ...` + + +## Hosted Playground +- `GET /playground` +- `GET /playground/ping` +- `GET /playground/sample-trace` +- `GET /playground/sample-risk` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..cbe65b3 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,25 @@ +# Architecture Decisions + +## Why BridgeTrace-AI +**BridgeTrace-AI combines graph-native risk propagation with explainable outputs so compliance teams can act in minutes, not days.** + +## Core layers +1. **API Layer** (`app/api`) for HTTP contracts and routing. +2. **Service Layer** (`app/services`) for domain logic. +3. **Analytics Layer** (`app/analytics`) for graph intelligence (risk propagation). +4. **Core Layer** (`app/core`) for config, logging, security, exceptions. + +## Current differentiator +- Graph-based **risk propagation engine** that transfers seed risk across transaction paths with decay and edge weights. +- Explanations returned with risk responses to support auditability. + +## Next architectural milestones +- Add streaming ingestion boundary (`ingestion/`) with idempotent consumers. +- Add pluggable graph backend interface (NetworkX/Neo4j). +- Add audit replay store for deterministic decision reconstruction. + + +## Tier-1 additions in this iteration +- Graph backend interface (`GraphBackend`) with in-memory, mock and Neo4j-ready adapter stubs. +- Observability metrics modules for latency, graph size, and tracing quality indicators. +- API middleware with request-id propagation and in-memory rate limiting baseline. diff --git a/docs/COMPETITIVE_COMPARISON.md b/docs/COMPETITIVE_COMPARISON.md new file mode 100644 index 0000000..c68229d --- /dev/null +++ b/docs/COMPETITIVE_COMPARISON.md @@ -0,0 +1,10 @@ +# Official Comparison: BridgeTrace vs Alternatives + +| Engine | Explainability | Deterministic | Replay | Propagation | +|---|---|---|---|---| +| BridgeTrace | Native visual + API replay | Yes (seeded benchmark) | Yes (`/api/v2/demo/replay`) | Yes (multi-source + temporal decay) | +| Plain BFS stack | Limited | Depends | No | Partial | +| Local-score only | Low | Usually | No | No | +| Black-box anomaly API | Low/opaque | Unknown | Rare | Unknown | + +BridgeTrace is positioned as an explainable, deterministic propagation platform rather than a black-box risk score API. diff --git a/docs/COMPLIANCE_READINESS.md b/docs/COMPLIANCE_READINESS.md new file mode 100644 index 0000000..7a04016 --- /dev/null +++ b/docs/COMPLIANCE_READINESS.md @@ -0,0 +1,21 @@ +# Compliance Readiness Checklist + +## SOC 2 Readiness +- [x] Access logging baseline +- [x] Change tracking via Git + changelog +- [x] Security disclosure policy +- [ ] Formal vendor risk management +- [ ] Independent control audits + +## ISO 27001 Readiness +- [x] Risk/threat model documented +- [x] Security policy documented +- [x] Incident response ownership defined +- [ ] ISMS scope statement +- [ ] Asset inventory and classification + +## Privacy / Regulatory Readiness +- [x] Tenant-aware audit logs baseline +- [x] Deterministic dataset generation for evidence +- [ ] Data retention policy enforcement +- [ ] DSR workflow automation diff --git a/docs/DISTRIBUTION_ROADMAP.md b/docs/DISTRIBUTION_ROADMAP.md new file mode 100644 index 0000000..744a208 --- /dev/null +++ b/docs/DISTRIBUTION_ROADMAP.md @@ -0,0 +1,11 @@ +# Distribution Roadmap + +## Public Adoption Pipeline +1. Landing page with live demo and benchmarks +2. Public playground (free tier) +3. SDK Python + SDK JS +4. CLI for batch trace and risk evaluation +5. Community edition + enterprise edition packaging + +## Why this matters +Without distribution, technically strong projects fail to gain real-world adoption. diff --git a/docs/FORTUNE500_TIER1_ENHANCEMENTS.md b/docs/FORTUNE500_TIER1_ENHANCEMENTS.md new file mode 100644 index 0000000..a36c08b --- /dev/null +++ b/docs/FORTUNE500_TIER1_ENHANCEMENTS.md @@ -0,0 +1,188 @@ +# BridgeTrace-AI — Melhorias adicionais para nível Tier-1 (Fortune 500) + +Este documento complementa as sugestões já levantadas com um foco explícito em requisitos reais de grandes bancos, seguradoras, fintechs globais e equipes de auditoria/reguladores. + +## 1) Operating Model corporativo (além de código) + +### 1.1 Product Operating Model (POM) +- Definir **três trilhas de produto** com ownership claro: + - `Fraud Operations` (detecção e resposta em tempo real) + - `Regulatory Reporting` (SAR/STR, trilhas e evidências) + - `Investigations` (casos, colaboração, replay) +- Criar **KPIs por trilha**: tempo de investigação, precision@k de alertas, custo por caso, SLA regulatório. + +### 1.2 Governance Board técnico +- Estabelecer comitê quinzenal com Eng + Risk + Compliance + Security. +- Exigir aprovação formal para: + - mudanças em regras AML; + - mudanças de score que impactem decisões; + - novos conectores de dados sensíveis. + +## 2) Confiabilidade e escala de classe bancária + +### 2.1 SLO/SLA/SLI formais +- Definir e publicar: + - **SLO API**: p95 < 300 ms (read), p95 < 1.5 s (trace complexo) + - **SLO pipeline de eventos**: atraso < 5 s em 99% dos eventos + - **SLO disponibilidade**: 99.95% +- Implementar **error budget** com políticas automáticas de freeze de release. + +### 2.2 Resiliência multi-região +- Ativar arquitetura active-active ou active-passive com failover automatizado. +- Introduzir: + - replicação cross-region; + - plano de DR testado trimestralmente; + - RTO/RPO assinados por negócio. + +### 2.3 Chaos Engineering +- Incluir experimentos de falha para: + - indisponibilidade de banco de dados; + - latência extrema no broker; + - queda de provedor de KMS/segredos. +- Objetivo: provar que alertas críticos continuam operando sob degradação. + +## 3) Segurança e privacidade de nível Fortune 500 + +### 3.1 Zero Trust + Identity-first +- Service-to-service auth com mTLS + workload identity. +- Remover segredos estáticos de runtime e adotar secret rotation automática. + +### 3.2 Criptografia avançada +- BYOK/HYOK para clientes enterprise. +- Tokenização/FPE para campos sensíveis (CPF/CNPJ/chaves). +- Políticas de chave por jurisdição (LGPD, GDPR, etc.). + +### 3.3 Privacy Engineering +- Implementar **Data Minimization by Design** no schema. +- Adicionar **privacy budget** para consultas analíticas. +- Trilhas de consentimento e bases legais por fonte de dado. + +### 3.4 Supply Chain Security (SSDF/SLSA) +- Assinatura de artefatos (Sigstore/Cosign). +- SBOM por build (CycloneDX/SPDX). +- Políticas "no critical vuln" no deploy. + +## 4) Compliance e auditoria regulatória internacional + +### 4.1 Regulatory-as-Code +- Converter normas (BACEN, COAF, FATF, 6AMLD) em regras versionadas. +- Cada regra com: + - owner regulatório; + - evidência mínima exigida; + - racional jurídico. + +### 4.2 Evidence Pack automático +- Geração em 1 clique para auditoria externa contendo: + - lineage completo da decisão; + - inputs, modelo, ruleset, versão de dados; + - hash de integridade e timestamp confiável. + +### 4.3 Model Risk Management (MRM) +- Framework SR 11-7 style: + - validação independente; + - monitoramento de drift; + - limites de uso e fallback manual. + +## 5) Excelência em dados (Data Platform) + +### 5.1 Contratos de dados com versionamento +- Adotar schema registry e contratos obrigatórios por produtor. +- Bloquear deploy de produtor que quebra contratos críticos. + +### 5.2 Feature Store para risco +- Features online/offline consistentes para scoring. +- TTL e backfill governados com rastreabilidade. + +### 5.3 Data Quality SRE +- Monitorar completude, unicidade, atraso e distribuição por fonte. +- Acionar incidentes automáticos quando violar limiares. + +## 6) IA avançada com governança robusta + +### 6.1 Human-in-the-loop real +- Fluxo de revisão humana para casos de alto impacto. +- Capturar feedback do analista para aprendizado contínuo supervisionado. + +### 6.2 LLM Governance +- Catálogo de prompts versionados + testes de regressão semântica. +- Guardrails para: + - vazamento de PII; + - respostas não determinísticas em contextos regulatórios; + - citações sem fonte rastreável. + +### 6.3 Fairness e explainability operacional +- Métricas de viés por segmento. +- Explicações em formato auditável (não apenas narrativa textual). + +## 7) Produto enterprise e adoção global + +### 7.1 Modo multi-tenant completo +- Isolamento forte de dados por tenant e por jurisdição. +- Chaves de criptografia dedicadas por cliente. +- Limites de throughput por tenant (noisy neighbor control). + +### 7.2 Case Management nativo +- Workflows de investigação com: + - fila e priorização; + - playbooks por tipo de alerta; + - colaboração entre squads (risk, legal, ops). + +### 7.3 Integrações corporativas +- Conectores padrão para SIEM, GRC, ticketing (ServiceNow/Jira), DLP. +- Webhooks assinados com política de retry e idempotência. + +## 8) FinOps e eficiência econômica + +### 8.1 Unit economics do risco +- Medir custo por 1.000 transações analisadas. +- Medir custo por alerta útil e por caso resolvido. + +### 8.2 Autoscaling orientado a custo +- Políticas diferenciadas para pico de horário bancário. +- Spot/preemptible com failover para workloads não críticos. + +## 9) Go-to-market técnico (para virar padrão de mercado) + +### 9.1 Certificações e confiança +- Roadmap: ISO 27001, SOC 2 Type II, PCI DSS (se aplicável). +- Publicar trust center com uptime, incidentes e postura de segurança. + +### 9.2 Ecossistema e plataforma +- SDKs oficiais (Python/TypeScript/Java). +- Marketplace de plugins de regras e conectores. +- Programa de parceiros (consultorias AML/regtech). + +## 10) Backlog recomendado de execução (90/180/365 dias) + +### 0–90 dias +- Definir SLOs + error budget + runbooks. +- Regulatory-as-code v1 com 20 regras prioritárias. +- Evidence Pack automatizado mínimo. +- Modo case management básico. + +### 90–180 dias +- Multi-tenant hard isolation + KMS por tenant. +- MRM completo com monitoramento de drift. +- Supply chain hardening com SBOM e assinatura. +- Benchmark público com cenários sintéticos reproduzíveis. + +### 180–365 dias +- Multi-região com DR testado. +- Framework avançado de fairness + explicabilidade estruturada. +- Marketplace de plugins e SDKs. +- Certificação SOC 2 Type II em andamento. + +--- + +## Checklist executivo: "pronto para Fortune 500" + +- [ ] SLOs oficiais + incident response maduro +- [ ] Segurança zero trust + supply chain assinada +- [ ] Regulatory-as-code com trilha de auditoria completa +- [ ] MRM/AI governance com validação independente +- [ ] Multi-tenant enterprise com isolamento forte +- [ ] Evidence pack auditável em um clique +- [ ] FinOps com unit economics por caso +- [ ] Plano de certificações e trust center + +Se o projeto fechar esse checklist com execução consistente, ele deixa de ser "apenas tecnicamente bom" e se torna **comprável por organizações Fortune 500**. diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md new file mode 100644 index 0000000..7326bc4 --- /dev/null +++ b/docs/INSTALLATION.md @@ -0,0 +1,30 @@ +# Installation Guide + +## Prerequisites +- Python 3.9+ +- `pip` +- Docker (optional, for full stack) + +## Local setup +```bash +git clone https://github.com/felipeofdev-ai/BridgeTrace-AI.git +cd BridgeTrace-AI +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +uvicorn app.main:app --reload +``` + +## Developer setup +```bash +pip install -r requirements-dev.txt +pre-commit install +pytest -q -o addopts='' +``` + +## Docker setup +```bash +docker-compose up -d --build +curl http://localhost:8000/api/v2/health +``` diff --git a/docs/PERFORMANCE_PROOF.md b/docs/PERFORMANCE_PROOF.md new file mode 100644 index 0000000..4350871 --- /dev/null +++ b/docs/PERFORMANCE_PROOF.md @@ -0,0 +1,15 @@ +# Performance Proof Matrix + +Synthetic benchmark snapshot (indicative): + +| Engine/Approach | Latency | Scale | Memory | Recall | +|---|---:|---:|---:|---:| +| NetworkX + Propagation | 0.80 ms | 300 nodes / 1188 edges | Low | 0.033 | +| NetworkX + BFS | 0.09 ms | 300 nodes / 1188 edges | Low | 0.483 | +| Local-only baseline | 0.02 ms | 300 nodes / 1188 edges | Very low | 0.000 | +| Neo4j (planned benchmark) | TBD | TBD | TBD | TBD | +| TigerGraph (planned benchmark) | TBD | TBD | TBD | TBD | + +Notes: +- Current measured results come from `scripts/benchmark_risk_engine.py`. +- Neo4j/TigerGraph comparison will be published once production adapters are fully integrated. diff --git a/docs/QUICKSTART_5MIN.md b/docs/QUICKSTART_5MIN.md new file mode 100644 index 0000000..d11111d --- /dev/null +++ b/docs/QUICKSTART_5MIN.md @@ -0,0 +1,24 @@ +# 5-Minute Quickstart + +## Copy/Paste Flow +```bash +uvicorn app.main:app --reload +curl http://localhost:8000/api/v2/health +curl -X POST http://localhost:8000/api/v2/trace \ + -H "Content-Type: application/json" \ + -H "X-API-Key: dev-key-1" \ + -H "X-Tenant-ID: demo" \ + -d '{"source_id":"bank_001","max_hops":5,"min_amount":0}' +``` + +## SDK (Python) +```python +from bridge_trace_sdk import BridgeTraceSDK +sdk = BridgeTraceSDK("http://localhost:8000", api_key="dev-key-1", tenant_id="demo") +print(sdk.risk("entity_001")) +``` + +## CLI +```bash +python scripts/bt_cli.py --base-url http://localhost:8000 --api-key dev-key-1 --tenant demo risk --entity entity_001 +``` diff --git a/docs/SDK_PUBLISHING.md b/docs/SDK_PUBLISHING.md new file mode 100644 index 0000000..3114cb4 --- /dev/null +++ b/docs/SDK_PUBLISHING.md @@ -0,0 +1,28 @@ +# SDK Publishing Plan (Registries) + +## Targets +- PyPI: `bridgetrace-sdk` +- npm: `@bridgetrace/sdk-js` +- crates.io: `bridgetrace-sdk-rs` + +## Python (PyPI) +```bash +cd sdk/python +python -m build +python -m twine upload dist/* +``` + +## JavaScript (npm) +```bash +cd sdk/js +npm publish --access public +``` + +## Rust (crates.io) +```bash +cd sdk/rust +cargo publish +``` + +## Trust Signal +After publication, add registry badges in README with version pins. diff --git a/docs/datasets/BRIDGETRACE_SYNTHETIC_DATASET_V1.md b/docs/datasets/BRIDGETRACE_SYNTHETIC_DATASET_V1.md new file mode 100644 index 0000000..c66b527 --- /dev/null +++ b/docs/datasets/BRIDGETRACE_SYNTHETIC_DATASET_V1.md @@ -0,0 +1,32 @@ +# BridgeTrace Synthetic Financial Dataset v1 + +## License +MIT (same repository license). + +## Purpose +Open synthetic dataset for benchmarking traceability and risk propagation workflows. + +## Scenarios Included +- layering chains +- loop laundering +- mixer transfers +- high-value cashout branches + +## Schema +Each JSONL row contains: +- `tx_id` +- `source` +- `target` +- `amount` +- `timestamp` +- `pattern` +- `risk_transfer` + +## Files +- `data/public/bridgetrace_synthetic_financial_dataset_v1.jsonl` + +## Reproducibility +Generate with: +```bash +python scripts/generate_public_dataset_v1.py --rows 500 --seed 42 +``` diff --git a/docs/papers/BRIDGETRACE_TECHNICAL_PAPER_V1.md b/docs/papers/BRIDGETRACE_TECHNICAL_PAPER_V1.md new file mode 100644 index 0000000..645f98e --- /dev/null +++ b/docs/papers/BRIDGETRACE_TECHNICAL_PAPER_V1.md @@ -0,0 +1,40 @@ +# BridgeTrace Technical Paper v1 + +## Abstract +BridgeTrace-AI addresses cross-rail financial traceability by combining graph-native risk propagation, explainable risk maps, and simulation APIs. + +## 1. Problem +Fraud and AML investigations span heterogeneous rails (banking, PIX, crypto), where direct links are often obscured by layering and mixers. + +## 2. Approach +BridgeTrace models transfers as directed weighted graph edges and computes propagated risk from multi-source seeds using decay, min-signal pruning, and adaptive explainability thresholds. + +## 3. Equation +For edge `u -> v`: + +`R(v, k+1) = max(R(v, k+1), R(u, k) * w(u,v) * d * T)` + +where `w` is edge risk transfer, `d` is global decay, and `T` is temporal decay. + +## 4. Evaluation +We benchmark propagation against BFS and local-only baselines on synthetic graphs (`scripts/benchmark_risk_engine.py`), reporting: +- precision +- recall +- latency (ms) +- scalability (nodes/edges) + +## 5. Benchmark Snapshot +Example output (environment-dependent): +- propagation: better global context than local baseline, with small latency overhead. +- bfs: stronger recall, lower explainability granularity. +- local: fastest but weakest analytical depth. + +## 6. Limitations +- Current benchmark is synthetic and pseudo-labeled. +- Neo4j backend is contract-defined but not fully integrated. +- Rate limiting is in-memory baseline; distributed limits are pending. + +## 7. Roadmap +- Publish signed benchmark artifacts. +- Add deterministic replay and model versioning. +- Add production graph backends and streaming ingestion. diff --git a/docs/papers/SCIENTIFIC_CHANGELOG.md b/docs/papers/SCIENTIFIC_CHANGELOG.md new file mode 100644 index 0000000..1f87f87 --- /dev/null +++ b/docs/papers/SCIENTIFIC_CHANGELOG.md @@ -0,0 +1,6 @@ +# Scientific Changelog + +## v1.0.0 - 2026-02-15 +- First formal whitepaper release draft with DOI placeholder. +- Added deterministic reproducibility protocol (dataset + benchmark + checksums). +- Added replay-based explainability narrative as a public demonstration feature. diff --git a/docs/papers/WHITEPAPER_FORMAL_V1_0.md b/docs/papers/WHITEPAPER_FORMAL_V1_0.md new file mode 100644 index 0000000..5c6b50d --- /dev/null +++ b/docs/papers/WHITEPAPER_FORMAL_V1_0.md @@ -0,0 +1,39 @@ +# BridgeTrace Whitepaper (Formal) — v1.0.0 + +**DOI (planned):** `10.5281/zenodo.BRIDGETRACE-WP-V1` (to be minted at release) + +## Citation +BridgeTrace Team. *BridgeTrace Whitepaper (Formal) v1.0.0*. 2026. DOI: pending mint. + +## 1. Problem Statement +Cross-rail financial investigations require explainable, deterministic methods that connect weakly linked transaction paths under uncertainty. + +## 2. Method +BridgeTrace combines: +- directed weighted propagation, +- temporal decay, +- multi-source risk seeding, +- explainability replay outputs. + +## 3. Formalism +Given edge `u -> v`, risk update at step `k+1`: + +`R(v,k+1)=max(R(v,k+1), R(u,k)*w(u,v)*d*T)` + +## 4. Evaluation Protocol +- deterministic synthetic dataset generation (fixed seed) +- deterministic benchmark script +- reproducible artifacts + checksums + +## 5. Results Snapshot +See: +- `scripts/benchmark_risk_engine.py` +- `scripts/run_external_proof.sh` +- `docs/PERFORMANCE_PROOF.md` + +## 6. Limitations +- external third-party benchmark execution still pending +- graph-db adapters (Neo4j/TigerGraph) not yet benchmarked end-to-end + +## 7. Scientific Changelog +Tracked in `docs/papers/SCIENTIFIC_CHANGELOG.md`. diff --git a/examples/scripts/basic_trace.py b/examples/scripts/basic_trace.py new file mode 100644 index 0000000..108b0db --- /dev/null +++ b/examples/scripts/basic_trace.py @@ -0,0 +1,29 @@ +"""Basic example for tracing a transaction path using the service layer.""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.services.trace_service import TraceService + + +async def main() -> None: + service = TraceService() + + trace = await service.trace_flow(source_id="bank_001", max_hops=5, min_amount=100.0) + + print("=== BridgeTrace Basic Trace Example ===") + print(f"Source: {trace['source_id']}") + print(f"Paths found: {trace['total_paths']}") + for path in trace["paths"]: + print(f" - {path['from']} -> {path['to']} | amount={path['data'].get('amount')}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/frontend/playground.html b/frontend/playground.html new file mode 100644 index 0000000..c06b058 --- /dev/null +++ b/frontend/playground.html @@ -0,0 +1,50 @@ + + + + + + BridgeTrace Hosted Playground + + + + +
+

BridgeTrace Hosted Playground

+

Teste público sem instalação: trace, risk e replay em segundos.

+ +
+

1) Connectivity

+
+ + + +
+
Click any button to fetch a live sample payload.
+
+ +
+

2) Quick API Calls

+
curl http://localhost:8000/api/v2/playground/ping
+curl http://localhost:8000/api/v2/playground/sample-trace
+curl http://localhost:8000/api/v2/playground/sample-risk
+
+
+ + + diff --git a/frontend/tier1_dashboard.html b/frontend/tier1_dashboard.html new file mode 100644 index 0000000..9499a98 --- /dev/null +++ b/frontend/tier1_dashboard.html @@ -0,0 +1,111 @@ + + + + + + BridgeTrace Tier-1 Demo Dashboard + + + + + +
+

BridgeTrace — Interactive Tier-1 Demo

+

Interactive graph, risk color coding, timeline, and animated risk cascade replay.

+
+
+

Risk Propagation Graph

+ +
+
+

Timeline

+ +
    +

    Risk Cascade Replay

    + +
    +
    +
    +
    + + + + diff --git a/scripts/benchmark_risk_engine.py b/scripts/benchmark_risk_engine.py new file mode 100644 index 0000000..6ae95c4 --- /dev/null +++ b/scripts/benchmark_risk_engine.py @@ -0,0 +1,158 @@ +"""Benchmark risk propagation engine against simple baselines (deterministic).""" + +from __future__ import annotations + +import argparse +import json +import random +import sys +import time +from pathlib import Path +from statistics import mean + +import networkx as nx + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.analytics.risk_propagation import RiskPropagationEngine + + +def build_synthetic_graph(nodes: int = 300, edges: int = 1200, seed: int = 42) -> nx.DiGraph: + rng = random.Random(seed) + graph = nx.DiGraph() + for idx in range(nodes): + graph.add_node(f"N{idx}") + for _ in range(edges): + a = f"N{rng.randint(0, nodes - 1)}" + b = f"N{rng.randint(0, nodes - 1)}" + if a != b: + graph.add_edge(a, b, risk_transfer=round(rng.uniform(0.2, 1.0), 3)) + return graph + + +def propagation_scores(graph: nx.DiGraph, seeds: dict[str, float]) -> dict[str, float]: + engine = RiskPropagationEngine(decay=0.75, min_signal=0.01) + result = engine.run(graph, seeds, max_hops=4) + return result.scores + + +def bfs_baseline(graph: nx.DiGraph, seed: str, max_hops: int = 4) -> dict[str, float]: + scores = {node: 0.0 for node in graph.nodes} + visited = {seed} + frontier = [(seed, 0)] + scores[seed] = 1.0 + + while frontier: + node, depth = frontier.pop(0) + if depth >= max_hops: + continue + for nxt in graph.successors(node): + if nxt not in visited: + visited.add(nxt) + scores[nxt] = 1.0 / (depth + 2) + frontier.append((nxt, depth + 1)) + return scores + + +def local_baseline(graph: nx.DiGraph, seed: str) -> dict[str, float]: + scores = {node: 0.0 for node in graph.nodes} + scores[seed] = 1.0 + for nxt in graph.successors(seed): + scores[nxt] = 0.5 + return scores + + +def pseudo_quality(scores: dict[str, float]) -> tuple[float, float]: + predicted = {node for node, val in scores.items() if val >= 0.2} + relevant = {node for node in scores if node.endswith("7") or node.endswith("9")} + tp = len(predicted & relevant) + fp = len(predicted - relevant) + fn = len(relevant - predicted) + + precision = tp / max((tp + fp), 1) + recall = tp / max((tp + fn), 1) + return precision, recall + + +def run_benchmark(seed: int = 42, runs_count: int = 10) -> dict: + graph = build_synthetic_graph(seed=seed) + seeds = {"N0": 0.95, "N3": 0.55} + + runs = [] + for _ in range(runs_count): + t0 = time.perf_counter() + p_scores = propagation_scores(graph, seeds) + p_lat = time.perf_counter() - t0 + + t1 = time.perf_counter() + b_scores = bfs_baseline(graph, "N0") + b_lat = time.perf_counter() - t1 + + t2 = time.perf_counter() + l_scores = local_baseline(graph, "N0") + l_lat = time.perf_counter() - t2 + + p_pr, p_rc = pseudo_quality(p_scores) + b_pr, b_rc = pseudo_quality(b_scores) + l_pr, l_rc = pseudo_quality(l_scores) + + runs.append( + { + "propagation": (p_pr, p_rc, p_lat), + "bfs": (b_pr, b_rc, b_lat), + "local": (l_pr, l_rc, l_lat), + } + ) + + def avg(metric: str, idx: int) -> float: + return mean(run[metric][idx] for run in runs) + + return { + "seed": seed, + "rows": { + "propagation": { + "precision": round(avg("propagation", 0), 4), + "recall": round(avg("propagation", 1), 4), + "latency_ms": round(avg("propagation", 2) * 1000, 2), + }, + "bfs": { + "precision": round(avg("bfs", 0), 4), + "recall": round(avg("bfs", 1), 4), + "latency_ms": round(avg("bfs", 2) * 1000, 2), + }, + "local": { + "precision": round(avg("local", 0), 4), + "recall": round(avg("local", 1), 4), + "latency_ms": round(avg("local", 2) * 1000, 2), + }, + }, + "scalability": {"nodes": graph.number_of_nodes(), "edges": graph.number_of_edges()}, + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--runs", type=int, default=10) + parser.add_argument("--json-output", type=Path, default=None) + args = parser.parse_args() + + result = run_benchmark(seed=args.seed, runs_count=args.runs) + + print("=== Risk Engine Benchmark ===") + print("metric,precision,recall,latency_ms") + for name in ["propagation", "bfs", "local"]: + row = result["rows"][name] + print(f"{name},{row['precision']:.4f},{row['recall']:.4f},{row['latency_ms']:.2f}") + print(f"scalability,nodes={result['scalability']['nodes']},edges={result['scalability']['edges']}") + + if args.json_output: + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(json.dumps(result, indent=2)) + print(f"json_output={args.json_output}") + + +if __name__ == "__main__": + main() diff --git a/scripts/bt_cli.py b/scripts/bt_cli.py new file mode 100644 index 0000000..a7de9a4 --- /dev/null +++ b/scripts/bt_cli.py @@ -0,0 +1,46 @@ +"""BridgeTrace CLI tool for quick API usage.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from bridge_trace_sdk import BridgeTraceSDK + + +def main() -> None: + parser = argparse.ArgumentParser(description="BridgeTrace CLI") + parser.add_argument("--base-url", default="http://localhost:8000") + parser.add_argument("--api-key", default=None) + parser.add_argument("--tenant", default="public") + + sub = parser.add_subparsers(dest="command", required=True) + + trace_cmd = sub.add_parser("trace") + trace_cmd.add_argument("--source", required=True) + trace_cmd.add_argument("--max-hops", type=int, default=5) + trace_cmd.add_argument("--min-amount", type=float, default=0.0) + + risk_cmd = sub.add_parser("risk") + risk_cmd.add_argument("--entity", required=True) + risk_cmd.add_argument("--days", type=int, default=30) + + args = parser.parse_args() + sdk = BridgeTraceSDK(args.base_url, api_key=args.api_key, tenant_id=args.tenant) + + if args.command == "trace": + out = sdk.trace(args.source, max_hops=args.max_hops, min_amount=args.min_amount) + else: + out = sdk.risk(args.entity, days=args.days) + + print(json.dumps(out, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_public_dataset_v1.py b/scripts/generate_public_dataset_v1.py new file mode 100644 index 0000000..1d252a8 --- /dev/null +++ b/scripts/generate_public_dataset_v1.py @@ -0,0 +1,69 @@ +"""Generate BridgeTrace Synthetic Financial Dataset v1 (JSONL).""" + +from __future__ import annotations + +import argparse +import json +import random +from datetime import datetime, timedelta +from pathlib import Path + + +PATTERNS = ["layering", "loop", "mixer", "cashout"] + + +def generate_rows(rows: int, seed: int) -> list[dict]: + random.seed(seed) + now = datetime(2026, 1, 1) + timedelta(days=seed % 365) + output = [] + for i in range(rows): + pattern = random.choice(PATTERNS) + if pattern == "loop": + src = f"entity_{random.randint(100,199)}" + dst = src if random.random() < 0.2 else f"entity_{random.randint(100,199)}" + elif pattern == "mixer": + src = f"wallet_{random.randint(1000,1999)}" + dst = f"mixer_{random.randint(1,9)}" + elif pattern == "cashout": + src = f"entity_{random.randint(200,299)}" + dst = f"merchant_{random.randint(1,50)}" + else: + src = f"wallet_{random.randint(2000,2999)}" + dst = f"entity_{random.randint(300,399)}" + + output.append( + { + "tx_id": f"BTX_{i:07d}", + "source": src, + "target": dst, + "amount": round(random.uniform(500, 150000), 2), + "timestamp": (now - timedelta(minutes=random.randint(1, 60 * 24 * 120))).isoformat() + "Z", + "pattern": pattern, + "risk_transfer": round(random.uniform(0.2, 1.0), 3), + } + ) + return output + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--rows", type=int, default=500) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument( + "--output", + type=Path, + default=Path("data/public/bridgetrace_synthetic_financial_dataset_v1.jsonl"), + ) + args = parser.parse_args() + + rows = generate_rows(args.rows, args.seed) + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open("w", encoding="utf-8") as f: + for row in rows: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + + print(f"Generated {len(rows)} rows at {args.output}") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_synthetic_data.py b/scripts/generate_synthetic_data.py new file mode 100644 index 0000000..6b2be0f --- /dev/null +++ b/scripts/generate_synthetic_data.py @@ -0,0 +1,53 @@ +"""Generate synthetic PIX transactions for local testing and demos.""" + +from __future__ import annotations + +import argparse +import json +import random +from datetime import datetime, timedelta +from pathlib import Path + + +def generate_pix_transactions(count: int = 1000) -> list[dict]: + """Generate synthetic PIX-like transactions.""" + + now = datetime.utcnow() + rows: list[dict] = [] + + for idx in range(count): + created_at = now - timedelta(minutes=random.randint(1, 60 * 24 * 120)) + rows.append( + { + "id": f"PIX_{idx:07d}", + "source_account": f"ACC_{random.randint(1000, 9999)}", + "target_account": f"ACC_{random.randint(1000, 9999)}", + "amount": round(random.uniform(5.0, 100000.0), 2), + "timestamp": created_at.isoformat() + "Z", + "pix_key_type": random.choice(["CPF", "CNPJ", "EMAIL", "PHONE", "RANDOM"]), + "channel": random.choice(["PIX", "TED", "CRYPTO_BRIDGE"]), + } + ) + + return rows + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate synthetic financial transactions") + parser.add_argument("--count", type=int, default=1000, help="Number of records to generate") + parser.add_argument( + "--output", + type=Path, + default=Path("data/raw/pix_transactions.json"), + help="Output path for generated JSON", + ) + args = parser.parse_args() + + args.output.parent.mkdir(parents=True, exist_ok=True) + payload = generate_pix_transactions(args.count) + args.output.write_text(json.dumps(payload, indent=2, ensure_ascii=False)) + print(f"Generated {len(payload)} transactions at {args.output}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_external_proof.sh b/scripts/run_external_proof.sh new file mode 100755 index 0000000..344ffa6 --- /dev/null +++ b/scripts/run_external_proof.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +mkdir -p artifacts + +python scripts/generate_public_dataset_v1.py --rows 500 --seed 42 --output artifacts/dataset_v1_500_seed42.jsonl +python scripts/benchmark_risk_engine.py --seed 42 --runs 10 --json-output artifacts/benchmark_seed42.json + +sha256sum artifacts/dataset_v1_500_seed42.jsonl artifacts/benchmark_seed42.json > artifacts/proof_checksums.sha256 + +echo "External proof artifacts generated:" +cat artifacts/proof_checksums.sha256 diff --git a/sdk/js/index.js b/sdk/js/index.js new file mode 100644 index 0000000..9b163ab --- /dev/null +++ b/sdk/js/index.js @@ -0,0 +1,23 @@ +export class BridgeTraceSDK { + constructor(baseUrl, apiKey = null, tenantId = 'public') { + this.baseUrl = baseUrl.replace(/\/$/, ''); + this.apiKey = apiKey; + this.tenantId = tenantId; + } + + _headers() { + const h = { 'X-Tenant-ID': this.tenantId }; + if (this.apiKey) h['X-API-Key'] = this.apiKey; + return h; + } + + async trace(sourceId, maxHops = 5, minAmount = 0) { + const res = await fetch(`${this.baseUrl}/api/v2/trace`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...this._headers() }, + body: JSON.stringify({ source_id: sourceId, max_hops: maxHops, min_amount: minAmount }) + }); + if (!res.ok) throw new Error(`trace failed: ${res.status}`); + return await res.json(); + } +} diff --git a/sdk/js/package.json b/sdk/js/package.json new file mode 100644 index 0000000..7b74b8e --- /dev/null +++ b/sdk/js/package.json @@ -0,0 +1,11 @@ +{ + "name": "@bridgetrace/sdk-js", + "version": "0.1.0", + "description": "Official JavaScript SDK for BridgeTrace API", + "type": "module", + "main": "index.js", + "files": ["index.js", "README.md"], + "scripts": { + "test": "node -e \"console.log('ok')\"" + } +} diff --git a/sdk/python/bridgetrace_sdk/__init__.py b/sdk/python/bridgetrace_sdk/__init__.py new file mode 100644 index 0000000..772ff8a --- /dev/null +++ b/sdk/python/bridgetrace_sdk/__init__.py @@ -0,0 +1,5 @@ +"""Official Python SDK package for BridgeTrace.""" + +from bridge_trace_sdk import BridgeTraceSDK + +__all__ = ["BridgeTraceSDK"] diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml new file mode 100644 index 0000000..b1308a4 --- /dev/null +++ b/sdk/python/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "bridgetrace-sdk" +version = "0.1.0" +description = "Official Python SDK for BridgeTrace API" +requires-python = ">=3.9" +dependencies = ["httpx>=0.27"] + +[tool.setuptools] +package-dir = {"" = "."} + +[tool.setuptools.packages.find] +where = ["."] +include = ["bridgetrace_sdk*"] diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml new file mode 100644 index 0000000..894c008 --- /dev/null +++ b/sdk/rust/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "bridgetrace-sdk-rs" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Official Rust SDK for BridgeTrace API" + +[dependencies] +reqwest = { version = "0.12", features = ["json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs new file mode 100644 index 0000000..1d6a07d --- /dev/null +++ b/sdk/rust/src/lib.rs @@ -0,0 +1,35 @@ +use reqwest::Client; +use serde_json::json; + +pub struct BridgeTraceSdk { + base_url: String, + api_key: Option, + tenant_id: String, + client: Client, +} + +impl BridgeTraceSdk { + pub fn new(base_url: &str, api_key: Option, tenant_id: &str) -> Self { + Self { + base_url: base_url.trim_end_matches('/').to_string(), + api_key, + tenant_id: tenant_id.to_string(), + client: Client::new(), + } + } + + pub async fn trace(&self, source_id: &str) -> Result { + let mut req = self.client + .post(format!("{}/api/v2/trace", self.base_url)) + .header("X-Tenant-ID", &self.tenant_id) + .json(&json!({"source_id": source_id, "max_hops": 5, "min_amount": 0})); + + if let Some(k) = &self.api_key { + req = req.header("X-API-Key", k); + } + + let res = req.send().await?; + let json = res.json::().await?; + Ok(json) + } +} diff --git a/tests/unit/test_demo_and_dataset.py b/tests/unit/test_demo_and_dataset.py new file mode 100644 index 0000000..d4d1083 --- /dev/null +++ b/tests/unit/test_demo_and_dataset.py @@ -0,0 +1,43 @@ +"""Tests for interactive demo endpoints and public dataset generator.""" + +from pathlib import Path + +from scripts.generate_public_dataset_v1 import generate_rows + + +def test_demo_graph_endpoint(client): + response = client.get("/api/v2/demo/graph") + assert response.status_code == 200 + payload = response.json() + assert "nodes" in payload and "links" in payload + assert len(payload["nodes"]) > 0 + + +def test_demo_timeline_endpoint(client): + response = client.get("/api/v2/demo/timeline") + assert response.status_code == 200 + payload = response.json() + assert "events" in payload + assert len(payload["events"]) >= 1 + + +def test_dataset_generator_deterministic(): + rows_a = generate_rows(5, 7) + rows_b = generate_rows(5, 7) + assert rows_a == rows_b + assert all("pattern" in row for row in rows_a) + + +def test_dashboard_page_served(client): + response = client.get("/dashboard") + assert response.status_code == 200 + assert "Interactive Tier-1 Demo" in response.text + assert Path("frontend/tier1_dashboard.html").exists() + + +def test_demo_replay_endpoint(client): + response = client.get("/api/v2/demo/replay") + assert response.status_code == 200 + payload = response.json() + assert "replay" in payload + assert len(payload["replay"]) >= 1 diff --git a/tests/unit/test_enterprise_controls.py b/tests/unit/test_enterprise_controls.py new file mode 100644 index 0000000..78dbcb9 --- /dev/null +++ b/tests/unit/test_enterprise_controls.py @@ -0,0 +1,28 @@ +"""Tests for enterprise controls and security helpers.""" + +from app.core.security import authenticate_request, create_access_token, validate_api_key + + +def test_validate_api_key_default_dev_key() -> None: + assert validate_api_key("dev-key-1") is True + assert validate_api_key("invalid-key") is False + + +def test_authenticate_with_bearer_token() -> None: + token = create_access_token({"sub": "test-user"}) + assert authenticate_request(None, f"Bearer {token}") is True + + +def test_business_metrics_endpoint(client) -> None: + response = client.get("/metrics/business") + assert response.status_code == 200 + payload = response.json() + assert "avg_trace_seconds" in payload + assert "detection_rate" in payload + + +def test_audit_logs_endpoint(client) -> None: + response = client.get("/audit/logs") + assert response.status_code == 200 + payload = response.json() + assert "entries" in payload diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 0000000..05af1cf --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,11 @@ +"""Tests for custom exception mapping.""" + +from app.core.exceptions import GraphTraversalError, exception_to_http + + +def test_graph_traversal_error_maps_to_422() -> None: + exc = GraphTraversalError("failed traversal") + http_exc = exception_to_http(exc) + + assert http_exc.status_code == 422 + assert http_exc.detail["code"] == "GraphTraversalError" diff --git a/tests/unit/test_external_proof.py b/tests/unit/test_external_proof.py new file mode 100644 index 0000000..6c7be4b --- /dev/null +++ b/tests/unit/test_external_proof.py @@ -0,0 +1,13 @@ +"""Tests for reproducible external proof artifacts.""" + +from scripts.benchmark_risk_engine import run_benchmark + + +def test_benchmark_is_deterministic_for_same_seed() -> None: + a = run_benchmark(seed=42, runs_count=3) + b = run_benchmark(seed=42, runs_count=3) + + assert a["seed"] == b["seed"] == 42 + assert a["rows"]["propagation"]["precision"] == b["rows"]["propagation"]["precision"] + assert a["rows"]["bfs"]["recall"] == b["rows"]["bfs"]["recall"] + assert a["scalability"] == b["scalability"] diff --git a/tests/unit/test_playground.py b/tests/unit/test_playground.py new file mode 100644 index 0000000..d76f451 --- /dev/null +++ b/tests/unit/test_playground.py @@ -0,0 +1,19 @@ +"""Tests for hosted playground public endpoints.""" + + +def test_playground_page_served(client): + response = client.get('/playground') + assert response.status_code == 200 + assert 'Hosted Playground' in response.text + + +def test_playground_ping(client): + response = client.get('/api/v2/playground/ping') + assert response.status_code == 200 + assert response.json()['status'] == 'ok' + + +def test_playground_sample_trace(client): + response = client.get('/api/v2/playground/sample-trace') + assert response.status_code == 200 + assert response.json()['total_paths'] >= 1 diff --git a/tests/unit/test_professional_api.py b/tests/unit/test_professional_api.py new file mode 100644 index 0000000..60946b9 --- /dev/null +++ b/tests/unit/test_professional_api.py @@ -0,0 +1,51 @@ +"""Tests for professional API endpoints and middleware behavior.""" + + +def test_request_id_is_echoed(client, sample_trace_request): + response = client.post( + "/api/v2/trace", + json=sample_trace_request, + headers={"X-Request-ID": "req-test-123"}, + ) + assert response.status_code == 200 + assert response.headers.get("X-Request-ID") == "req-test-123" + + +def test_get_risk_by_entity(client): + response = client.get("/api/v2/risk/entity_001") + assert response.status_code == 200 + payload = response.json() + assert payload["entity_id"] == "entity_001" + assert "explanations" in payload + + +def test_get_graph_by_entity(client): + response = client.get("/api/v2/graph/bank_001") + assert response.status_code == 200 + payload = response.json() + assert payload["graph"]["entity"] == "bank_001" + assert "graph_size" in payload + + +def test_propagation_map_endpoint(client): + response = client.get("/api/v2/risk/propagation-map/entity_001") + assert response.status_code == 200 + payload = response.json() + assert payload["entity_id"] == "entity_001" + assert "influence" in payload + + +def test_simulate_endpoint(client): + response = client.post( + "/api/v2/simulate", + json={ + "source_id": "entity_001", + "target_id": "wallet_new", + "amount": 25000, + "risk_transfer": 0.82, + }, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["simulation"]["target_id"] == "wallet_new" + assert "projected_risk" in payload diff --git a/tests/unit/test_risk_propagation.py b/tests/unit/test_risk_propagation.py new file mode 100644 index 0000000..e06ca55 --- /dev/null +++ b/tests/unit/test_risk_propagation.py @@ -0,0 +1,19 @@ +"""Unit tests for graph risk propagation.""" + +import networkx as nx + +from app.analytics.risk_propagation import RiskPropagationEngine + + +def test_risk_propagation_spreads_risk_to_downstream_nodes() -> None: + graph = nx.DiGraph() + graph.add_edge("A", "B", risk_transfer=1.0) + graph.add_edge("B", "C", risk_transfer=0.5) + + engine = RiskPropagationEngine(decay=1.0, min_signal=0.0) + result = engine.run(graph, seed_scores={"A": 1.0}, max_hops=3) + + assert result.scores["A"] == 1.0 + assert result.scores["B"] == 1.0 + assert result.scores["C"] == 0.5 + assert result.dominant_source["C"] == "A" diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index 421e4c1..a900a0d 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -1,18 +1,22 @@ -"""Test services.""" -import pytest -from app.services.trace_service import TraceService +"""Test service layer.""" + +import asyncio + from app.services.risk_service import RiskService +from app.services.trace_service import TraceService -@pytest.mark.asyncio -async def test_trace_service(): + +def test_trace_service() -> None: service = TraceService() - result = await service.trace_flow("bank_001", max_hops=5) + result = asyncio.run(service.trace_flow("bank_001", max_hops=5)) + assert "source_id" in result assert result["source_id"] == "bank_001" -@pytest.mark.asyncio -async def test_risk_service(): + +def test_risk_service() -> None: service = RiskService() - result = await service.analyze_entity_risk("entity_001") + result = asyncio.run(service.analyze_entity_risk("entity_001")) + assert "risk_level" in result assert result["risk_level"] in ["LOW", "MEDIUM", "HIGH"]