#!/usr/bin/env python3 """ RAG System Test Suite ===================== Tests all core RAG modules: embeddings, ingestion, search, vector store, entity extraction, temporal decay, contextual chunking, hallucination guard. NOTE: This suite uses a custom @test() decorator + run_tests() runner. Do NOT run with pytest - it will produce false failures (pytest-asyncio auto-collects these functions but they aren't standard pytest test items). Correct runner: docker exec rmi-backend python tests/test_rag.py A pytest.ini in /root/backend/ disables auto-collection to prevent accidental pytest runs from reporting failures. """ import asyncio import os import sys from datetime import UTC # Ensure app/ is importable sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) # ══════════════════════════════════════════════════════════════════════ # Lightweight test runner (no pytest dependency needed) # ══════════════════════════════════════════════════════════════════════ _results = [] _current = None def test(name): """Decorator to mark a test function.""" def decorator(fn): if asyncio.iscoroutinefunction(fn): _results.append((name, fn, True)) else: _results.append((name, fn, False)) return fn return decorator async def run_tests(): """Run all registered tests and print results.""" passed = 0 failed = 0 errors = [] for name, fn, is_async in _results: try: if is_async: await fn() else: fn() passed += 1 print(f" PASS {name}") except Exception as e: failed += 1 errors.append((name, str(e))) print(f" FAIL {name}: {e}") print(f"\n{'=' * 60}") print(f" Results: {passed} passed, {failed} failed, {passed + failed} total") if errors: print("\n Failures:") for name, err in errors: print(f" - {name}: {err[:80]}") print(f"{'=' * 60}") return failed == 0 # ══════════════════════════════════════════════════════════════════════ # FEATURE EXTRACTORS (pure functions, no API/model needed) # ══════════════════════════════════════════════════════════════════════ @test("extract_contract_features: returns 128-dim vector") def test_contract_features_dims(): from app.crypto_embeddings import extract_contract_features vec = extract_contract_features("pragma solidity; function mint() external onlyOwner {}") assert len(vec) == 128, f"Expected 128 dims, got {len(vec)}" assert vec.dtype.name.startswith("float32"), f"Expected float32, got {vec.dtype}" @test("extract_contract_features: detects rug patterns") def test_contract_features_rug_detection(): from app.crypto_embeddings import extract_contract_features rug_code = "function mint(address to, uint256 amount) external onlyOwner { maxTxAmount = 0; }" vec = extract_contract_features(rug_code) # The keyword list checks lowercased code; verify at least one rug dim is active rug_dims = [i for i, v in enumerate(vec[:56]) if v > 0] # mint is in the list, onlyOwner is in the list - just verify code is non-zero assert len(rug_dims) >= 1, "No rug pattern dims active in first 56 dims" # Also verify structural dims are populated (lines, functions, etc.) struct_dims = [i for i in range(32, 56) if vec[i] > 0] assert len(struct_dims) >= 1, f"No structural dims active: {struct_dims}" @test("extract_contract_features: empty input returns zeros") def test_contract_features_empty(): from app.crypto_embeddings import extract_contract_features vec = extract_contract_features("") assert len(vec) == 128 assert all(v == 0.0 for v in vec), "Empty input should produce zero vector" @test("extract_transaction_features: returns 64-dim vector") def test_transaction_features_dims(): from app.crypto_embeddings import extract_transaction_features vec = extract_transaction_features({"transactions": []}) assert len(vec) == 64, f"Expected 64 dims, got {len(vec)}" @test("extract_transaction_features: captures counterparty diversity") def test_transaction_features_counterparty(): from app.crypto_embeddings import extract_transaction_features txs = [ {"amount": 100, "from": "0xA", "to": "0xB", "timestamp": 1000}, {"amount": 200, "from": "0xB", "to": "0xC", "timestamp": 2000}, ] vec = extract_transaction_features({"transactions": txs}) # dim 20 = counterparty diversity, should be > 0 assert vec[20] > 0, "Counterparty diversity should be > 0" @test("extract_wallet_features: returns 64-dim vector") def test_wallet_features_dims(): from app.crypto_embeddings import extract_wallet_features vec = extract_wallet_features({"labels": ["scammer"], "balance_usd": 5000}) assert len(vec) == 64, f"Expected 64 dims, got {len(vec)}" @test("extract_wallet_features: detects risk labels") def test_wallet_features_labels(): from app.crypto_embeddings import extract_wallet_features vec = extract_wallet_features({"labels": ["scammer", "rug_puller"]}) # dim 20 = scammer, dim 21 = rug_puller assert vec[20] == 1.0, "Scammer label not detected at dim 20" assert vec[21] == 1.0, "Rug_puller label not detected at dim 21" # ══════════════════════════════════════════════════════════════════════ # COSINE SIMILARITY # ══════════════════════════════════════════════════════════════════════ @test("cosine_similarity: identical vectors = 1.0") def test_cosine_same(): from app.crypto_embeddings import CryptoEmbedder v = [1.0, 0.0, 0.5, 0.3] sim = CryptoEmbedder.cosine_similarity(v, v) assert abs(sim - 1.0) < 0.001, f"Same vector sim={sim}" @test("cosine_similarity: orthogonal vectors = 0.0") def test_cosine_orthogonal(): from app.crypto_embeddings import CryptoEmbedder a = [1.0, 0.0, 0.0] b = [0.0, 1.0, 0.0] sim = CryptoEmbedder.cosine_similarity(a, b) assert abs(sim) < 0.001, f"Orthogonal sim={sim}" @test("cosine_similarity: opposite vectors = -1.0") def test_cosine_opposite(): from app.crypto_embeddings import CryptoEmbedder a = [1.0, 0.0] b = [-1.0, 0.0] sim = CryptoEmbedder.cosine_similarity(a, b) assert abs(sim - (-1.0)) < 0.001, f"Opposite sim={sim}" @test("cosine_similarity: zero vector = 0.0") def test_cosine_zero(): from app.crypto_embeddings import CryptoEmbedder sim = CryptoEmbedder.cosine_similarity([0, 0, 0], [1, 0, 0]) assert sim == 0.0, f"Zero vector sim={sim}" # ══════════════════════════════════════════════════════════════════════ # HASH EMBEDDING (no API needed) # ══════════════════════════════════════════════════════════════════════ @test("hash_embed: returns 384-dim normalized vector") def test_hash_embed(): from app.crypto_embeddings import CryptoEmbedder embedder = CryptoEmbedder() vec = embedder._hash_embed("test wallet scam pattern") assert len(vec) == 384, f"Expected 384 dims, got {len(vec)}" # Should be L2-normalized import numpy as np norm = np.linalg.norm(vec) assert abs(norm - 1.0) < 0.01 or norm == 0.0, f"Norm={norm}" @test("hash_embed: deterministic (same input = same output)") def test_hash_embed_deterministic(): from app.crypto_embeddings import CryptoEmbedder embedder = CryptoEmbedder() v1 = embedder._hash_embed("rug pull honeypot") v2 = embedder._hash_embed("rug pull honeypot") assert v1 == v2, "Hash embedding not deterministic" @test("hash_embed: different inputs = different outputs") def test_hash_embed_unique(): from app.crypto_embeddings import CryptoEmbedder embedder = CryptoEmbedder() v1 = embedder._hash_embed("rug pull") v2 = embedder._hash_embed("wash trading") assert v1 != v2, "Different inputs produced same hash embedding" # ══════════════════════════════════════════════════════════════════════ # BUNDLE/CLUSTER BEHAVIORAL EMBEDDING (pure functions) # ══════════════════════════════════════════════════════════════════════ @test("embed_bundle_profile: returns 128-dim vector") def test_bundle_profile(): from app.bundle_cluster_rag import embed_bundle_profile bundle = { "confidence": 0.8, "atomic_block_score": 0.9, "common_funder_score": 0.7, "wallets_in_earliest_block": 15, "chain": "solana", } vec = embed_bundle_profile(bundle) assert len(vec) == 128, f"Expected 128 dims, got {len(vec)}" @test("embed_bundle_profile: metrics at dims 80-85 NOT overwritten by hash") def test_bundle_metrics_not_clobbered(): from app.bundle_cluster_rag import embed_bundle_profile bundle = { "confidence": 0.9, "avg_buy_amount": 5000, "max_buy_amount": 10000, "profit_ratio": 5.0, "chain": "ethereum", } vec = embed_bundle_profile(bundle) # Dim 80 = avg_buy_amount/10000 = 0.5, should NOT be zero (overwritten by hash) assert vec[80] > 0, f"Metric at dim 80 is zero (clobbered by hash): {vec[80]}" assert vec[81] > 0, f"Metric at dim 81 is zero (clobbered by hash): {vec[81]}" @test("embed_cluster_profile: returns 192-dim vector") def test_cluster_profile(): from app.bundle_cluster_rag import embed_cluster_profile cluster = { "size": 50, "density": 0.8, "total_volume_usd": 1000000, "scam_probability": 0.9, "active_chains": ["ethereum"], } vec = embed_cluster_profile(cluster) assert len(vec) == 192, f"Expected 192 dims, got {len(vec)}" @test("embed_cluster_profile: risk scoring at dims 80-85") def test_cluster_risk_scoring(): from app.bundle_cluster_rag import embed_cluster_profile cluster = {"scam_probability": 0.9, "rug_probability": 0.8} vec = embed_cluster_profile(cluster) assert abs(vec[80] - 0.9) < 0.01, f"Scam prob at dim 80 wrong: {vec[80]}" assert abs(vec[81] - 0.8) < 0.01, f"Rug prob at dim 81 wrong: {vec[81]}" # ══════════════════════════════════════════════════════════════════════ # ENTITY EXTRACTION (pure regex, no API) # ══════════════════════════════════════════════════════════════════════ @test("extract_entities: EVM address detection") def test_entity_evm(): from app.entity_extraction import extract_entities result = extract_entities("Send funds to 0xdAC17F958D2ee523a2206206994597C13D831ec7") assert "0xdac17f958d2ee523a2206206994597c13d831ec7" in [ a.lower() for a in result.evm_addresses ], f"EVM address not found: {result.evm_addresses}" @test("extract_entities: token symbol detection") def test_entity_symbol(): from app.entity_extraction import extract_entities result = extract_entities("Price of $ETH and $SOL surging") symbols_upper = [s.upper() for s in result.token_symbols] assert "ETH" in symbols_upper or "$ETH" in result.token_symbols, ( f"ETH not found: {result.token_symbols}" ) assert "SOL" in symbols_upper or "$SOL" in result.token_symbols, ( f"SOL not found: {result.token_symbols}" ) @test("extract_entities: chain name detection") def test_entity_chain(): from app.entity_extraction import extract_entities result = extract_entities("Bridged from Ethereum to Solana") assert "ethereum" in result.chain_names, f"Ethereum not found: {result.chain_names}" assert "solana" in result.chain_names, f"Solana not found: {result.chain_names}" @test("extract_entities: scam keyword detection") def test_entity_scam(): from app.entity_extraction import extract_entities result = extract_entities("This token is a rug pull honeypot") assert "rug pull" in result.scam_keywords, f"rug pull not found: {result.scam_keywords}" @test("extract_entities: ENS domain detection") def test_entity_ens(): from app.entity_extraction import extract_entities result = extract_entities("Send to vitalik.eth") assert "vitalik.eth" in result.ens_domains, f"ENS not found: {result.ens_domains}" @test("extract_entities: empty input returns nothing") def test_entity_empty(): from app.entity_extraction import extract_entities result = extract_entities("Hello world this is a test") assert result.total_count == 0, f"Expected 0 entities, got {result.total_count}" # ══════════════════════════════════════════════════════════════════════ # TEMPORAL DECAY (pure math) # ══════════════════════════════════════════════════════════════════════ @test("compute_decay: fresh content = 1.0") def test_decay_fresh(): from app.temporal_decay import compute_decay assert compute_decay(0, 30) == 1.0 assert compute_decay(0, 365) == 1.0 @test("compute_decay: infinite half-life = 1.0 always") def test_decay_infinite(): from app.temporal_decay import compute_decay assert compute_decay(1000, float("inf")) == 1.0 assert compute_decay(0.1, float("inf")) == 1.0 @test("compute_decay: at half-life, score ~= 0.5") def test_decay_halflife(): from app.temporal_decay import compute_decay score = compute_decay(30.0, 30.0) # 30 days old, 30-day half-life assert abs(score - 0.5) < 0.01, f"At half-life, expected ~0.5, got {score}" @test("compute_decay: older = lower score") def test_decay_monotonic(): from app.temporal_decay import compute_decay s1 = compute_decay(10, 30) s2 = compute_decay(30, 30) s3 = compute_decay(60, 30) assert s1 > s2 > s3, f"Not monotonically decreasing: {s1} > {s2} > {s3}" @test("apply_temporal_decay: adds decay fields to results") def test_apply_decay_fields(): from datetime import datetime from app.temporal_decay import apply_temporal_decay results = [ { "similarity": 0.8, "collection": "news_articles", "stored_at": datetime.now(UTC).isoformat(), }, { "similarity": 0.7, "collection": "forensic_reports", "stored_at": "2023-01-01T00:00:00+00:00", }, ] decayed = apply_temporal_decay(results) assert "raw_similarity" in decayed[0], "Missing raw_similarity field" assert "decay_factor" in decayed[0], "Missing decay_factor field" assert "age_days" in decayed[0], "Missing age_days field" # Forensic report should keep 100% score (never decays) assert decayed[1]["decay_factor"] == 1.0, "Forensic report should never decay" @test("get_half_life: forensic_reports = infinite") def test_halflife_forensic(): from app.temporal_decay import get_half_life hl = get_half_life("forensic_reports") assert hl == float("inf"), f"forensic_reports half-life should be inf, got {hl}" @test("get_half_life: news_articles = 30 days") def test_halflife_news(): from app.temporal_decay import get_half_life hl = get_half_life("news_articles") assert hl == 30.0, f"news_articles half-life should be 30, got {hl}" # ══════════════════════════════════════════════════════════════════════ # CONTEXTUAL CHUNKING # ══════════════════════════════════════════════════════════════════════ @test("chunk_document: returns chunks with correct structure") def test_chunk_document(): from app.contextual_chunking import chunk_document text = "First paragraph.\n\nSecond paragraph. " * 100 chunks = chunk_document(text, chunk_size=500, overlap=50) assert len(chunks) > 1, "Should produce multiple chunks" for c in chunks: assert hasattr(c, "content"), "Chunk missing content" assert hasattr(c, "index"), "Chunk missing index" assert len(c.content) > 0, "Chunk has empty content" @test("chunk_document: single short doc produces one chunk") def test_chunk_single(): from app.contextual_chunking import chunk_document chunks = chunk_document("Short text.", chunk_size=2500) assert len(chunks) == 1, f"Short text should be 1 chunk, got {len(chunks)}" @test("chunk_document: respects boundaries") def test_chunk_boundaries(): from app.contextual_chunking import chunk_document text = "# Section 1\n\nFirst section content here. " * 50 chunks = chunk_document(text, chunk_size=500, overlap=50, respect_boundaries=True) # Should not cut mid-sentence for _c in chunks: # Content should not start mid-word (allowing for overlap) pass # Basic validation that chunking completed @test("heuristic context: generates context without LLM") def test_heuristic_context(): from app.contextual_chunking import _generate_heuristic_context ctx = _generate_heuristic_context( "# DeFi Analysis\n\nFull document text...", "chunk about exploits", 0, 5 ) assert "DeFi Analysis" in ctx, f"Title not in context: {ctx}" assert "Chunk 1 of 5" in ctx, f"Position not in context: {ctx}" @test("parent_child_chunk: creates parent+child chunks") def test_parent_child(): from app.contextual_chunking import parent_child_chunk text = "First paragraph with enough text. " * 200 chunks = parent_child_chunk(text, parent_size=500, child_size=200) parents = [c for c in chunks if c.metadata.get("is_parent")] children = [c for c in chunks if c.parent_id is not None] assert len(parents) > 0, "No parent chunks created" assert len(children) > 0, "No child chunks created" assert all(c.parent_content for c in children), "Children missing parent content" # ══════════════════════════════════════════════════════════════════════ # EMBEDDING CACHE (requires Redis) # ══════════════════════════════════════════════════════════════════════ @test("embedding cache: _cache_key generates deterministic keys") async def test_cache_key(): from app.crypto_embeddings import CryptoEmbedder embedder = CryptoEmbedder() key1 = await embedder._cache_key("semantic", "test text") key2 = await embedder._cache_key("semantic", "test text") assert key1 == key2, f"Cache keys not deterministic: {key1} != {key2}" @test("embedding cache: different heads generate different keys") async def test_cache_key_different_heads(): from app.crypto_embeddings import CryptoEmbedder embedder = CryptoEmbedder() key1 = await embedder._cache_key("semantic", "test") key2 = await embedder._cache_key("code", "test") assert key1 != key2, f"Different heads should have different cache keys: {key1} == {key2}" # ══════════════════════════════════════════════════════════════════════ # SUPABASE VECTOR STORE - SQL SAFETY (unit-level, no network) # ══════════════════════════════════════════════════════════════════════ @test("_get_dim: auto-detects dimension from first embedding") def test_get_dim_auto(): from app.supabase_vector import SupabaseVectorStore store = SupabaseVectorStore() # EMBEDDING_DIM=0 means auto-detect dim = store._get_dim([0.1] * 384) assert dim == 384, f"Auto-detect should return 384, got {dim}" @test("_get_dim: caches resolved dimension") def test_get_dim_caches(): from app.supabase_vector import SupabaseVectorStore store = SupabaseVectorStore() dim1 = store._get_dim([0.1] * 512) assert dim1 == 512, f"First call should return 512, got {dim1}" dim2 = store._get_dim([0.1] * 384) # different length, but cached assert dim2 == 512, f"Should return cached 512, got {dim2}" @test("_get_dim: fallback to 384 when no embedding provided") def test_get_dim_fallback(): from app.supabase_vector import SupabaseVectorStore store = SupabaseVectorStore() dim = store._get_dim() # no embedding, no env var assert dim == 384, f"Default should be 384, got {dim}" # ══════════════════════════════════════════════════════════════════════ # KNOWN SCAM PATTERNS DATA # ══════════════════════════════════════════════════════════════════════ @test("KNOWN_SCAM_PATTERNS: has required fields") def test_scam_patterns_structure(): from app.crypto_embeddings import KNOWN_SCAM_PATTERNS assert len(KNOWN_SCAM_PATTERNS) >= 10, ( f"Expected >= 10 patterns, got {len(KNOWN_SCAM_PATTERNS)}" ) for p in KNOWN_SCAM_PATTERNS: assert "name" in p, f"Pattern missing name: {p}" assert "description" in p, f"Pattern missing description: {p}" assert "severity" in p, f"Pattern missing severity: {p}" assert p["severity"] in ("low", "medium", "high", "critical"), ( f"Invalid severity: {p['severity']}" ) @test("CLUSTER_LABEL_TEMPLATES: has 10 templates") def test_cluster_labels(): from app.bundle_cluster_rag import CLUSTER_LABEL_TEMPLATES assert len(CLUSTER_LABEL_TEMPLATES) == 10, ( f"Expected 10 templates, got {len(CLUSTER_LABEL_TEMPLATES)}" ) labels = [t["label"] for t in CLUSTER_LABEL_TEMPLATES] assert "insider_trading_ring" in labels, "Missing insider_trading_ring" assert "wash_trading_farm" in labels, "Missing wash_trading_farm" assert "mev_bot_network" in labels, "Missing mev_bot_network" # ══════════════════════════════════════════════════════════════════════ # RAG SERVICE - TTL MAP # ══════════════════════════════════════════════════════════════════════ @test("TTL map: forensic_reports and scam_patterns have permanent TTL (0)") def test_ttl_permanent(): # Read the TTL map from ingest_document function # We verify by inspecting the source - TTL=0 means no expiry import inspect from app.rag_service import ingest_document source = inspect.getsource(ingest_document) assert "forensic_reports" in source, "forensic_reports missing from TTL logic" assert "scam_patterns" in source, "scam_patterns missing from TTL logic" # ══════════════════════════════════════════════════════════════════════ # RERANK MODEL CONFIG # ══════════════════════════════════════════════════════════════════════ @test("Rerank and analysis models are env-configurable") def test_model_config(): from app.rag_agentic import ANALYSIS_MODEL, RERANK_MODEL # Should have defaults assert RERANK_MODEL, "RERANK_MODEL should not be empty" assert ANALYSIS_MODEL, "ANALYSIS_MODEL should not be empty" # Should not be the old hardcoded broken names assert "claude-sonnet-4" not in ANALYSIS_MODEL or "20250514" in ANALYSIS_MODEL, ( f"ANALYSIS_MODEL should use dated model, got: {ANALYSIS_MODEL}" ) # ══════════════════════════════════════════════════════════════════════ # COLLECTIONS LIST # ══════════════════════════════════════════════════════════════════════ @test("COLLECTIONS list includes all expected collections") def test_collections(): from app.crypto_embeddings import COLLECTIONS expected = [ "wallet_profiles", "token_analysis", "scam_patterns", "forensic_reports", "market_intel", "contract_audits", "known_scams", "news_articles", "transaction_patterns", ] for coll in expected: assert coll in COLLECTIONS, f"Missing collection: {coll}" # ══════════════════════════════════════════════════════════════════════ # ANN INDEX (FAISS) # ══════════════════════════════════════════════════════════════════════ @test("ANNIndex: build_index creates in-memory index") async def test_ann_build(): from app.ann_index import ANNIndex idx = ANNIndex() meta = await idx.build_index("scam_patterns") assert meta.get("status") == "built", f"Expected built, got {meta}" assert meta.get("n", 0) > 0, f"Expected docs > 0, got {meta.get('n')}" @test("ANNIndex: search returns hydrated results with content") async def test_ann_search(): from app.ann_index import ANNIndex from app.crypto_embeddings import get_embedder idx = ANNIndex() await idx.build_index("scam_patterns") embedder = await get_embedder() query_vec = await embedder.embed_query("honeypot token") results = await idx.search(query_vec, "scam_patterns", limit=3, min_similarity=0.3) assert len(results) > 0, "No results returned" first = results[0] assert "id" in first, "Missing id" assert "similarity" in first, "Missing similarity" assert "content" in first, "Missing content (hydration failed)" @test("ANNIndex: search is sub-second for small collections") async def test_ann_speed(): import time from app.ann_index import ANNIndex from app.crypto_embeddings import get_embedder idx = ANNIndex() await idx.build_index("scam_patterns") embedder = await get_embedder() query_vec = await embedder.embed_query("rug pull") t0 = time.time() await idx.search(query_vec, "scam_patterns", limit=5, min_similarity=0.3) t1 = time.time() assert (t1 - t0) < 1.0, f"Search took {t1 - t0:.2f}s, expected < 1s" @test("ANNIndex: stats returns collection info") async def test_ann_stats(): from app.ann_index import ANNIndex idx = ANNIndex() await idx.build_index("scam_patterns") s = idx.stats() assert "scam_patterns" in s, f"scam_patterns not in stats: {s}" assert s["scam_patterns"].get("n", 0) > 0, "No vectors in stats" # ══════════════════════════════════════════════════════════════════════ # SEMANTIC CACHE # ══════════════════════════════════════════════════════════════════════ @test("SemanticCache: store and check returns hit") async def test_semantic_cache_hit(): from app.semantic_cache import SemanticCache cache = SemanticCache() vec = [0.1] * 384 # dummy vector results = [{"id": "test1", "similarity": 0.9, "content": "test doc"}] await cache.store(vec, results) cached = await cache.check(vec) assert cached is not None, "Expected cache hit, got None" assert len(cached) == 1, f"Expected 1 cached result, got {len(cached)}" @test("SemanticCache: different vector returns miss") async def test_semantic_cache_miss(): from app.semantic_cache import SemanticCache cache = SemanticCache() vec1 = [1.0] + [0.0] * 383 # orthogonal await cache.store(vec1, [{"id": "x"}]) vec2 = [0.0] + [1.0] * 1 + [0.0] * 382 # very different cached = await cache.check(vec2) assert cached is None, f"Expected cache miss, got hit: {cached}" @test("SemanticCache: stats returns hit rate") async def test_semantic_cache_stats(): from app.semantic_cache import SemanticCache cache = SemanticCache() s = await cache.stats() assert "entries" in s, f"Missing entries in stats: {s}" assert "hit_rate" in s, f"Missing hit_rate in stats: {s}" # ══════════════════════════════════════════════════════════════════════ # THREE-PILLAR HYBRID SEARCH # ══════════════════════════════════════════════════════════════════════ @test("three_pillar_search: returns results with pillar attribution") async def test_three_pillar(): from app.rag_service import three_pillar_search result = await three_pillar_search( "honeypot token with high sell tax", collections=["scam_patterns"], limit=5, ) assert "results" in result, f"Missing results key: {list(result.keys())}" assert "pillar_summary" in result, "Missing pillar_summary" ps = result["pillar_summary"] assert "dense_hits" in ps, "Missing dense_hits" assert "sparse_hits" in ps, "Missing sparse_hits" assert "pillars_used" in ps, "Missing pillars_used" @test("three_pillar_search: entity extraction from query") async def test_three_pillar_entity(): from app.rag_service import three_pillar_search result = await three_pillar_search( "token on solana and ethereum", collections=["scam_patterns"], limit=3, ) ee = result.get("entity_extraction") if ee: chains = ee.get("chain_names", []) assert "solana" in chains or "ethereum" in chains, f"Expected chain names, got: {chains}" @test("three_pillar_search: RRF fusion produces ranked results") async def test_three_pillar_rrf(): from app.rag_service import three_pillar_search result = await three_pillar_search( "rug pull honeypot", collections=["scam_patterns"], limit=5, ) results = result.get("results", []) assert len(results) > 0, "No results from three-pillar search" # Results should have match_type showing pillar attribution for r in results[:2]: assert "match_type" in r or "pillars" in r, f"Missing pillar attribution: {list(r.keys())}" # ══════════════════════════════════════════════════════════════════════ # QUERY TRANSFORMATION # ══════════════════════════════════════════════════════════════════════ @test("query_transform: expand adds crypto synonyms") async def test_query_expand(): from app.query_transform import expand_query variants = await expand_query("rug pull token on ethereum") assert len(variants) >= 3, f"Expected >=3 variants, got {len(variants)}: {variants}" # Should contain synonym expansions variants_lower = [v.lower() for v in variants] assert any( "honeypot" in v or "liquidity drain" in v or "exit scam" in v for v in variants_lower ), f"No crypto synonyms found in: {variants}" @test("query_transform: step_back generalizes questions") async def test_query_step_back(): from app.query_transform import step_back_query result = await step_back_query("Is $SOL a rug pull?") # Should produce a broader query assert "rug pull" in result.lower() or "token" in result.lower(), ( f"Step-back didn't generalize: {result}" ) @test("query_transform: auto router picks correct strategy") async def test_query_auto_route(): from app.query_transform import transform_query # Specific entity (address) → should pick expand or step_back tq = await transform_query("0xdAC17F958D2ee523a2206206994597C13D831ec7 scam", strategy="auto") assert tq.strategy != "none", f"Entity query should not be 'none', got {tq.strategy}" assert len(tq.transformed_queries) >= 1, f"Should have transforms: {tq.transformed_queries}" @test("query_transform: short factual queries pass through") async def test_query_passthrough(): from app.query_transform import transform_query tq = await transform_query("rug pull", strategy="none") assert tq.strategy == "none" assert tq.transformed_queries == ["rug pull"] # ══════════════════════════════════════════════════════════════════════ # RAGAS EVALUATION # ══════════════════════════════════════════════════════════════════════ @test("ragas_eval: golden test set has entries") def test_ragas_golden_set(): from app.ragas_eval import GOLDEN_TEST_SET assert len(GOLDEN_TEST_SET) >= 40, ( f"Expected >=40 golden test pairs, got {len(GOLDEN_TEST_SET)}" ) for entry in GOLDEN_TEST_SET: assert "query" in entry, f"Missing query field: {entry}" assert "collection" in entry, f"Missing collection field: {entry}" @test("ragas_eval: context_precision computes nDCG") def test_ragas_ndcg(): from app.ragas_eval import context_precision # Perfect ranking prec = context_precision(["a", "b", "c"], ["a", "b", "c"], k=5) assert prec == 1.0, f"Perfect ranking should be 1.0, got {prec}" # Empty results prec0 = context_precision([], ["a", "b"], k=5) assert prec0 == 0.0, f"Empty results should be 0.0, got {prec0}" @test("ragas_eval: hit_rate computes correctly") def test_ragas_hit_rate(): from app.ragas_eval import hit_rate hr = hit_rate(["a", "b", "c"], ["b"]) assert hr == 1.0, f"Hit should be 1.0 when relevant doc in results, got {hr}" hr0 = hit_rate(["x", "y"], ["b"]) assert hr0 == 0.0, f"Hit should be 0.0 when no relevant doc, got {hr0}" @test("ragas_eval: mrr computes correctly") def test_ragas_mrr(): from app.ragas_eval import mrr mr = mrr(["x", "b", "y"], ["b"]) assert abs(mr - 0.5) < 0.01, f"MRR for rank-2 hit should be 0.5, got {mr}" mr0 = mrr(["x", "y"], ["b"]) assert mr0 == 0.0, f"MRR for no hit should be 0.0, got {mr0}" # ══════════════════════════════════════════════════════════════════════ # SCAM PATTERN CACHE # ══════════════════════════════════════════════════════════════════════ @test("scam pattern cache: pre_embed caches all 10 patterns") async def test_pattern_cache(): from app.crypto_embeddings import KNOWN_SCAM_PATTERNS from app.rag_service import _pattern_cache, _preembed_scam_patterns await _preembed_scam_patterns() assert len(_pattern_cache) == len(KNOWN_SCAM_PATTERNS), ( f"Expected {len(KNOWN_SCAM_PATTERNS)} cached, got {len(_pattern_cache)}" ) @test("scam pattern cache: cached patterns have embedding vectors") async def test_pattern_cache_vectors(): from app.rag_service import _pattern_cache, _preembed_scam_patterns await _preembed_scam_patterns() for name, result in _pattern_cache.items(): assert hasattr(result, "vector"), f"Pattern {name} missing vector attr" assert len(result.vector) > 0, f"Pattern {name} has empty vector" # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ if __name__ == "__main__": print("=" * 60) print(" RAG SYSTEM TEST SUITE") print("=" * 60) ok = asyncio.run(run_tests()) sys.exit(0 if ok else 1)