agriflow-api / benchmarks /equity_comparison.py
masterAAA123's picture
Space deploy v5: orphan snapshot of main, zero binary files
b81a86b
Raw
History Blame Contribute Delete
24.7 kB
"""
benchmarks/equity_comparison.py — Baseline Comparison + Equity Sensitivity Harness.
Action 2 (baseline comparison) + Action 6 (sensitivity) from recommendation.md.
Generates two pitch tables and writes them to benchmarks/output/equity_comparison.md.
Five strategies:
1. pure_greedy — equity_fn=lambda _: 1.0, force_strategy="greedy"
Efficiency anchor (honest frontier: no equity).
2. agriflow — default equity_fn + default dispatch
Production behavior.
3. uniform — equal split per surplus across viable deficits
Equity anchor (ignores score entirely).
4. proportional — allocation proportional to deficit magnitude per kab
Status-quo Bapanas foil (attack #7 answer).
5. agriflow_smoothed — linear-interpolation closure over existing tier knots
(1.30/1.15/1.05/1.00 at IPM 68/72/78), no new params.
Demonstrates smoothed ≈ step (attack #2 answer).
All five strategies receive IDENTICAL inputs: same supply/deficit pool, same
logistics context, same hard constraints (generate_candidates). Only
scoring/prioritisation differs. This is the apple-to-apple requirement.
Run:
python benchmarks/equity_comparison.py
"""
from __future__ import annotations
import os
import sys
from collections import defaultdict
from typing import Dict, List, Tuple
if sys.platform == "win32":
try:
sys.stdout.reconfigure(encoding="utf-8")
except (AttributeError, OSError):
pass
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from matching_engine import run_matching
from matching_engine.allocation import equity_multiplier_value
from matching_engine.constraints import generate_candidates
from matching_engine.models import LogisticsContext
from sample_data.loader import load_all_sample_data
from benchmarks._metrics import (
atkinson,
fulfillment_by_node,
gini,
kab_fulfillment,
min_fulfillment,
total_deficit_covered,
)
# ---------------------------------------------------------------------------
# Key type: (kab_id, commodity_code, segment_value) -> float
# ---------------------------------------------------------------------------
_Key = Tuple[str, str, str]
SAMPANG_ID = "3527"
BANGKALAN_ID = "3526"
# ---------------------------------------------------------------------------
# HELPER: extract demand_tons dict from loaded deficit nodes
# ---------------------------------------------------------------------------
def _build_demand_tons(deficit_nodes) -> Dict[_Key, float]:
"""Build demand_tons dict from a list of DemandNode objects."""
out: Dict[_Key, float] = {}
for d in deficit_nodes:
key = (d.kabupaten.id, d.commodity.code, d.segment.value)
out[key] = out.get(key, 0.0) + d.volume_tons
return out
# ---------------------------------------------------------------------------
# HELPER: extract matched_tons dict from a MatchingReport
# ---------------------------------------------------------------------------
def _report_to_matched_tons(report) -> Dict[_Key, float]:
"""Adapt MatchingReport.matches -> matched_tons dict."""
out: Dict[_Key, float] = {}
for m in report.matches:
key = (m.deficit.kabupaten.id, m.deficit.commodity.code, m.deficit.segment.value)
out[key] = out.get(key, 0.0) + m.matched_volume_tons
return out
# ---------------------------------------------------------------------------
# STRATEGY 3: UNIFORM ALLOCATION
# Uses only Layer 1 candidate generation; ignores score entirely.
# Each surplus splits its volume equally across its viable deficits,
# capping at each deficit's actual need.
# ---------------------------------------------------------------------------
def uniform_allocate(surplus, deficit, logistics) -> Dict[_Key, float]:
"""
Equity baseline: each surplus splits volume equally across its viable
deficit partners (post hard-constraint). Score is ignored entirely.
Returns matched_tons dict: (kab_id, commodity_code, segment) -> tons matched.
This is a foil only — not a product feature, implemented in benchmarks/.
"""
candidates = generate_candidates(surplus, deficit, logistics=logistics)
# Group: surplus_key -> list of deficit nodes that can receive it
surplus_to_deficits: Dict[str, List] = defaultdict(list)
for s, d in candidates:
s_key = s.kabupaten.id + "_" + s.commodity.code
surplus_to_deficits[s_key].append((s, d))
# Build demand lookup: (kab_id, commodity, segment) -> total_demand
demand_lookup: Dict[_Key, float] = _build_demand_tons(deficit)
# remaining demand per key
remaining_demand: Dict[_Key, float] = dict(demand_lookup)
matched: Dict[_Key, float] = defaultdict(float)
# For each surplus, distribute its volume equally across eligible deficits
# Iterate multiple passes to handle partial fills (demand may already be
# partially filled from another surplus). Simple single-pass equal split:
# divide surplus volume by number of eligible deficit keys.
for s_key, pairs in surplus_to_deficits.items():
s = pairs[0][0]
remaining_supply = s.volume_tons
eligible = [
(s_, d) for s_, d in pairs
if remaining_demand.get(
(d.kabupaten.id, d.commodity.code, d.segment.value), 0.0
) > 0
]
if not eligible:
continue
n = len(eligible)
share = remaining_supply / n # equal share per eligible deficit
for _, d in eligible:
d_key = (d.kabupaten.id, d.commodity.code, d.segment.value)
allocation = min(share, remaining_demand.get(d_key, 0.0))
matched[d_key] += allocation
remaining_demand[d_key] = max(0.0, remaining_demand[d_key] - allocation)
return dict(matched)
# ---------------------------------------------------------------------------
# STRATEGY 4: PROPORTIONAL-TO-DEFICIT (Bapanas status-quo foil)
# Allocates each surplus proportionally to the deficit magnitude of each
# eligible kab. Larger deficit gets larger share.
# ---------------------------------------------------------------------------
def proportional_allocate(surplus, deficit, logistics) -> Dict[_Key, float]:
"""
Status-quo Bapanas foil: allocation proportional to deficit magnitude.
Each surplus distributes volume to eligible deficits weighted by
their remaining demand volume. Ignores score, distance ranking, equity.
Represents a simple pro-rata rule: 'give more to those who need more'.
Returns matched_tons dict: (kab_id, commodity_code, segment) -> tons.
"""
candidates = generate_candidates(surplus, deficit, logistics=logistics)
surplus_to_deficits: Dict[str, List] = defaultdict(list)
for s, d in candidates:
s_key = s.kabupaten.id + "_" + s.commodity.code
surplus_to_deficits[s_key].append((s, d))
demand_lookup: Dict[_Key, float] = _build_demand_tons(deficit)
remaining_demand: Dict[_Key, float] = dict(demand_lookup)
matched: Dict[_Key, float] = defaultdict(float)
for s_key, pairs in surplus_to_deficits.items():
s = pairs[0][0]
remaining_supply = s.volume_tons
eligible = [
(s_, d) for s_, d in pairs
if remaining_demand.get(
(d.kabupaten.id, d.commodity.code, d.segment.value), 0.0
) > 0
]
if not eligible:
continue
# Weight = remaining demand magnitude
d_keys = [
(d.kabupaten.id, d.commodity.code, d.segment.value)
for _, d in eligible
]
weights = [remaining_demand[k] for k in d_keys]
total_weight = sum(weights)
if total_weight == 0:
continue
for d_key, w in zip(d_keys, weights):
share = remaining_supply * (w / total_weight)
allocation = min(share, remaining_demand[d_key])
matched[d_key] += allocation
remaining_demand[d_key] = max(0.0, remaining_demand[d_key] - allocation)
return dict(matched)
# ---------------------------------------------------------------------------
# STRATEGY 5: AGRIFLOW-SMOOTHED (linear interpolation over existing knots)
# This is a closure ONLY — do not modify equity_multiplier_value production.
# Knots: (68, 1.30), (72, 1.15), (78, 1.05), (inf, 1.00)
# Linear interpolation between knots; outside range uses boundary values.
# ---------------------------------------------------------------------------
def _equity_smoothed(ipm: float) -> float:
"""
Linear interpolation over the existing tier knots.
Knot values match equity_multiplier_value exactly at knot boundaries:
IPM=68 -> 1.30, IPM=72 -> 1.15, IPM=78 -> 1.05, IPM>=85 -> 1.00
Below 68: returns 1.30 (same as step-function).
Between knots: linear interpolation.
Above 85: returns 1.00.
This demonstrates that smoothed behaviour approximates the step-function
(attack #2 answer) without changing any production code.
"""
# Knot points: (ipm, multiplier)
knots = [(68.0, 1.30), (72.0, 1.15), (78.0, 1.05), (85.0, 1.00)]
if ipm <= knots[0][0]:
return knots[0][1]
if ipm >= knots[-1][0]:
return knots[-1][1]
for i in range(len(knots) - 1):
x0, y0 = knots[i]
x1, y1 = knots[i + 1]
if x0 <= ipm <= x1:
t = (ipm - x0) / (x1 - x0)
return y0 + t * (y1 - y0)
return 1.00 # unreachable
# ---------------------------------------------------------------------------
# ACTION 6 — SENSITIVITY: three threshold variants
# ---------------------------------------------------------------------------
def equity_strict(ipm: float) -> float:
"""Strict thresholds: 1.50/1.25/1.10/1.00 at IPM <65/<70/<75/>=75."""
if ipm < 65:
return 1.50
elif ipm < 70:
return 1.25
elif ipm < 75:
return 1.10
else:
return 1.00
def equity_current(ipm: float) -> float:
"""Current (production) thresholds — delegates to equity_multiplier_value.
Single source of truth: this column in the sensitivity table IS the
shipped behavior, not a re-typed copy that could drift."""
return equity_multiplier_value(ipm)
def equity_lenient(ipm: float) -> float:
"""Lenient thresholds: 1.15/1.08/1.03/1.00 at IPM <70/<75/<80/>=80."""
if ipm < 70:
return 1.15
elif ipm < 75:
return 1.08
elif ipm < 80:
return 1.03
else:
return 1.00
# ---------------------------------------------------------------------------
# BOUNDARY PERTURBATION — how many Jatim kabs change tier under ±1/±2 shifts
# ---------------------------------------------------------------------------
def boundary_perturbation(kabupaten_dict: dict) -> dict:
"""
Shift each IPM threshold by ±1 and ±2 points and count how many
Jatim kabupaten change tier (i.e. receive a different multiplier).
Thresholds being perturbed: 68, 72, 78 (the three break points in
equity_multiplier_value for Jatim 2024).
Returns a dict with counts per shift magnitude for each threshold.
"""
ipm_values = [k.ipm for k in kabupaten_dict.values()]
def count_different(delta: float) -> int:
"""Count kabs whose multiplier changes when ALL thresholds shift by delta."""
changed = 0
for ipm in ipm_values:
orig = equity_multiplier_value(ipm)
# Shifted function: thresholds 68->68+delta, 72->72+delta, 78->78+delta
t68 = 68.0 + delta
t72 = 72.0 + delta
t78 = 78.0 + delta
if ipm < t68:
shifted = 1.30
elif ipm < t72:
shifted = 1.15
elif ipm < t78:
shifted = 1.05
else:
shifted = 1.00
if abs(orig - shifted) > 1e-9:
changed += 1
return changed
result = {}
for delta in (-2.0, -1.0, +1.0, +2.0):
result[delta] = count_different(delta)
return result
# ---------------------------------------------------------------------------
# COMPUTE METRICS ROW for a strategy
# ---------------------------------------------------------------------------
def compute_row(
strategy_name: str,
matched_tons: Dict[_Key, float],
demand_tons: Dict[_Key, float],
) -> dict:
"""Compute all 5 metric columns for one strategy."""
return {
"strategy": strategy_name,
"total_deficit_covered": total_deficit_covered(matched_tons, demand_tons),
"gini": gini(matched_tons, demand_tons),
"atkinson_05": atkinson(matched_tons, demand_tons, epsilon=0.5),
"atkinson_10": atkinson(matched_tons, demand_tons, epsilon=1.0),
"min_fulfillment": min_fulfillment(matched_tons, demand_tons),
"sampang": kab_fulfillment(matched_tons, demand_tons, SAMPANG_ID),
"bangkalan": kab_fulfillment(matched_tons, demand_tons, BANGKALAN_ID),
}
# ---------------------------------------------------------------------------
# MAIN
# ---------------------------------------------------------------------------
def main():
print("=" * 80)
print(" AGRIFLOW EQUITY COMPARISON HARNESS")
print("=" * 80)
print(" Loading Jatim sample data ...")
data = load_all_sample_data()
surplus = data["surplus"]
deficit = data["deficit"]
weather = data["weather"]
historical = data["historical_prices"]
kabupaten_dict = data["kabupaten"]
logistics = LogisticsContext()
demand_tons = _build_demand_tons(deficit)
print(f" Surplus nodes: {len(surplus)}")
print(f" Deficit nodes: {len(deficit)}")
print(f" Demand keys: {len(demand_tons)}")
print()
# -----------------------------------------------------------------------
# RUN FIVE STRATEGIES
# All use same surplus/deficit/logistics — only equity_fn / prioritisation differs
# -----------------------------------------------------------------------
print(" Running strategies ...")
# 1. Pure greedy — efficiency anchor
report_greedy = run_matching(
surplus, deficit,
logistics=logistics,
weather_forecasts=weather,
historical_prices=historical,
force_strategy="greedy",
equity_fn=lambda _ipm: 1.0,
)
matched_greedy = _report_to_matched_tons(report_greedy)
print(" [1/5] pure_greedy done")
# 2. AgriFlow — default (production behavior, equity_fn omitted)
report_agriflow = run_matching(
surplus, deficit,
logistics=logistics,
weather_forecasts=weather,
historical_prices=historical,
)
matched_agriflow = _report_to_matched_tons(report_agriflow)
print(" [2/5] agriflow done")
# 3. Uniform — equity anchor (ignores score, equal split)
matched_uniform = uniform_allocate(surplus, deficit, logistics)
print(" [3/5] uniform done")
# 4. Proportional-to-deficit (Bapanas status-quo foil)
matched_proportional = proportional_allocate(surplus, deficit, logistics)
print(" [4/5] proportional done")
# 5. AgriFlow-smoothed — linear interpolation over existing knots
report_smoothed = run_matching(
surplus, deficit,
logistics=logistics,
weather_forecasts=weather,
historical_prices=historical,
equity_fn=_equity_smoothed,
)
matched_smoothed = _report_to_matched_tons(report_smoothed)
print(" [5/5] agriflow_smoothed done")
print()
# -----------------------------------------------------------------------
# TABLE 1 — BASELINE COMPARISON (5 strategies × 5 metrics + kab spotlight)
# -----------------------------------------------------------------------
rows = [
compute_row("pure_greedy", matched_greedy, demand_tons),
compute_row("agriflow", matched_agriflow, demand_tons),
compute_row("uniform", matched_uniform, demand_tons),
compute_row("proportional", matched_proportional, demand_tons),
compute_row("agriflow_smoothed", matched_smoothed, demand_tons),
]
table1_header = (
"| Strategy | Coverage | Gini | Atk(0.5) | Atk(1.0) | "
"MinFulfill | Sampang | Bangkalan |"
)
table1_sep = (
"|-------------------|----------|-------|----------|----------|"
"-----------|---------|-----------|"
)
table1_lines = [table1_header, table1_sep]
for r in rows:
line = (
f"| {r['strategy']:<17s} "
f"| {r['total_deficit_covered']:.4f} "
f"| {r['gini']:.4f}"
f"| {r['atkinson_05']:.4f} "
f"| {r['atkinson_10']:.4f} "
f"| {r['min_fulfillment']:.4f} "
f"| {r['sampang']:.4f} "
f"| {r['bangkalan']:.4f} |"
)
table1_lines.append(line)
print("=" * 80)
print(" TABLE 1 — BASELINE COMPARISON (5 strategies)")
print(" Coverage = volume-weighted tons fulfilled / tons demanded")
print(" Gini / Atkinson = weighted by demand volume (lower = more equitable)")
print(" MinFulfill = fulfillment ratio of worst-served demand node (higher = better)")
print(" Sampang=3527 (IPM 66.72), Bangkalan=3526 (IPM 67.70)")
print("=" * 80)
for line in table1_lines:
print(line)
print()
# -----------------------------------------------------------------------
# ACTION 6 — SENSITIVITY (strict / current / lenient threshold variants)
# -----------------------------------------------------------------------
print(" Running Action 6 sensitivity ...")
sens_variants = [
("strict", equity_strict, "IPM <65→1.50, <70→1.25, <75→1.10, >=75→1.00"),
("current", equity_current, "IPM <68→1.30, <72→1.15, <78→1.05, >=78→1.00 [PROD]"),
("lenient", equity_lenient, "IPM <70→1.15, <75→1.08, <80→1.03, >=80→1.00"),
]
sens_rows = []
for name, fn, desc in sens_variants:
rep = run_matching(
surplus, deficit,
logistics=logistics,
weather_forecasts=weather,
historical_prices=historical,
equity_fn=fn,
)
mt = _report_to_matched_tons(rep)
sens_rows.append({
"variant": name,
"desc": desc,
"total_deficit_covered": total_deficit_covered(mt, demand_tons),
"gini": gini(mt, demand_tons),
"sampang": kab_fulfillment(mt, demand_tons, SAMPANG_ID),
"bangkalan": kab_fulfillment(mt, demand_tons, BANGKALAN_ID),
})
table2_header = (
"| Variant | Coverage | Gini | Sampang | Bangkalan | Description |"
)
table2_sep = (
"|---------|----------|-------|---------|-----------|-------------|"
)
table2_lines = [table2_header, table2_sep]
for r in sens_rows:
line = (
f"| {r['variant']:<7s} "
f"| {r['total_deficit_covered']:.4f} "
f"| {r['gini']:.4f}"
f"| {r['sampang']:.4f} "
f"| {r['bangkalan']:.4f} "
f"| {r['desc']} |"
)
table2_lines.append(line)
print()
print("=" * 80)
print(" TABLE 2 — ACTION 6 SENSITIVITY (threshold variants)")
print(" 'current' delegates to equity_multiplier_value — single source of truth.")
print("=" * 80)
for line in table2_lines:
print(line)
print()
# -----------------------------------------------------------------------
# BOUNDARY PERTURBATION
# -----------------------------------------------------------------------
perturb = boundary_perturbation(kabupaten_dict)
print("=" * 80)
print(" BOUNDARY PERTURBATION — kabs changing tier when thresholds shift ±1/±2")
print(" (Attack #2: 'cliff effect' — how sensitive is tier assignment to threshold?)")
print("=" * 80)
print(" Jatim IPM values (sorted):")
ipm_sorted = sorted(k.ipm for k in kabupaten_dict.values())
print(" " + " ".join(f"{v:.2f}" for v in ipm_sorted))
print()
print(" | Threshold shift | Kabs changing tier |")
print(" |-----------------|---------------------|")
for delta in (-2.0, -1.0, +1.0, +2.0):
label = f"{delta:+.0f} pts"
print(f" | {label:<15s} | {perturb[delta]:2d} |")
print()
print(" Interpretation:")
print(" - 0-1 kabs per ±1pt shift = cliff effect negligible.")
print(" - More kabs = higher sensitivity (attack #2 stronger).")
print()
# -----------------------------------------------------------------------
# WRITE OUTPUT
# -----------------------------------------------------------------------
output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "equity_comparison.md")
with open(output_path, "w", encoding="utf-8") as f:
f.write("# AgriFlow Equity Comparison\n\n")
f.write("Generated by `benchmarks/equity_comparison.py`.\n\n")
f.write("## Table 1 — Baseline Comparison (5 strategies)\n\n")
f.write("Coverage = volume-weighted tons fulfilled / tons demanded. \n")
f.write("Gini / Atkinson = weighted by demand volume; lower = more equitable. \n")
f.write("MinFulfill = fulfillment ratio of worst-served demand node; higher = better. \n")
f.write("Sampang=3527 (IPM 66.72), Bangkalan=3526 (IPM 67.70). \n\n")
for line in table1_lines:
f.write(line + "\n")
f.write("\n")
f.write("## Table 2 — Action 6 Sensitivity (threshold variants)\n\n")
f.write("`current` delegates to `equity_multiplier_value` — single source of truth.\n\n")
for line in table2_lines:
f.write(line + "\n")
f.write("\n")
f.write("## Boundary Perturbation\n\n")
f.write("Shift all IPM thresholds simultaneously by ±1 or ±2 points.\n")
f.write("Counts kabupaten in Jatim (38 total) that move to a different tier.\n\n")
f.write("| Threshold shift | Kabs changing tier |\n")
f.write("|-----------------|---------------------|\n")
for delta in (-2.0, -1.0, +1.0, +2.0):
label = f"{delta:+.0f} pts"
f.write(f"| {label:<15s} | {perturb[delta]:2d} |\n")
f.write("\n")
print(f" Tables written to: {output_path}")
print()
# -----------------------------------------------------------------------
# QUICK SANITY CHECK — report potential ordering violations
# -----------------------------------------------------------------------
greedy_cov = rows[0]["total_deficit_covered"]
agriflow_cov = rows[1]["total_deficit_covered"]
greedy_gini = rows[0]["gini"]
agriflow_gini = rows[1]["gini"]
uniform_gini = rows[2]["gini"]
sampang_agriflow = rows[1]["sampang"]
sampang_greedy = rows[0]["sampang"]
print(" ORDERING CHECKS (expected by pitch claims):")
check = lambda ok, msg: print(f" {'PASS' if ok else 'FAIL'} {msg}")
check(greedy_cov >= agriflow_cov,
f"greedy coverage ({greedy_cov:.4f}) >= agriflow coverage ({agriflow_cov:.4f})")
check(agriflow_gini <= greedy_gini,
f"agriflow gini ({agriflow_gini:.4f}) <= greedy gini ({greedy_gini:.4f})")
check(uniform_gini <= agriflow_gini,
f"uniform gini ({uniform_gini:.4f}) <= agriflow gini ({agriflow_gini:.4f})")
check(sampang_agriflow >= sampang_greedy,
f"Sampang: agriflow ({sampang_agriflow:.4f}) >= greedy ({sampang_greedy:.4f})")
strict_sampang = sens_rows[0]["sampang"]
lenient_sampang = sens_rows[2]["sampang"]
check(strict_sampang >= lenient_sampang,
f"strict Sampang ({strict_sampang:.4f}) >= lenient Sampang ({lenient_sampang:.4f})")
smoothed_cov = rows[4]["total_deficit_covered"]
smoothed_gini = rows[4]["gini"]
check(abs(smoothed_cov - agriflow_cov) < 0.05,
f"smoothed coverage ({smoothed_cov:.4f}) approx agriflow ({agriflow_cov:.4f})")
check(abs(smoothed_gini - agriflow_gini) < 0.05,
f"smoothed gini ({smoothed_gini:.4f}) approx agriflow gini ({agriflow_gini:.4f})")
print()
print("=" * 80)
print(" DONE. Copy tables from benchmarks/output/equity_comparison.md into pitch deck.")
print("=" * 80)
if __name__ == "__main__":
main()