File size: 38,923 Bytes
bde2f3a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
#!/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)