File size: 16,462 Bytes
b81a86b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
tests/test_baseline_comparison.py β€” Correctness assertions for baseline comparison harness.

These tests assert ORDERINGS and BOUNDS that defend pitch claims, NOT golden numbers.
Golden numbers are in benchmarks/output/equity_comparison.md and drift with data changes.

Test structure:
    1. _metrics.py helper sanity tests
    2. equity_fn injection: default-preservation test (run_matching with / without equity_fn)
    3. Strategy ordering tests (the five pitch claims)
    4. Action 6 sensitivity ordering (strict >= lenient for worst-off kab)
"""
from __future__ import annotations
import pytest

from matching_engine import run_matching
from matching_engine.allocation import equity_multiplier_value
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,
)
from benchmarks.equity_comparison import (
    _build_demand_tons,
    _equity_smoothed,
    _report_to_matched_tons,
    equity_current,
    equity_lenient,
    equity_strict,
    proportional_allocate,
    uniform_allocate,
    SAMPANG_ID,
    BANGKALAN_ID,
)


# =============================================================================
# FIXTURES
# =============================================================================

@pytest.fixture(scope="module")
def sample_data():
    return load_all_sample_data()


@pytest.fixture(scope="module")
def logistics():
    return LogisticsContext()


@pytest.fixture(scope="module")
def demand_tons(sample_data):
    return _build_demand_tons(sample_data["deficit"])


@pytest.fixture(scope="module")
def matched_greedy(sample_data, logistics):
    report = run_matching(
        sample_data["surplus"], sample_data["deficit"],
        logistics=logistics,
        force_strategy="greedy",
        equity_fn=lambda _: 1.0,
    )
    return _report_to_matched_tons(report)


@pytest.fixture(scope="module")
def matched_agriflow(sample_data, logistics):
    report = run_matching(
        sample_data["surplus"], sample_data["deficit"],
        logistics=logistics,
    )
    return _report_to_matched_tons(report)


@pytest.fixture(scope="module")
def matched_uniform(sample_data, logistics):
    return uniform_allocate(
        sample_data["surplus"], sample_data["deficit"], logistics
    )


@pytest.fixture(scope="module")
def matched_smoothed(sample_data, logistics):
    report = run_matching(
        sample_data["surplus"], sample_data["deficit"],
        logistics=logistics,
        equity_fn=_equity_smoothed,
    )
    return _report_to_matched_tons(report)


# =============================================================================
# 1. _metrics.py helper sanity
# =============================================================================

class TestMetricsHelpers:
    """Sanity checks on pure metric functions.

    These are invariants that must hold regardless of sample data.
    """

    def test_gini_uniform_returns_zero(self, demand_tons):
        """Gini of perfectly equal fulfilment is 0."""
        # All kabs get exactly their full demand
        perfect = {k: v for k, v in demand_tons.items()}
        assert gini(perfect, demand_tons) == pytest.approx(0.0, abs=1e-9)

    def test_gini_uniform_list_zero(self):
        """Classic list form: uniform fulfillment β†’ Gini = 0."""
        # Build equal-weight, equal-ratio scenario manually
        d = {("a", "c", "s"): 10.0, ("b", "c", "s"): 10.0, ("c_kab", "c", "s"): 10.0}
        m = {("a", "c", "s"): 10.0, ("b", "c", "s"): 10.0, ("c_kab", "c", "s"): 10.0}
        assert gini(m, d) == pytest.approx(0.0, abs=1e-9)

    def test_gini_maximum_inequality(self):
        """One kab gets everything, others get nothing β†’ Gini > 0.6."""
        d = {
            ("a", "c", "s"): 100.0,
            ("b", "c", "s"): 100.0,
            ("c_kab", "c", "s"): 100.0,
            ("d_kab", "c", "s"): 100.0,
        }
        m = {("a", "c", "s"): 100.0}  # only 'a' gets supply
        assert gini(m, d) > 0.6

    def test_total_deficit_covered_volume_weighted(self):
        """Coverage is tons fulfilled / tons demanded, NOT count of kabs."""
        d = {("big", "c", "s"): 1000.0, ("small", "c", "s"): 10.0}
        # big gets 50%, small gets 100%
        m = {("big", "c", "s"): 500.0, ("small", "c", "s"): 10.0}
        cov = total_deficit_covered(m, d)
        # Expected: (500+10)/(1000+10) = 510/1010 β‰ˆ 0.5050
        assert cov == pytest.approx(510.0 / 1010.0, rel=1e-6)
        # Must be less than the unweighted count fraction (2/2 = 1.0)
        assert cov < 0.6

    def test_min_fulfillment_returns_worst(self):
        """min_fulfillment reports the single worst fulfillment ratio."""
        d = {("a", "c", "s"): 100.0, ("b", "c", "s"): 50.0}
        m = {("a", "c", "s"): 50.0, ("b", "c", "s"): 50.0}
        # a gets 0.5, b gets 1.0 β†’ min is 0.5
        assert min_fulfillment(m, d) == pytest.approx(0.5, rel=1e-6)

    def test_atkinson_zero_on_perfect_equality(self, demand_tons):
        """Atkinson = 0 when all get their full demand."""
        perfect = {k: v for k, v in demand_tons.items()}
        assert atkinson(perfect, demand_tons, epsilon=0.5) == pytest.approx(0.0, abs=1e-6)
        assert atkinson(perfect, demand_tons, epsilon=1.0) == pytest.approx(0.0, abs=1e-6)

    def test_kab_fulfillment_aggregates_correctly(self):
        """kab_fulfillment is volume-weighted across commodity/segment nodes."""
        d = {
            ("3527", "beras_premium", "RETAIL"): 200.0,
            ("3527", "cabai_merah", "RETAIL"): 50.0,
        }
        m = {
            ("3527", "beras_premium", "RETAIL"): 200.0,
            ("3527", "cabai_merah", "RETAIL"): 25.0,  # half covered
        }
        result = kab_fulfillment(m, d, "3527")
        # Expected: (200+25)/(200+50) = 225/250 = 0.9
        assert result == pytest.approx(0.9, rel=1e-6)


# =============================================================================
# 2. equity_fn default-preservation test
# =============================================================================

class TestEquityFnInjection:
    """Verify that the equity_fn seam is default-preserving.

    run_matching(...) with equity_fn omitted must produce the exact same
    final_score list as run_matching(..., equity_fn=equity_multiplier_value).
    This proves the 166-test gate condition at the API level.
    """

    def test_default_fn_identical_to_explicit_fn(self, sample_data, logistics):
        report_default = run_matching(
            sample_data["surplus"], sample_data["deficit"],
            logistics=logistics,
        )
        report_explicit = run_matching(
            sample_data["surplus"], sample_data["deficit"],
            logistics=logistics,
            equity_fn=equity_multiplier_value,
        )
        scores_default = sorted(m.final_score for m in report_default.matches)
        scores_explicit = sorted(m.final_score for m in report_explicit.matches)
        assert scores_default == pytest.approx(scores_explicit, rel=1e-9), (
            "equity_fn=equity_multiplier_value must be byte-identical to default"
        )

    def test_pure_greedy_equity_multipliers_all_one(self, sample_data, logistics):
        """With equity_fn=lambda _: 1.0, every match's equity_multiplier must be 1.0."""
        report = run_matching(
            sample_data["surplus"], sample_data["deficit"],
            logistics=logistics,
            force_strategy="greedy",
            equity_fn=lambda _: 1.0,
        )
        for m in report.matches:
            assert m.equity_multiplier == pytest.approx(1.0, rel=1e-9), (
                f"Expected equity_multiplier=1.0, got {m.equity_multiplier} for "
                f"{m.deficit.kabupaten.nama}"
            )

    def test_smoothed_fn_close_to_step_scores(self, sample_data, logistics):
        """Smoothed equity_fn produces similar final scores to step-function."""
        report_step = run_matching(
            sample_data["surplus"], sample_data["deficit"],
            logistics=logistics,
        )
        report_smooth = run_matching(
            sample_data["surplus"], sample_data["deficit"],
            logistics=logistics,
            equity_fn=_equity_smoothed,
        )
        # Smoothed knots interpolate between step values β†’ similar but not identical
        # Both must produce the same number of matches (same feasibility)
        assert len(report_step.matches) == len(report_smooth.matches)


# =============================================================================
# 3. Strategy ordering tests (pitch claims)
# =============================================================================

class TestStrategyOrdering:
    """Ordering assertions that defend the pitch narrative.

    These do NOT assert golden numbers β€” they assert relationships between
    strategies that the pitch claims:
        - Pure greedy is the efficiency frontier (highest coverage)
        - Uniform is the equity extreme (lowest Gini)
        - AgriFlow is between them on both axes
        - Sampang and Bangkalan (the two poorest Madura kabs) do at least
          as well under AgriFlow as under pure greedy
    """

    def test_greedy_coverage_gte_agriflow(self, matched_greedy, matched_agriflow, demand_tons):
        """Efficiency frontier: pure greedy must not be beaten on raw volume coverage."""
        g_cov = total_deficit_covered(matched_greedy, demand_tons)
        a_cov = total_deficit_covered(matched_agriflow, demand_tons)
        assert g_cov >= a_cov - 1e-9, (
            f"Pure greedy ({g_cov:.4f}) must have >= coverage than AgriFlow ({a_cov:.4f}). "
            f"If this fails, the efficiency-equity tradeoff claim is broken."
        )

    def test_uniform_gini_lte_agriflow(self, matched_uniform, matched_agriflow, demand_tons):
        """Equity extreme: uniform allocation must have <= Gini than AgriFlow."""
        u_gini = gini(matched_uniform, demand_tons)
        a_gini = gini(matched_agriflow, demand_tons)
        assert u_gini <= a_gini + 1e-9, (
            f"Uniform gini ({u_gini:.4f}) must be <= AgriFlow gini ({a_gini:.4f}). "
            f"Uniform is the equity anchor β€” it must never be MORE unequal than AgriFlow."
        )

    def test_sampang_agriflow_gte_greedy(self, matched_agriflow, matched_greedy, demand_tons):
        """Sampang (IPM 66.72, highest equity boost) does >= under AgriFlow vs greedy."""
        a = kab_fulfillment(matched_agriflow, demand_tons, SAMPANG_ID)
        g = kab_fulfillment(matched_greedy, demand_tons, SAMPANG_ID)
        assert a >= g - 1e-9, (
            f"Sampang under AgriFlow ({a:.4f}) must be >= under greedy ({g:.4f}). "
            f"The 1.30x equity boost exists to ensure this."
        )

    def test_bangkalan_agriflow_gte_greedy(self, matched_agriflow, matched_greedy, demand_tons):
        """Bangkalan (IPM 67.70, highest equity boost) does >= under AgriFlow vs greedy."""
        a = kab_fulfillment(matched_agriflow, demand_tons, BANGKALAN_ID)
        g = kab_fulfillment(matched_greedy, demand_tons, BANGKALAN_ID)
        assert a >= g - 1e-9, (
            f"Bangkalan under AgriFlow ({a:.4f}) must be >= under greedy ({g:.4f})."
        )

    def test_smoothed_coverage_approx_step(self, matched_smoothed, matched_agriflow, demand_tons):
        """AgriFlow-smoothed and AgriFlow-step must have similar coverage (within 5pp)."""
        s_cov = total_deficit_covered(matched_smoothed, demand_tons)
        a_cov = total_deficit_covered(matched_agriflow, demand_tons)
        assert abs(s_cov - a_cov) < 0.05, (
            f"Smoothed coverage ({s_cov:.4f}) differs from step ({a_cov:.4f}) by > 5pp. "
            f"Smoothed should approximate step β€” this is the attack-#2 answer."
        )

    def test_smoothed_gini_approx_step(self, matched_smoothed, matched_agriflow, demand_tons):
        """AgriFlow-smoothed and AgriFlow-step must have similar Gini (within 5pp)."""
        s_gini = gini(matched_smoothed, demand_tons)
        a_gini = gini(matched_agriflow, demand_tons)
        assert abs(s_gini - a_gini) < 0.05, (
            f"Smoothed Gini ({s_gini:.4f}) differs from step Gini ({a_gini:.4f}) by > 5pp."
        )

    def test_agriflow_coverage_positive(self, matched_agriflow, demand_tons):
        """AgriFlow must achieve non-trivial coverage (> 50%)."""
        a_cov = total_deficit_covered(matched_agriflow, demand_tons)
        assert a_cov > 0.5, f"AgriFlow coverage ({a_cov:.4f}) must be > 50%."

    def test_greedy_coverage_positive(self, matched_greedy, demand_tons):
        """Pure greedy must achieve non-trivial coverage (> 50%)."""
        g_cov = total_deficit_covered(matched_greedy, demand_tons)
        assert g_cov > 0.5, f"Greedy coverage ({g_cov:.4f}) must be > 50%."


# =============================================================================
# 4. Action 6 sensitivity ordering
# =============================================================================

class TestSensitivityOrdering:
    """Strict threshold setting helps worst-off kabs more than lenient."""

    def test_strict_sampang_gte_lenient(self, sample_data, logistics, demand_tons):
        """Stricter equity thresholds give Sampang >= coverage vs lenient."""
        report_strict = run_matching(
            sample_data["surplus"], sample_data["deficit"],
            logistics=logistics,
            equity_fn=equity_strict,
        )
        report_lenient = run_matching(
            sample_data["surplus"], sample_data["deficit"],
            logistics=logistics,
            equity_fn=equity_lenient,
        )
        mt_strict = _report_to_matched_tons(report_strict)
        mt_lenient = _report_to_matched_tons(report_lenient)
        s_strict = kab_fulfillment(mt_strict, demand_tons, SAMPANG_ID)
        s_lenient = kab_fulfillment(mt_lenient, demand_tons, SAMPANG_ID)
        assert s_strict >= s_lenient - 1e-9, (
            f"Strict Sampang ({s_strict:.4f}) must be >= lenient ({s_lenient:.4f}). "
            f"Stricter equity weighting should benefit the poorest kab."
        )

    def test_equity_current_delegates_to_production(self, sample_data, logistics):
        """equity_current() must return the same value as equity_multiplier_value()."""
        test_ipms = [60.0, 66.72, 67.70, 68.79, 70.0, 72.0, 75.0, 78.0, 84.69]
        for ipm in test_ipms:
            assert equity_current(ipm) == equity_multiplier_value(ipm), (
                f"equity_current({ipm}) != equity_multiplier_value({ipm}). "
                f"equity_current must delegate β€” it is the single source of truth."
            )

    def test_smoothed_equals_step_at_knot_boundaries(self):
        """Smoothed function equals step-function exactly at the knot IPM values."""
        # At knot boundaries, interpolation should produce the step value
        knot_checks = [
            (68.0, 1.30),  # lower boundary of tier 1β†’2
            (72.0, 1.15),  # lower boundary of tier 2β†’3
            (78.0, 1.05),  # lower boundary of tier 3β†’4
        ]
        for ipm, expected in knot_checks:
            assert _equity_smoothed(ipm) == pytest.approx(expected, rel=1e-9), (
                f"Smoothed at knot IPM={ipm}: expected {expected}, "
                f"got {_equity_smoothed(ipm):.6f}"
            )

    def test_smoothed_below_lowest_knot(self):
        """Below lowest knot (IPM < 68), smoothed returns 1.30 (same as step)."""
        for ipm in [50.0, 60.0, 67.99]:
            assert _equity_smoothed(ipm) == pytest.approx(1.30, rel=1e-9)

    def test_smoothed_above_highest_knot(self):
        """Above highest knot (IPM >= 85), smoothed returns 1.00."""
        assert _equity_smoothed(85.0) == pytest.approx(1.00, rel=1e-9)
        assert _equity_smoothed(90.0) == pytest.approx(1.00, rel=1e-9)

    def test_smoothed_monotone_decreasing(self):
        """Smoothed equity function is monotone decreasing (higher IPM = lower boost)."""
        ipms = [60.0, 65.0, 68.0, 70.0, 72.0, 74.0, 78.0, 80.0, 85.0, 90.0]
        values = [_equity_smoothed(ipm) for ipm in ipms]
        for i in range(len(values) - 1):
            assert values[i] >= values[i + 1] - 1e-9, (
                f"Smoothed not monotone at IPM={ipms[i]:.1f} ({values[i]:.4f}) "
                f"-> IPM={ipms[i+1]:.1f} ({values[i+1]:.4f})"
            )