File size: 13,346 Bytes
b7aa1f0
95f11da
b7aa1f0
95f11da
379f291
 
 
b7aa1f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379f291
 
 
 
 
 
9ae9432
 
 
 
 
 
 
 
 
 
 
379f291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e32a33b
 
 
 
 
 
 
379f291
 
 
 
e32a33b
 
 
 
 
 
379f291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a6b0c55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9ae9432
 
 
a6b0c55
 
2dedffd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a6b0c55
 
 
 
 
 
 
 
 
 
 
 
 
b7aa1f0
 
 
 
 
 
 
06abe10
b7aa1f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e32a33b
 
 
 
 
b7aa1f0
 
 
 
e32a33b
 
b7aa1f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from scenarios.arbitration import ARB_FEE_PER_SIDE, ArbitrationOutcome
from scenarios.case_generator import generate_task
from scenarios.issuer_model import IssuerDecision, IssuerReview
from core.models import ChargebackOpsAction
from server.chargeback_ops_environment import ChargebackOpsEnvironment


class _ScriptedIssuer:
    """Deterministic Issuer stub that returns planned decisions in sequence.

    Lets round-2 env tests exercise the full dispute lifecycle without
    depending on the exact thresholds in IssuerAgent — the scoring math is
    already pinned in tests/test_issuer.py.
    """

    def __init__(self, decisions: list[IssuerDecision]):
        self._decisions = list(decisions)
        self.calls: list[int] = []

    def decide_review(self, case, progress, round_number):
        self.calls.append(round_number)
        idx = min(len(self.calls) - 1, len(self._decisions) - 1)
        decision = self._decisions[idx]
        return IssuerReview(
            decision=decision,
            evidence_strength_score=0.5,
            rationale=f"stub decision {decision.value} at r{round_number}",
        )


def _drive_case_into_round_2(env: ChargebackOpsEnvironment) -> None:
    """Select CB-E1, attach required evidence, submit — with Issuer stub that
    requests more evidence on round 1. Leaves env in round-2 state.
    """
    env.step(ChargebackOpsAction(action_type="select_case", case_id="CB-E1"))
    env.step(
        ChargebackOpsAction(
            action_type="query_system", case_id="CB-E1", system_name="orders"
        )
    )
    env.step(
        ChargebackOpsAction(
            action_type="query_system", case_id="CB-E1", system_name="shipping"
        )
    )
    env.step(
        ChargebackOpsAction(
            action_type="add_evidence",
            case_id="CB-E1",
            evidence_ids=["E1-ORDER-CONF", "E1-DELIVERY-SCAN"],
        )
    )
    env.step(
        ChargebackOpsAction(
            action_type="set_strategy", case_id="CB-E1", strategy="contest"
        )
    )
    env.step(
        ChargebackOpsAction(action_type="submit_representment", case_id="CB-E1")
    )


def test_reset_returns_task_observation():
    env = ChargebackOpsEnvironment()
    obs = env.reset(task_id="goods_not_received_easy")
    assert obs.task_id == "goods_not_received_easy"
    assert obs.steps_remaining == 10
    assert len(obs.queue) == 1
    assert obs.queue[0].transaction_id.startswith("txn_")
    assert obs.queue[0].merchant_mcc.isdigit()
    assert obs.queue[0].masked_card.startswith("****")


def test_reset_accepts_curriculum_difficulty():
    env = ChargebackOpsEnvironment()
    obs = env.reset(difficulty="hard", seed=7)
    assert obs.task_id == "generated_hard_s7"
    assert obs.difficulty == "hard"
    assert len(obs.queue) >= 2


def test_easy_case_can_be_won():
    env = ChargebackOpsEnvironment()
    env.reset(task_id="goods_not_received_easy")
    env.step(ChargebackOpsAction(action_type="select_case", case_id="CB-E1"))
    env.step(ChargebackOpsAction(action_type="inspect_case", case_id="CB-E1"))
    env.step(
        ChargebackOpsAction(
            action_type="query_system",
            case_id="CB-E1",
            system_name="orders",
        )
    )
    env.step(
        ChargebackOpsAction(
            action_type="query_system",
            case_id="CB-E1",
            system_name="shipping",
        )
    )
    env.step(
        ChargebackOpsAction(
            action_type="query_system",
            case_id="CB-E1",
            system_name="support",
        )
    )
    env.step(
        ChargebackOpsAction(
            action_type="add_evidence",
            case_id="CB-E1",
            evidence_ids=[
                "E1-ORDER-CONF",
                "E1-DELIVERY-SCAN",
                "E1-SIGNATURE",
                "E1-SUPPORT-ACK",
            ],
        )
    )
    env.step(
        ChargebackOpsAction(
            action_type="set_strategy",
            case_id="CB-E1",
            strategy="contest",
        )
    )
    obs = env.step(
        ChargebackOpsAction(
            action_type="submit_representment",
            case_id="CB-E1",
        )
    )

    assert obs.done is True
    assert obs.grader_report is not None
    assert obs.grader_report.normalized_score > 0.8


def test_generated_task_reproducibility():
    """Same seed must produce identical cases."""
    t1 = generate_task(99, difficulty="medium")
    t2 = generate_task(99, difficulty="medium")
    assert t1.task_id == t2.task_id
    assert len(t1.cases) == len(t2.cases)
    for c1, c2 in zip(t1.cases, t2.cases):
        assert c1.case_id == c2.case_id
        assert c1.amount == c2.amount
        assert c1.optimal_strategy == c2.optimal_strategy


def test_generated_task_runs_in_environment():
    """A generated task should reset and accept at least one step."""
    env = ChargebackOpsEnvironment()
    obs = env.reset(task_id="generated_easy_s7")
    assert obs.task_id == "generated_easy_s7"
    assert len(obs.queue) >= 1
    case_id = obs.queue[0].case_id
    obs = env.step(ChargebackOpsAction(action_type="select_case", case_id=case_id))
    assert obs.selected_case_id == case_id
    assert obs.visible_case is not None
    assert obs.visible_case.transaction_timestamp.endswith("Z")
    assert "episode_metrics" in obs.info


def test_marathon_task_has_wave_arrivals_and_wait_action():
    env = ChargebackOpsEnvironment()
    obs = env.reset(task_id="monthly_dispute_backlog_marathon")
    assert obs.task_id == "monthly_dispute_backlog_marathon"
    assert obs.steps_remaining == 60
    assert len(obs.queue) == 4
    assert obs.info["episode_metrics"]["future_case_count"] == 8.0

    # Resolve the urgent refund cases that are initially visible enough to
    # leave the environment waiting on future arrivals or async work later.
    assert "wait_for_updates" not in obs.available_actions


def test_marathon_delayed_evidence_and_issuer_review():
    env = ChargebackOpsEnvironment()
    obs = env.reset(task_id="monthly_dispute_backlog_marathon")

    obs = env.step(ChargebackOpsAction(action_type="select_case", case_id="CB-L02"))
    obs = env.step(
        ChargebackOpsAction(
            action_type="query_system", case_id="CB-L02", system_name="orders"
        )
    )
    obs = env.step(
        ChargebackOpsAction(
            action_type="query_system", case_id="CB-L02", system_name="shipping"
        )
    )
    assert "delayed shipping evidence" in obs.last_action_result.lower()
    assert obs.info["pending_evidence_systems"] == ["shipping"]

    obs = env.step(ChargebackOpsAction(action_type="wait_for_updates"))
    obs = env.step(ChargebackOpsAction(action_type="wait_for_updates"))
    assert "Delayed shipping evidence arrived" in obs.last_action_result
    assert obs.visible_case is not None
    retrieved_ids = {item.evidence_id for item in obs.visible_case.retrieved_evidence}
    assert any(eid.endswith("E1-DELIVERY-SCAN") for eid in retrieved_ids)

    delivery_ids = sorted(
        eid
        for eid in retrieved_ids
        if eid.endswith("E1-ORDER-CONF") or eid.endswith("E1-DELIVERY-SCAN")
    )
    obs = env.step(
        ChargebackOpsAction(
            action_type="add_evidence",
            case_id="CB-L02",
            evidence_ids=delivery_ids,
        )
    )
    obs = env.step(
        ChargebackOpsAction(
            action_type="set_strategy", case_id="CB-L02", strategy="contest"
        )
    )
    obs = env.step(
        ChargebackOpsAction(action_type="submit_representment", case_id="CB-L02")
    )
    assert obs.visible_case is not None
    assert obs.visible_case.status == "pending_issuer_review"
    assert obs.info["episode_metrics"]["pending_issuer_reviews"] == 1.0

    obs = env.step(ChargebackOpsAction(action_type="wait_for_updates"))
    obs = env.step(ChargebackOpsAction(action_type="wait_for_updates"))
    obs = env.step(ChargebackOpsAction(action_type="wait_for_updates"))
    assert "Issuer" in obs.last_action_result
    assert obs.info["episode_metrics"]["pending_issuer_reviews"] == 0.0


def test_generated_task_covers_all_reason_codes():
    """Generator should produce all 6 reason code families across seeds."""
    seen_codes: set[str] = set()
    for seed in range(50):
        for diff in ("easy", "medium", "hard"):
            t = generate_task(seed, difficulty=diff)
            for c in t.cases:
                seen_codes.add(c.reason_code)
    expected = {
        "goods_not_received", "fraud_cnp", "credit_not_processed",
        "duplicate_processing", "product_not_as_described", "service_not_provided",
    }
    assert expected.issubset(seen_codes), f"Missing: {expected - seen_codes}"


# ---------------------------------------------------------------------------
# multi-round dispute lifecycle 
# ---------------------------------------------------------------------------


def test_pre_arb_available_actions_exclude_submit_representment():
    env = ChargebackOpsEnvironment()
    env.reset(task_id="goods_not_received_easy")
    env._issuer_agent = _ScriptedIssuer([IssuerDecision.REQUEST_MORE_EVIDENCE])
    _drive_case_into_round_2(env)

    actions = env._build_available_actions()
    assert "submit_representment" not in actions
    assert "respond_to_pre_arb" in actions
    assert "escalate_to_arbitration" in actions
    assert "accept_arbitration_loss" in actions


def test_full_three_round_cycle_ending_in_arbitration():
    env = ChargebackOpsEnvironment()
    env.reset(task_id="goods_not_received_easy")
    env._issuer_agent = _ScriptedIssuer(
        [
            IssuerDecision.REQUEST_MORE_EVIDENCE,
            IssuerDecision.ESCALATE_TO_ARBITRATION,
        ]
    )
    _drive_case_into_round_2(env)

    env.step(
        ChargebackOpsAction(
            action_type="query_system", case_id="CB-E1", system_name="support"
        )
    )
    obs = env.step(
        ChargebackOpsAction(
            action_type="respond_to_pre_arb",
            case_id="CB-E1",
            compelling_evidence_ids=["E1-SIGNATURE", "E1-SUPPORT-ACK"],
            note="Added signature delivery proof and support ack for pre-arb.",
        )
    )

    progress = env._progress_by_case["CB-E1"]
    assert progress.round_number == 3
    assert progress.arbitration_outcome == ArbitrationOutcome.MERCHANT_WINS.value
    assert progress.arb_fees_paid == ARB_FEE_PER_SIDE
    assert progress.final_economic_outcome == progress.final_economic_outcome
    assert progress.final_economic_outcome is not None
    assert progress.resolution_status == "won_arbitration"
    assert obs.done is True
    assert "arbitration" in obs.last_action_result.lower()


def test_respond_to_pre_arb_accepted_skips_arbitration():
    """If the Issuer accepts in round 2, no arbitration fee is charged and
    the merchant keeps the full dispute amount."""
    env = ChargebackOpsEnvironment()
    env.reset(task_id="goods_not_received_easy")
    env._issuer_agent = _ScriptedIssuer(
        [IssuerDecision.REQUEST_MORE_EVIDENCE, IssuerDecision.ACCEPT]
    )
    _drive_case_into_round_2(env)

    env.step(
        ChargebackOpsAction(
            action_type="respond_to_pre_arb",
            case_id="CB-E1",
            compelling_evidence_ids=["E1-SIGNATURE"],
        )
    )

    progress = env._progress_by_case["CB-E1"]
    assert progress.resolution_status == "won_pre_arb"
    assert progress.arb_fees_paid == 0.0
    assert progress.arbitration_outcome is None
    case = env._lookup_case("CB-E1")
    assert progress.final_economic_outcome == case.amount


def test_accept_arbitration_loss_skips_fees():
    """Conceding pre-arb forfeits the dispute amount but avoids the $250 fee."""
    env = ChargebackOpsEnvironment()
    env.reset(task_id="goods_not_received_easy")
    env._issuer_agent = _ScriptedIssuer([IssuerDecision.REQUEST_MORE_EVIDENCE])
    _drive_case_into_round_2(env)

    env.step(
        ChargebackOpsAction(
            action_type="accept_arbitration_loss", case_id="CB-E1"
        )
    )

    progress = env._progress_by_case["CB-E1"]
    case = env._lookup_case("CB-E1")
    assert progress.resolution_status == "conceded_pre_arb"
    assert progress.arb_fees_paid == 0.0
    assert progress.arbitration_outcome is None
    assert progress.final_economic_outcome == -case.amount


def test_escalate_to_arbitration_from_round_2():
    """Merchant can voluntarily file for arbitration from round 2."""
    env = ChargebackOpsEnvironment()
    env.reset(task_id="goods_not_received_easy")
    env._issuer_agent = _ScriptedIssuer([IssuerDecision.REQUEST_MORE_EVIDENCE])
    _drive_case_into_round_2(env)

    env.step(
        ChargebackOpsAction(
            action_type="escalate_to_arbitration", case_id="CB-E1"
        )
    )

    progress = env._progress_by_case["CB-E1"]
    case = env._lookup_case("CB-E1")
    assert progress.round_number == 3
    assert progress.arb_fees_paid == ARB_FEE_PER_SIDE
    assert progress.arbitration_outcome in {
        ArbitrationOutcome.MERCHANT_WINS.value,
        ArbitrationOutcome.ISSUER_WINS.value,
    }
    if progress.arbitration_outcome == ArbitrationOutcome.MERCHANT_WINS.value:
        assert progress.final_economic_outcome == case.amount - ARB_FEE_PER_SIDE
    else:
        assert progress.final_economic_outcome == -case.amount - ARB_FEE_PER_SIDE