Spaces:
Sleeping
Sleeping
| """ | |
| Unit tests Layer 3 (Equity-Weighted Allocation). | |
| Section 5.5.4 — Modified Gale-Shapley + Greedy Top-K + Equity Multiplier. | |
| """ | |
| import pytest | |
| from matching_engine.allocation import ( | |
| allocate, determine_confidence, equity_multiplier_value, | |
| greedy_match_tier2, stable_match_tier1, | |
| ) | |
| from matching_engine.models import Confidence, ScoreBreakdown | |
| from matching_engine.scoring import compute_score | |
| # ============================================================================= | |
| # EQUITY MULTIPLIER | |
| # ============================================================================= | |
| class TestEquityMultiplier: | |
| """ | |
| Threshold kalibrasi BPS 2024 Jatim: | |
| <68 → 1.30 | <72 → 1.15 | <78 → 1.05 | ≥78 → 1.00 | |
| """ | |
| def test_ipm_below_68_gets_30pct_boost(self): | |
| """Tertinggal severe: Sampang 66.72, Bangkalan 67.70 (cluster Madura).""" | |
| assert equity_multiplier_value(60.0) == 1.30 | |
| assert equity_multiplier_value(66.72) == 1.30 # Sampang real | |
| assert equity_multiplier_value(67.70) == 1.30 # Bangkalan real | |
| assert equity_multiplier_value(67.99) == 1.30 | |
| def test_ipm_68_to_72_gets_15pct_boost(self): | |
| """Tertinggal: Sumenep 68.79, Bondowoso 69.62, Lumajang 70.10, Pamekasan 70.43.""" | |
| assert equity_multiplier_value(68.0) == 1.15 | |
| assert equity_multiplier_value(68.79) == 1.15 # Sumenep | |
| assert equity_multiplier_value(69.62) == 1.15 # Bondowoso | |
| assert equity_multiplier_value(70.10) == 1.15 # Lumajang | |
| assert equity_multiplier_value(70.43) == 1.15 # Pamekasan | |
| assert equity_multiplier_value(71.99) == 1.15 | |
| def test_ipm_72_to_78_gets_5pct_boost(self): | |
| """Menengah: Banyuwangi 73.45, Kediri kab 74.50, Tulungagung 75.30, Gresik 77.61.""" | |
| assert equity_multiplier_value(72.0) == 1.05 | |
| assert equity_multiplier_value(73.45) == 1.05 # Banyuwangi | |
| assert equity_multiplier_value(74.50) == 1.05 # Kediri kab | |
| assert equity_multiplier_value(77.61) == 1.05 # Gresik | |
| assert equity_multiplier_value(77.99) == 1.05 | |
| def test_ipm_78_or_above_no_boost(self): | |
| """Maju: Kota Batu 78.30, Sidoarjo 80.13, Kota Surabaya 84.69.""" | |
| assert equity_multiplier_value(78.0) == 1.00 | |
| assert equity_multiplier_value(78.30) == 1.00 # Kota Batu | |
| assert equity_multiplier_value(80.13) == 1.00 # Sidoarjo | |
| assert equity_multiplier_value(84.69) == 1.00 # Surabaya | |
| # ============================================================================= | |
| # CONFIDENCE LABELING | |
| # ============================================================================= | |
| class TestConfidence: | |
| def test_both_tier1_high_confidence(self, surabaya, kota_kediri, cabai_merah, make_supply, make_demand): | |
| s = make_supply(kota_kediri, cabai_merah) | |
| d = make_demand(surabaya, cabai_merah) | |
| assert determine_confidence(s, d) == Confidence.HIGH | |
| def test_cross_tier_medium(self, surabaya, kediri_kab, cabai_merah, make_supply, make_demand): | |
| s = make_supply(kediri_kab, cabai_merah) # Tier 2 | |
| d = make_demand(surabaya, cabai_merah) # Tier 1 | |
| assert determine_confidence(s, d) == Confidence.MEDIUM | |
| def test_both_tier2_medium(self, kediri_kab, sidoarjo, cabai_merah, make_supply, make_demand): | |
| s = make_supply(kediri_kab, cabai_merah) # Tier 2 | |
| d = make_demand(sidoarjo, cabai_merah) # Tier 2 | |
| assert determine_confidence(s, d) == Confidence.MEDIUM | |
| # ============================================================================= | |
| # STABLE MATCHING (TIER 1) | |
| # ============================================================================= | |
| class TestStableMatchTier1: | |
| def test_simple_one_to_one(self, surabaya, kota_kediri, cabai_merah, | |
| make_supply, make_demand, logistics_normal): | |
| s = make_supply(kota_kediri, cabai_merah, volume=50, price=30000) | |
| d = make_demand(surabaya, cabai_merah, volume=50, price=60000) | |
| candidates = [(s, d)] | |
| def score_fn(s_, d_): | |
| return compute_score(s_, d_, logistics=logistics_normal) | |
| results = stable_match_tier1(candidates, score_fn) | |
| assert len(results) == 1 | |
| assert results[0].surplus.kabupaten.id == kota_kediri.id | |
| assert results[0].deficit.kabupaten.id == surabaya.id | |
| assert results[0].confidence == Confidence.HIGH | |
| def test_higher_score_wins(self, surabaya, kota_kediri, kediri_kab, cabai_merah, | |
| make_supply, make_demand, logistics_normal): | |
| # Surabaya butuh cabai. Dua surplus dengan price difference besar. | |
| # Force both jadi Tier 1 untuk uji pure stable matching: | |
| from matching_engine.models import Tier | |
| kediri_kab.tier = Tier.HIGH | |
| # kota_kediri price 25k (sangat murah, arbitrage tinggi) | |
| # kediri_kab price 50k (mahal, arbitrage rendah) | |
| s_cheap = make_supply(kota_kediri, cabai_merah, volume=50, price=25000) | |
| s_expensive = make_supply(kediri_kab, cabai_merah, volume=50, price=50000) | |
| d = make_demand(surabaya, cabai_merah, volume=50, price=60000) | |
| candidates = [(s_cheap, d), (s_expensive, d)] | |
| def score_fn(s_, d_): | |
| return compute_score(s_, d_, logistics=logistics_normal) | |
| results = stable_match_tier1(candidates, score_fn) | |
| assert len(results) >= 1 | |
| # Best match harus s_cheap (price arbitrage jauh lebih besar) | |
| assert results[0].surplus.kabupaten.id == kota_kediri.id | |
| # ============================================================================= | |
| # GREEDY MATCHING (TIER 2) | |
| # ============================================================================= | |
| class TestGreedyMatchTier2: | |
| def test_equity_priority_kab_tertinggal_first(self, kediri_kab, sampang, sidoarjo, | |
| cabai_merah, make_supply, make_demand, | |
| logistics_normal): | |
| """ | |
| Scenario E1: dua deficit kab dengan IPM berbeda. | |
| Sampang (IPM 66.72, +15% boost) harus dapat priority over Sidoarjo (80.13, no boost). | |
| """ | |
| s = make_supply(kediri_kab, cabai_merah, volume=50, price=30000) | |
| d_sampang = make_demand(sampang, cabai_merah, volume=50, price=55000) | |
| d_sidoarjo = make_demand(sidoarjo, cabai_merah, volume=50, price=60000) | |
| candidates = [(s, d_sampang), (s, d_sidoarjo)] | |
| def score_fn(s_, d_): | |
| return compute_score(s_, d_, logistics=logistics_normal) | |
| results = greedy_match_tier2(candidates, score_fn) | |
| # Sampang harus jadi pemenang meskipun harga jualnya lebih rendah | |
| # (karena equity multiplier menggandakan score) | |
| assert len(results) >= 1 | |
| # First match harus ke Sampang | |
| first_match = results[0] | |
| assert first_match.deficit.kabupaten.id == sampang.id | |
| assert first_match.equity_multiplier == 1.30 # Sampang IPM 66.72 < 68 | |
| def test_volume_split_when_supply_larger(self, kediri_kab, sampang, bondowoso, cabai_merah, | |
| make_supply, make_demand, logistics_normal): | |
| # Surplus 100t bisa di-split ke 2 deficit @ 40t each | |
| s = make_supply(kediri_kab, cabai_merah, volume=100, price=30000) | |
| d1 = make_demand(sampang, cabai_merah, volume=40, price=55000) | |
| d2 = make_demand(bondowoso, cabai_merah, volume=40, price=55000) | |
| candidates = [(s, d1), (s, d2)] | |
| def score_fn(s_, d_): | |
| return compute_score(s_, d_, logistics=logistics_normal) | |
| results = greedy_match_tier2(candidates, score_fn) | |
| # Both deficits should be served | |
| assert len(results) == 2 | |
| deficit_ids = {r.deficit.kabupaten.id for r in results} | |
| assert deficit_ids == {sampang.id, bondowoso.id} | |
| # ============================================================================= | |
| # DISPATCHER | |
| # ============================================================================= | |
| class TestAllocateDispatcher: | |
| def test_force_strategy_stable(self, surabaya, kota_kediri, cabai_merah, | |
| make_supply, make_demand, logistics_normal): | |
| s = make_supply(kota_kediri, cabai_merah) | |
| d = make_demand(surabaya, cabai_merah) | |
| candidates = [(s, d)] | |
| def score_fn(s_, d_): | |
| return compute_score(s_, d_, logistics=logistics_normal) | |
| results = allocate(candidates, score_fn, force_strategy="stable") | |
| assert len(results) == 1 | |
| def test_force_strategy_greedy(self, surabaya, kota_kediri, cabai_merah, | |
| make_supply, make_demand, logistics_normal): | |
| s = make_supply(kota_kediri, cabai_merah) | |
| d = make_demand(surabaya, cabai_merah) | |
| candidates = [(s, d)] | |
| def score_fn(s_, d_): | |
| return compute_score(s_, d_, logistics=logistics_normal) | |
| results = allocate(candidates, score_fn, force_strategy="greedy") | |
| assert len(results) == 1 | |
| def test_auto_picks_greedy_for_cross_tier(self, surabaya, kediri_kab, cabai_merah, | |
| make_supply, make_demand, logistics_normal): | |
| # Cross-tier (Tier 2 → Tier 1) → harus pakai greedy | |
| s = make_supply(kediri_kab, cabai_merah) # Tier 2 | |
| d = make_demand(surabaya, cabai_merah) # Tier 1 | |
| candidates = [(s, d)] | |
| def score_fn(s_, d_): | |
| return compute_score(s_, d_, logistics=logistics_normal) | |
| results = allocate(candidates, score_fn) | |
| assert len(results) == 1 | |
| assert results[0].confidence == Confidence.MEDIUM | |