agriflow-api / matching_engine /allocation.py
masterAAA123's picture
Space deploy v5: orphan snapshot of main, zero binary files
b81a86b
Raw
History Blame Contribute Delete
19.1 kB
"""
AgriFlow Matching Engine — Layer 3 (Equity-Weighted Allocation)
================================================================
Dua strategi allocation berdasarkan tier kabupaten:
Tier 1 (HIGH confidence) → Modified Gale-Shapley Stable Matching
- Deficit kab tertinggal proposes first
- Surplus accept proposal dengan FinalScore tertinggi
- Stability guarantee (Nobel Prize Economics 2012)
Tier 2 (MEDIUM confidence) → Greedy Top-K with Equity Priority
- Sort deficit by equity_multiplier descending
- Assign each ke top-scored available surplus
- Honest engineering: data ±15% error → stable matching guarantee
tidak meaningful, greedy lebih simple & debug-friendly
Cross-tier (Tier 1 surplus dengan Tier 2 deficit, atau sebaliknya):
Pakai approach Tier 2 (lower complexity respects lower data quality).
Author: AgriFlow Team
Version: 9.0
"""
from __future__ import annotations
from collections import defaultdict
from typing import Callable, Dict, List, Optional, Tuple
# Type alias for an injectable equity policy function.
# Signature: ipm (float) -> multiplier (float).
# The production default is equity_multiplier_value defined below.
EquityFn = Callable[[float], float]
from .models import (
Confidence, DemandNode, DemandSegment, MatchResult, ScoreBreakdown,
SupplyNode, Tier,
)
# =============================================================================
# EQUITY MULTIPLIER (Section 5.5.4 Step 3a)
# =============================================================================
def equity_multiplier_value(ipm: float) -> float:
"""
Equity boost berdasarkan IPM kabupaten — kalibrasi data BPS 2024 Jatim.
Threshold:
IPM < 68 → 1.30 (tertinggal severe — Sampang 66.72, Bangkalan 67.70)
IPM < 72 → 1.15 (tertinggal — Sumenep, Bondowoso, Probolinggo kab,
Lumajang, Pamekasan, Situbondo, Pasuruan kab,
Pacitan, Jember, Madiun kab)
IPM < 78 → 1.05 (menengah — Bojonegoro, Banyuwangi, Tulungagung,
Malang kab, Magetan, Gresik, Mojokerto, dll)
IPM ≥ 78 → 1.00 (maju — Sidoarjo, Surabaya, Kota Malang, dll)
Rasional kalibrasi:
Threshold lama (<65 → 1.30) tidak pernah ter-trigger dengan data 2024:
IPM terendah Jatim 2024 = Sampang 66.72. Threshold baru memastikan
klaim "+30% boost untuk kab tertinggal" konkret applicable ke
Sampang & Bangkalan (cluster Madura paling tertinggal).
Sumber IPM: BPS BRS Desember 2024.
"""
if ipm < 68:
return 1.30
elif ipm < 72:
return 1.15
elif ipm < 78:
return 1.05
else:
return 1.00
# =============================================================================
# F2 (v11 fix #2) — SEGMENT-AWARE MULTIPLIER
# =============================================================================
# Segment-aware adjustment ranges (kalibrasi konservatif, max swing ±10%
# supaya base_score 5-dim tetap dominan).
#
# HORECA (hotel/restoran/catering): high-volume contract buyers, margin-sensitive.
# - +5% bonus saat surplus >= 50t (bulk handling efficiency)
# - -3% penalty saat surplus < 5t (lots of micro-shipments not viable)
#
# GOVERNMENT (Bulog, sekolah, militer): compliance + reliability premium.
# - +5% bonus saat supplier Tier 1 (HIGH data confidence)
# - +3% bonus saat harvest_age == 0 (fresh, institutional consumers care)
#
# INDUSTRIAL (pabrik mie/tahu/tempe): raw bulk, processing-grade quality.
# - +8% bonus saat surplus >= 100t (bulk processing efficiency)
# - +2% bonus saat supplier Tier 2 (cheaper supply OK untuk processing)
#
# RETAIL (default): no adjustment — baseline.
SEGMENT_BONUS_HORECA_BULK_VOLUME_T = 50.0
SEGMENT_BONUS_INDUSTRIAL_BULK_VOLUME_T = 100.0
SEGMENT_PENALTY_HORECA_MICRO_VOLUME_T = 5.0
def _segment_priority_upper_bound(segment: DemandSegment) -> float:
"""
Upper-bound multiplier yang achievable untuk segment ini.
Dipakai oleh greedy allocator untuk sort deficits — segment dengan
bonus potensial lebih tinggi dapat priority earlier saat supply
contested. Tidak mengubah final_score, hanya order of processing.
"""
if segment == DemandSegment.HORECA:
return 1.05 # best case: bulk bonus
if segment == DemandSegment.GOVERNMENT:
return 1.05 * 1.03 # tier1 × fresh = 1.0815
if segment == DemandSegment.INDUSTRIAL:
return 1.08 * 1.02 # bulk × tier2-OK = 1.1016
return 1.00 # RETAIL baseline
def segment_multiplier_value(s: SupplyNode, d: DemandNode) -> tuple[float, list[str]]:
"""
F2 v11: hitung segment-aware multiplier untuk pair (supply, demand).
Return (multiplier, applied_flags).
Multiplier range: [0.97, 1.10]. RETAIL = 1.00 baseline.
Pure function — tidak modify supply/demand. Multiplier dapat di-audit
via flag list (HORECA_BULK_BONUS, GOVERNMENT_TIER1_BONUS, dll), supaya
Pemda/juri dapat trace kenapa segment X menang atas RETAIL.
"""
mult = 1.00
flags: list[str] = []
if d.segment == DemandSegment.RETAIL:
return mult, flags
if d.segment == DemandSegment.HORECA:
if s.volume_tons >= SEGMENT_BONUS_HORECA_BULK_VOLUME_T:
mult *= 1.05
flags.append("SEGMENT_HORECA_BULK_BONUS")
if s.volume_tons < SEGMENT_PENALTY_HORECA_MICRO_VOLUME_T:
mult *= 0.97
flags.append("SEGMENT_HORECA_MICRO_PENALTY")
elif d.segment == DemandSegment.GOVERNMENT:
if s.kabupaten.is_tier1:
mult *= 1.05
flags.append("SEGMENT_GOVERNMENT_TIER1_BONUS")
if s.harvest_age_days == 0:
mult *= 1.03
flags.append("SEGMENT_GOVERNMENT_FRESH_BONUS")
elif d.segment == DemandSegment.INDUSTRIAL:
if s.volume_tons >= SEGMENT_BONUS_INDUSTRIAL_BULK_VOLUME_T:
mult *= 1.08
flags.append("SEGMENT_INDUSTRIAL_BULK_BONUS")
if not s.kabupaten.is_tier1:
# Industrial dapat terima supply Tier 2 (processing-grade) lebih flexibly
mult *= 1.02
flags.append("SEGMENT_INDUSTRIAL_TIER2_OK")
return mult, flags
def determine_confidence(s: SupplyNode, d: DemandNode) -> Confidence:
"""
Confidence label untuk satu match.
Both Tier 1 → HIGH
Cross-tier atau both Tier 2 → MEDIUM
Kalau ada flag stale data → LOW (di-handle terpisah di engine.py)
"""
if s.kabupaten.tier == Tier.HIGH and d.kabupaten.tier == Tier.HIGH:
return Confidence.HIGH
return Confidence.MEDIUM
# =============================================================================
# TIER 1 — MODIFIED GALE-SHAPLEY STABLE MATCHING
# =============================================================================
def stable_match_tier1(
candidates: List[Tuple[SupplyNode, DemandNode]],
score_fn: Callable[[SupplyNode, DemandNode], Tuple[ScoreBreakdown, float, float]],
*,
equity_fn: EquityFn = equity_multiplier_value,
) -> List[MatchResult]:
"""
Modified Gale-Shapley untuk Tier 1.
Args:
candidates: pasangan viable dari Layer 1 (semua sudah lolos hard constraint)
score_fn: fungsi yang return (breakdown, base_score, distance_km)
biasanya scoring.compute_score yang sudah di-partial
Returns:
List MatchResult — setiap surplus matched dengan satu deficit terbaik.
Algorithm:
1. Hitung FinalScore untuk semua candidate
2. Build preference list per deficit: surplus diranking by FinalScore
3. Sort deficits by equity_multiplier descending (tertinggal proposes first)
4. Untuk setiap deficit: propose ke surplus pilihan tertingginya
- Kalau surplus belum matched → accept
- Kalau sudah matched dengan deficit X tapi current score > score X → switch
- Else: deficit lanjut ke pilihan berikutnya
Time complexity: O(n²) worst case, n = jumlah pair candidate.
Library reference: pip install matching (game-theoretic algorithms).
"""
# Step 1: Hitung skor untuk semua candidate
score_cache: Dict[Tuple[str, str], Tuple[ScoreBreakdown, float, float]] = {}
final_scores: Dict[Tuple[str, str], float] = {}
segment_cache: Dict[Tuple[str, str], Tuple[float, list[str]]] = {}
for s, d in candidates:
key = (s.kabupaten.id + "_" + s.commodity.code, d.kabupaten.id)
breakdown, base, dist = score_fn(s, d)
score_cache[key] = (breakdown, base, dist)
eq_mult = equity_fn(d.kabupaten.ipm)
seg_mult, _seg_flags = segment_multiplier_value(s, d)
segment_cache[key] = (seg_mult, _seg_flags)
final_scores[key] = base * eq_mult * seg_mult
# Step 2: Build preference per deficit (surplus diranking by FinalScore desc)
deficit_prefs: Dict[str, List[Tuple[float, SupplyNode, DemandNode]]] = defaultdict(list)
for s, d in candidates:
key = (s.kabupaten.id + "_" + s.commodity.code, d.kabupaten.id)
deficit_prefs[d.kabupaten.id + "_" + d.commodity.code].append(
(final_scores[key], s, d)
)
for k in deficit_prefs:
deficit_prefs[k].sort(key=lambda x: -x[0]) # descending
# Step 3: Sort deficit kabs by equity multiplier descending
unique_deficits: Dict[str, DemandNode] = {}
for _s, d in candidates:
unique_deficits[d.kabupaten.id + "_" + d.commodity.code] = d
proposers = sorted(
unique_deficits.items(),
key=lambda kv: -equity_fn(kv[1].kabupaten.ipm),
)
# Step 4: Gale-Shapley iteration
# surplus_id_commodity → (current_match_deficit, current_final_score)
surplus_match: Dict[str, Tuple[DemandNode, float]] = {}
deficit_unmatched: set[str] = {k for k, _ in proposers}
# Round-robin: setiap deficit yang belum matched coba propose ke pilihan berikutnya
deficit_proposal_idx: Dict[str, int] = defaultdict(int)
max_iterations = len(unique_deficits) * 10 # safety
iteration = 0
while deficit_unmatched and iteration < max_iterations:
iteration += 1
progressed = False
for d_key, d in list(proposers):
if d_key not in deficit_unmatched:
continue
prefs = deficit_prefs[d_key]
idx = deficit_proposal_idx[d_key]
if idx >= len(prefs):
# exhausted preference list → tetap unmatched
deficit_unmatched.discard(d_key)
continue
score, s, _d = prefs[idx]
s_key = s.kabupaten.id + "_" + s.commodity.code
if s_key not in surplus_match:
# surplus belum matched → accept
surplus_match[s_key] = (d, score)
deficit_unmatched.discard(d_key)
deficit_proposal_idx[d_key] = idx + 1
progressed = True
else:
_existing_d, existing_score = surplus_match[s_key]
if score > existing_score:
# current proposal lebih baik → switch
existing_d_key = (
_existing_d.kabupaten.id + "_" + _existing_d.commodity.code
)
surplus_match[s_key] = (d, score)
deficit_unmatched.discard(d_key)
deficit_unmatched.add(existing_d_key) # existing kembali ke pool
deficit_proposal_idx[d_key] = idx + 1
progressed = True
else:
# rejected → coba pilihan berikutnya
deficit_proposal_idx[d_key] = idx + 1
progressed = True
if not progressed:
break
# Step 5: Build MatchResult
results: List[MatchResult] = []
# Re-find original surplus from candidates
surplus_lookup: Dict[str, SupplyNode] = {}
for s, _d in candidates:
surplus_lookup[s.kabupaten.id + "_" + s.commodity.code] = s
for s_key, (d, fscore) in surplus_match.items():
s = surplus_lookup[s_key]
cache_key = (s_key, d.kabupaten.id)
if cache_key not in score_cache:
continue
breakdown, base_score, dist_km = score_cache[cache_key]
eq_mult = equity_fn(d.kabupaten.ipm)
seg_mult, seg_flags = segment_cache.get(cache_key, (1.0, []))
results.append(MatchResult(
surplus=s, deficit=d,
matched_volume_tons=min(s.volume_tons, d.volume_tons),
distance_km=dist_km,
base_score=base_score,
equity_multiplier=eq_mult,
segment_multiplier=seg_mult,
final_score=fscore,
confidence=determine_confidence(s, d),
breakdown=breakdown,
flags=list(seg_flags), # segment audit flags
))
# Sort by final_score descending
results.sort(key=lambda r: -r.final_score)
return results
# =============================================================================
# TIER 2 — GREEDY TOP-K WITH EQUITY PRIORITY
# =============================================================================
def greedy_match_tier2(
candidates: List[Tuple[SupplyNode, DemandNode]],
score_fn: Callable[[SupplyNode, DemandNode], Tuple[ScoreBreakdown, float, float]],
*,
equity_fn: EquityFn = equity_multiplier_value,
) -> List[MatchResult]:
"""
Greedy multi-objective dengan equity priority untuk Tier 2.
Algorithm:
1. Group candidate pairs (per komoditas)
2. Sort deficit by equity_multiplier descending (tertinggal first)
3. Untuk setiap deficit: pilih top-scored available surplus
4. Surplus dengan volume <= deficit volume di-remove dari pool
(1 surplus bisa di-split ke beberapa deficit kalau volume cukup)
Time complexity: O(n log n).
Why greedy untuk Tier 2:
Data ±15% error → stable matching guarantee tidak meaningful.
Greedy: simpler, debug-friendly, aligns dengan honest engineering.
"""
# Hitung skor + cache distance
score_cache: Dict[Tuple[str, str], Tuple[ScoreBreakdown, float, float]] = {}
for s, d in candidates:
key = (s.kabupaten.id + "_" + s.commodity.code, d.kabupaten.id)
if key not in score_cache:
score_cache[key] = score_fn(s, d)
# Group candidates by deficit. v11 fix #2: include segment in key so
# HORECA + RETAIL demands at same (kab, commodity) don't collapse —
# tiap segment di-allocate independent.
by_deficit: Dict[str, Tuple[DemandNode, List[SupplyNode]]] = {}
for s, d in candidates:
d_key = d.kabupaten.id + "_" + d.commodity.code + "_" + d.segment.value
if d_key not in by_deficit:
by_deficit[d_key] = (d, [])
by_deficit[d_key][1].append(s)
# Sort deficits by (equity × segment_upper_bound) descending.
# v11 fix #2: ensures HORECA/INDUSTRIAL/GOVERNMENT get earlier pick at
# contested supply when their segment bonus would tip them over RETAIL.
deficits_sorted = sorted(
by_deficit.items(),
key=lambda kv: -(
equity_fn(kv[1][0].kabupaten.ipm)
* _segment_priority_upper_bound(kv[1][0].segment)
),
)
# Track remaining volume per surplus (surplus bisa di-split untuk many deficits)
remaining_volume: Dict[str, float] = {}
for s, _d in candidates:
s_key = s.kabupaten.id + "_" + s.commodity.code
if s_key not in remaining_volume:
remaining_volume[s_key] = s.volume_tons
surplus_lookup: Dict[str, SupplyNode] = {}
for s, _d in candidates:
surplus_lookup[s.kabupaten.id + "_" + s.commodity.code] = s
results: List[MatchResult] = []
for d_key, (d, surpluses) in deficits_sorted:
# Filter yang masih punya volume
available = [s for s in surpluses
if remaining_volume[s.kabupaten.id + "_" + s.commodity.code] > 0]
if not available:
continue
# Pick best by FinalScore (= base × equity × segment multiplier)
eq_mult = equity_fn(d.kabupaten.ipm)
def final_score_for(s: SupplyNode) -> float:
cache_key = (s.kabupaten.id + "_" + s.commodity.code, d.kabupaten.id)
_br, base, _dist = score_cache[cache_key]
seg_mult, _ = segment_multiplier_value(s, d)
return base * eq_mult * seg_mult
best_s = max(available, key=final_score_for)
s_key = best_s.kabupaten.id + "_" + best_s.commodity.code
cache_key = (s_key, d.kabupaten.id)
breakdown, base_score, dist_km = score_cache[cache_key]
seg_mult, seg_flags = segment_multiplier_value(best_s, d)
final_score = base_score * eq_mult * seg_mult
matched_vol = min(remaining_volume[s_key], d.volume_tons)
results.append(MatchResult(
surplus=best_s, deficit=d,
matched_volume_tons=matched_vol,
distance_km=dist_km,
base_score=base_score,
equity_multiplier=eq_mult,
segment_multiplier=seg_mult,
final_score=final_score,
confidence=determine_confidence(best_s, d),
breakdown=breakdown,
flags=list(seg_flags), # segment audit flags
))
remaining_volume[s_key] -= matched_vol
results.sort(key=lambda r: -r.final_score)
return results
# =============================================================================
# DISPATCHER — Pilih algoritma berdasarkan tier composition
# =============================================================================
def allocate(
candidates: List[Tuple[SupplyNode, DemandNode]],
score_fn: Callable[[SupplyNode, DemandNode], Tuple[ScoreBreakdown, float, float]],
force_strategy: Optional[str] = None,
*,
equity_fn: EquityFn = equity_multiplier_value,
) -> List[MatchResult]:
"""
Pilih strategy allocation:
- Both Tier 1 → stable matching
- Else → greedy
Cross-tier handled by greedy (sesuai Section 5.5.4 catatan).
Args:
force_strategy: "stable" | "greedy" | None
untuk testing override.
equity_fn: injectable equity policy, ipm -> multiplier.
Default: equity_multiplier_value (production step-function).
Override only in benchmarks/tests — do not monkeypatch.
"""
if force_strategy == "stable":
return stable_match_tier1(candidates, score_fn, equity_fn=equity_fn)
if force_strategy == "greedy":
return greedy_match_tier2(candidates, score_fn, equity_fn=equity_fn)
# Auto-detect: kalau semua kab Tier 1 → pakai stable matching
all_tier1 = all(
s.kabupaten.is_tier1 and d.kabupaten.is_tier1
for s, d in candidates
)
if all_tier1 and len(candidates) > 0:
return stable_match_tier1(candidates, score_fn, equity_fn=equity_fn)
return greedy_match_tier2(candidates, score_fn, equity_fn=equity_fn)