pauldebanshu19 commited on
Commit
b7aa1f0
·
1 Parent(s): b105545

feat: Implement multi-round dispute lifecycle with arbitration scoring and related tests

Browse files
core/models.py CHANGED
@@ -19,7 +19,7 @@ ActionType = Literal[
19
  "set_strategy",
20
  "submit_representment",
21
  "resolve_case",
22
- # v2 multi-round dispute actions (PRD §4.3)
23
  "respond_to_pre_arb",
24
  "escalate_to_arbitration",
25
  "accept_arbitration_loss",
@@ -139,6 +139,7 @@ class CaseScoreBreakdown(BaseModel):
139
  efficiency: float
140
  outcome_quality: float
141
  note_quality: float = 0.0
 
142
  weighted_score: float
143
  final_resolution: str
144
  notes: str
 
19
  "set_strategy",
20
  "submit_representment",
21
  "resolve_case",
22
+ # multi-round dispute actions
23
  "respond_to_pre_arb",
24
  "escalate_to_arbitration",
25
  "accept_arbitration_loss",
 
139
  efficiency: float
140
  outcome_quality: float
141
  note_quality: float = 0.0
142
+ escalation_roi: float = 1.0
143
  weighted_score: float
144
  final_resolution: str
145
  notes: str
evaluation/grading.py CHANGED
@@ -77,6 +77,7 @@ def score_case(
77
  efficiency=round(dims["efficiency"], 4),
78
  outcome_quality=round(dims["outcome_quality"], 4),
79
  note_quality=round(dims["note_quality"], 4),
 
80
  weighted_score=round(weighted * case.weight, 4),
81
  final_resolution=progress.final_resolution or "unresolved",
82
  notes=_build_case_notes(case, progress, step_count),
 
77
  efficiency=round(dims["efficiency"], 4),
78
  outcome_quality=round(dims["outcome_quality"], 4),
79
  note_quality=round(dims["note_quality"], 4),
80
+ escalation_roi=round(dims["escalation_roi"], 4),
81
  weighted_score=round(weighted * case.weight, 4),
82
  final_resolution=progress.final_resolution or "unresolved",
83
  notes=_build_case_notes(case, progress, step_count),
evaluation/rubrics.py CHANGED
@@ -21,8 +21,20 @@ from typing import Any
21
  from openenv.core.rubrics import Gate, Rubric, WeightedSum
22
 
23
  try:
 
 
 
 
 
 
24
  from ..scenarios.simulation import CaseProgress, InternalCase, TaskScenario
25
  except ImportError: # pragma: no cover
 
 
 
 
 
 
26
  from scenarios.simulation import CaseProgress, InternalCase, TaskScenario
27
 
28
 
@@ -250,6 +262,57 @@ class OutcomeQualityRubric(Rubric):
250
  return 0.0
251
 
252
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  class NoteQualityRubric(Rubric):
254
  """Text-based representment note scorer (contest-only)."""
255
 
@@ -342,7 +405,16 @@ def grade_representment_note(
342
 
343
 
344
  # Weights must match the order of rubrics handed to WeightedSum and sum to 1.0.
345
- CASE_DIMENSION_WEIGHTS: tuple[float, ...] = (0.25, 0.20, 0.15, 0.15, 0.10, 0.10, 0.05)
 
 
 
 
 
 
 
 
 
346
  CASE_DIMENSION_NAMES: tuple[str, ...] = (
347
  "strategy_correctness",
348
  "evidence_quality",
@@ -351,6 +423,7 @@ CASE_DIMENSION_NAMES: tuple[str, ...] = (
351
  "efficiency",
352
  "outcome_quality",
353
  "note_quality",
 
354
  )
355
 
356
 
@@ -379,6 +452,7 @@ class CaseRubric(Rubric):
379
  EfficiencyRubric(),
380
  OutcomeQualityRubric(),
381
  NoteQualityRubric(),
 
382
  ],
383
  weights=list(CASE_DIMENSION_WEIGHTS),
384
  )
 
21
  from openenv.core.rubrics import Gate, Rubric, WeightedSum
22
 
23
  try:
24
+ from ..scenarios.arbitration import (
25
+ ARB_FEE_PER_SIDE,
26
+ ARB_ISSUER_WIN_THRESHOLD,
27
+ ARB_MERCHANT_WIN_THRESHOLD,
28
+ )
29
+ from ..scenarios.issuer_model import evidence_strength_score
30
  from ..scenarios.simulation import CaseProgress, InternalCase, TaskScenario
31
  except ImportError: # pragma: no cover
32
+ from scenarios.arbitration import (
33
+ ARB_FEE_PER_SIDE,
34
+ ARB_ISSUER_WIN_THRESHOLD,
35
+ ARB_MERCHANT_WIN_THRESHOLD,
36
+ )
37
+ from scenarios.issuer_model import evidence_strength_score
38
  from scenarios.simulation import CaseProgress, InternalCase, TaskScenario
39
 
40
 
 
262
  return 0.0
263
 
264
 
265
+ def _probability_of_merchant_win(score: float) -> float:
266
+ """Map evidence strength to arbitration win probability.
267
+
268
+ Mirrors the deterministic arbitration ruling: strong packets always win,
269
+ weak packets always lose, the ambiguity band is a 50/50 coin flip.
270
+ """
271
+
272
+ if score >= ARB_MERCHANT_WIN_THRESHOLD:
273
+ return 1.0
274
+ if score <= ARB_ISSUER_WIN_THRESHOLD:
275
+ return 0.0
276
+ return 0.5
277
+
278
+
279
+ class EscalationROIRubric(Rubric):
280
+ """Score the merchant's escalate-vs-concede decision on expected value.
281
+
282
+ Escalation is rational iff ``P(win) * dispute_amount > arb_fee`` — the
283
+ arbitration fee is paid by both sides regardless, so the merchant should
284
+ only pay it when the expected recovered dispute amount exceeds the fee.
285
+
286
+ Dimension is vacuous (full credit) for cases that never entered
287
+ pre-arbitration, since no escalation decision was taken.
288
+ """
289
+
290
+ def forward(self, action: Any, observation: Any) -> float:
291
+ ctx: GradingContext = action
292
+ case = ctx.case
293
+ progress = ctx.progress
294
+
295
+ if progress.round_number < 2 and progress.arbitration_outcome is None:
296
+ return 1.0
297
+
298
+ score = evidence_strength_score(case, progress)
299
+ p_win = _probability_of_merchant_win(score)
300
+ expected_recovery = p_win * case.amount
301
+ escalate_is_positive_ev = expected_recovery > ARB_FEE_PER_SIDE
302
+
303
+ status = progress.resolution_status
304
+ if status == "won_pre_arb":
305
+ return 1.0
306
+
307
+ if status == "conceded_pre_arb":
308
+ return 0.0 if escalate_is_positive_ev else 1.0
309
+
310
+ if progress.arbitration_outcome is not None:
311
+ return 1.0 if escalate_is_positive_ev else 0.0
312
+
313
+ return 0.5
314
+
315
+
316
  class NoteQualityRubric(Rubric):
317
  """Text-based representment note scorer (contest-only)."""
318
 
 
405
 
406
 
407
  # Weights must match the order of rubrics handed to WeightedSum and sum to 1.0.
408
+ CASE_DIMENSION_WEIGHTS: tuple[float, ...] = (
409
+ 0.20,
410
+ 0.15,
411
+ 0.10,
412
+ 0.10,
413
+ 0.10,
414
+ 0.10,
415
+ 0.05,
416
+ 0.20,
417
+ )
418
  CASE_DIMENSION_NAMES: tuple[str, ...] = (
419
  "strategy_correctness",
420
  "evidence_quality",
 
423
  "efficiency",
424
  "outcome_quality",
425
  "note_quality",
426
+ "escalation_roi",
427
  )
428
 
429
 
 
452
  EfficiencyRubric(),
453
  OutcomeQualityRubric(),
454
  NoteQualityRubric(),
455
+ EscalationROIRubric(),
456
  ],
457
  weights=list(CASE_DIMENSION_WEIGHTS),
458
  )
scenarios/arbitration.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Network arbitration ruling for ChargebackOps
2
+
3
+ Arbitration is the terminal round of a disputed chargeback. The card network
4
+ (Visa / Mastercard) adjudicates and charges **both** sides a non-refundable
5
+ $250 filing fee. The loser additionally forfeits the disputed amount. The
6
+ function is pure: same inputs → same output, so benchmarks and the arbitration
7
+ EV rubric stay reproducible.
8
+
9
+ Decision rule:
10
+
11
+ score >= 0.65 → MERCHANT_WINS
12
+ score <= 0.35 → ISSUER_WINS
13
+ else → deterministic coin flip keyed by case_id
14
+
15
+ The score comes from the shared ``evidence_strength_score`` so round-2
16
+ escalation odds line up with round-3 outcomes — a merchant that barely cleared
17
+ pre-arb shouldn't suddenly crush arbitration.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ from dataclasses import dataclass
24
+ from enum import Enum
25
+
26
+ try:
27
+ from .issuer_model import evidence_strength_score
28
+ from .simulation import CaseProgress, InternalCase
29
+ except ImportError: # pragma: no cover
30
+ from scenarios.issuer_model import evidence_strength_score
31
+ from scenarios.simulation import CaseProgress, InternalCase
32
+
33
+
34
+ class ArbitrationOutcome(str, Enum):
35
+ """Terminal outcomes of network arbitration."""
36
+
37
+ MERCHANT_WINS = "merchant_wins"
38
+ ISSUER_WINS = "issuer_wins"
39
+
40
+
41
+ # Decision band edges
42
+ ARB_MERCHANT_WIN_THRESHOLD: float = 0.65
43
+ ARB_ISSUER_WIN_THRESHOLD: float = 0.35
44
+ ARB_FEE_PER_SIDE: float = 250.0
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ArbitrationRuling:
49
+ """Pure result of one arbitration adjudication."""
50
+
51
+ outcome: ArbitrationOutcome
52
+ evidence_strength_score: float
53
+ arb_fee_per_side: float
54
+ dispute_amount: float
55
+ merchant_net_pnl: float
56
+ rationale: str
57
+
58
+
59
+ def _coin_flip_merchant_wins(case_id: str) -> bool:
60
+ """Deterministic 50/50 coin flip keyed by ``case_id``.
61
+
62
+ Python's built-in ``hash`` is salted per-process so we use SHA-256 to keep
63
+ outcomes reproducible across runs and machines.
64
+ """
65
+
66
+ digest = hashlib.sha256(case_id.encode("utf-8")).digest()
67
+ return digest[0] % 2 == 0
68
+
69
+
70
+ def arbitration_ruling(
71
+ case: InternalCase, progress: CaseProgress
72
+ ) -> ArbitrationRuling:
73
+ """Adjudicate the case and return the terminal ruling.
74
+
75
+ Both sides pay the fixed fee. Loser additionally forfeits the disputed
76
+ amount. ``merchant_net_pnl`` is the merchant's net dollars from this
77
+ arbitration alone (not the full episode).
78
+ """
79
+
80
+ score = evidence_strength_score(case, progress)
81
+
82
+ if score >= ARB_MERCHANT_WIN_THRESHOLD:
83
+ outcome = ArbitrationOutcome.MERCHANT_WINS
84
+ rationale = (
85
+ f"Arbitration: packet scores {score:.2f} (>= {ARB_MERCHANT_WIN_THRESHOLD:.2f}) "
86
+ f"— network rules for the merchant."
87
+ )
88
+ elif score <= ARB_ISSUER_WIN_THRESHOLD:
89
+ outcome = ArbitrationOutcome.ISSUER_WINS
90
+ rationale = (
91
+ f"Arbitration: packet scores {score:.2f} (<= {ARB_ISSUER_WIN_THRESHOLD:.2f}) "
92
+ f"— network rules for the issuer."
93
+ )
94
+ else:
95
+ merchant_wins = _coin_flip_merchant_wins(case.case_id)
96
+ outcome = (
97
+ ArbitrationOutcome.MERCHANT_WINS
98
+ if merchant_wins
99
+ else ArbitrationOutcome.ISSUER_WINS
100
+ )
101
+ rationale = (
102
+ f"Arbitration: packet scores {score:.2f} in ambiguity band "
103
+ f"({ARB_ISSUER_WIN_THRESHOLD:.2f}, {ARB_MERCHANT_WIN_THRESHOLD:.2f}) — "
104
+ f"deterministic coin flip on case_id ruled for "
105
+ f"{'merchant' if merchant_wins else 'issuer'}."
106
+ )
107
+
108
+ if outcome == ArbitrationOutcome.MERCHANT_WINS:
109
+ merchant_net_pnl = case.amount - ARB_FEE_PER_SIDE
110
+ else:
111
+ merchant_net_pnl = -case.amount - ARB_FEE_PER_SIDE
112
+
113
+ return ArbitrationRuling(
114
+ outcome=outcome,
115
+ evidence_strength_score=score,
116
+ arb_fee_per_side=ARB_FEE_PER_SIDE,
117
+ dispute_amount=case.amount,
118
+ merchant_net_pnl=merchant_net_pnl,
119
+ rationale=rationale,
120
+ )
121
+
122
+
123
+ __all__ = [
124
+ "ArbitrationOutcome",
125
+ "ArbitrationRuling",
126
+ "arbitration_ruling",
127
+ "ARB_MERCHANT_WIN_THRESHOLD",
128
+ "ARB_ISSUER_WIN_THRESHOLD",
129
+ "ARB_FEE_PER_SIDE",
130
+ ]
scenarios/issuer_model.py CHANGED
@@ -1,12 +1,12 @@
1
- """Scripted Issuer agent for ChargebackOps v2 multi-round dispute lifecycle.
2
 
3
  The Issuer reviews a merchant's representment packet and decides whether to
4
- accept it, request more evidence (triggering pre-arbitration / round 2), or
5
  escalate to network arbitration. The decision is **deterministic** by default —
6
  benchmarks must be reproducible — with optional LLM softening reserved for the
7
  Day 4 milestone.
8
 
9
- Decision rule (PRD §4.1):
10
 
11
  1. Compute ``evidence_strength_score`` in [0, 1] from the attached packet.
12
  2. Round 1 cutoffs:
@@ -53,7 +53,7 @@ class IssuerReview:
53
  used_llm_softening: bool = False
54
 
55
 
56
- # Deterministic decision band edges (PRD §4.1).
57
  ROUND1_ACCEPT_THRESHOLD: float = 0.7
58
  ROUND1_REJECT_THRESHOLD: float = 0.4
59
  ROUND1_MIDPOINT_FALLBACK: float = 0.55
 
1
+ """Scripted Issuer agent for ChargebackOps multi-round dispute lifecycle.
2
 
3
  The Issuer reviews a merchant's representment packet and decides whether to
4
+ accept it, request more evidence (triggering pre-arbitration ), or
5
  escalate to network arbitration. The decision is **deterministic** by default —
6
  benchmarks must be reproducible — with optional LLM softening reserved for the
7
  Day 4 milestone.
8
 
9
+ Decision rule:
10
 
11
  1. Compute ``evidence_strength_score`` in [0, 1] from the attached packet.
12
  2. Round 1 cutoffs:
 
53
  used_llm_softening: bool = False
54
 
55
 
56
+ # Deterministic decision band edges
57
  ROUND1_ACCEPT_THRESHOLD: float = 0.7
58
  ROUND1_REJECT_THRESHOLD: float = 0.4
59
  ROUND1_MIDPOINT_FALLBACK: float = 0.55
scenarios/simulation.py CHANGED
@@ -85,7 +85,7 @@ class CaseProgress:
85
  deadline_penalized: bool = False
86
  notes: list[str] = field(default_factory=list)
87
  representment_note: str | None = None
88
- # v2 multi-round dispute lifecycle (PRD §4.4)
89
  round_number: int = 1
90
  issuer_decisions: list[str] = field(default_factory=list)
91
  pre_arb_evidence_added: list[str] = field(default_factory=list)
 
85
  deadline_penalized: bool = False
86
  notes: list[str] = field(default_factory=list)
87
  representment_note: str | None = None
88
+ # multi-round dispute lifecycle
89
  round_number: int = 1
90
  issuer_decisions: list[str] = field(default_factory=list)
91
  pre_arb_evidence_added: list[str] = field(default_factory=list)
server/chargeback_ops_environment.py CHANGED
@@ -23,7 +23,13 @@ try:
23
  PolicyView,
24
  VisibleCase,
25
  )
26
- from ..scenarios.issuer_model import IssuerAgent, IssuerDecision
 
 
 
 
 
 
27
  from ..scenarios.simulation import (
28
  ActionRecord,
29
  CaseProgress,
@@ -45,7 +51,13 @@ except ImportError: # pragma: no cover
45
  PolicyView,
46
  VisibleCase,
47
  )
48
- from scenarios.issuer_model import IssuerAgent, IssuerDecision
 
 
 
 
 
 
49
  from scenarios.simulation import ActionRecord, CaseProgress, InternalCase, get_task
50
 
51
 
@@ -213,15 +225,16 @@ class ChargebackOpsEnvironment(
213
  return self._submit_representment(case, note=action.note)
214
  if action.action_type == "resolve_case":
215
  return self._resolve_case(case, action.strategy)
216
- # v2 multi-round actions — full logic lands on Day 2 (PRD §4.5).
217
- if action.action_type in (
218
- "respond_to_pre_arb",
219
- "escalate_to_arbitration",
220
- "accept_arbitration_loss",
221
- ):
222
- raise ValueError(
223
- f"Action '{action.action_type}' is registered but not yet wired (Day 2)."
224
  )
 
 
 
 
225
  raise ValueError(f"Unsupported action_type '{action.action_type}'.")
226
 
227
  def _select_case(self, case_id: str | None) -> tuple[float, str]:
@@ -402,13 +415,13 @@ class ChargebackOpsEnvironment(
402
  )
403
 
404
  # v2: hand off to scripted Issuer instead of unconditionally terminating.
405
- review = self._issuer_agent.decide_review(case, progress, round_number=1)
406
- progress.issuer_decisions.append(review.decision.value)
407
 
408
  if review.decision == IssuerDecision.ACCEPT:
409
  progress.final_resolution = "contest"
410
  progress.resolution_status = "won"
411
  progress.resolved_at_step = self._state.step_count
 
412
  return (
413
  0.45,
414
  f"Issuer accepted representment for case {case.case_id} "
@@ -433,6 +446,161 @@ class ChargebackOpsEnvironment(
433
  f"Issuer escalated case {case.case_id} unexpectedly. {review.rationale}",
434
  )
435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  def _resolve_case(
437
  self,
438
  case: InternalCase,
@@ -699,6 +867,19 @@ class ChargebackOpsEnvironment(
699
  case_progress = self._progress_by_case[self._selected_case_id]
700
  if case_progress.resolution_status != "open":
701
  return ["select_case"]
 
 
 
 
 
 
 
 
 
 
 
 
 
702
  return base + [
703
  "inspect_case",
704
  "query_system",
 
23
  PolicyView,
24
  VisibleCase,
25
  )
26
+ from ..scenarios.arbitration import (
27
+ ARB_FEE_PER_SIDE,
28
+ ArbitrationOutcome,
29
+ ArbitrationRuling,
30
+ arbitration_ruling,
31
+ )
32
+ from ..scenarios.issuer_model import IssuerAgent, IssuerDecision, IssuerReview
33
  from ..scenarios.simulation import (
34
  ActionRecord,
35
  CaseProgress,
 
51
  PolicyView,
52
  VisibleCase,
53
  )
54
+ from scenarios.arbitration import (
55
+ ARB_FEE_PER_SIDE,
56
+ ArbitrationOutcome,
57
+ ArbitrationRuling,
58
+ arbitration_ruling,
59
+ )
60
+ from scenarios.issuer_model import IssuerAgent, IssuerDecision, IssuerReview
61
  from scenarios.simulation import ActionRecord, CaseProgress, InternalCase, get_task
62
 
63
 
 
225
  return self._submit_representment(case, note=action.note)
226
  if action.action_type == "resolve_case":
227
  return self._resolve_case(case, action.strategy)
228
+ if action.action_type == "respond_to_pre_arb":
229
+ return self._respond_to_pre_arb(
230
+ case,
231
+ compelling_evidence_ids=action.compelling_evidence_ids,
232
+ note=action.note,
 
 
 
233
  )
234
+ if action.action_type == "escalate_to_arbitration":
235
+ return self._escalate_to_arbitration(case)
236
+ if action.action_type == "accept_arbitration_loss":
237
+ return self._accept_arbitration_loss(case)
238
  raise ValueError(f"Unsupported action_type '{action.action_type}'.")
239
 
240
  def _select_case(self, case_id: str | None) -> tuple[float, str]:
 
415
  )
416
 
417
  # v2: hand off to scripted Issuer instead of unconditionally terminating.
418
+ review = self._invoke_issuer_review(case, progress, round_number=1)
 
419
 
420
  if review.decision == IssuerDecision.ACCEPT:
421
  progress.final_resolution = "contest"
422
  progress.resolution_status = "won"
423
  progress.resolved_at_step = self._state.step_count
424
+ progress.final_economic_outcome = case.amount
425
  return (
426
  0.45,
427
  f"Issuer accepted representment for case {case.case_id} "
 
446
  f"Issuer escalated case {case.case_id} unexpectedly. {review.rationale}",
447
  )
448
 
449
+ def _invoke_issuer_review(
450
+ self,
451
+ case: InternalCase,
452
+ progress: CaseProgress,
453
+ *,
454
+ round_number: int,
455
+ ) -> IssuerReview:
456
+ """Shared helper that calls the scripted Issuer and records the decision."""
457
+
458
+ review = self._issuer_agent.decide_review(case, progress, round_number=round_number)
459
+ progress.issuer_decisions.append(review.decision.value)
460
+ return review
461
+
462
+ def _respond_to_pre_arb(
463
+ self,
464
+ case: InternalCase,
465
+ *,
466
+ compelling_evidence_ids: list[str],
467
+ note: str | None = None,
468
+ ) -> tuple[float, str]:
469
+ """handler: attach compelling evidence and re-invoke the Issuer."""
470
+
471
+ progress = self._progress_by_case[case.case_id]
472
+ if progress.round_number != 2:
473
+ raise ValueError(
474
+ "respond_to_pre_arb is only valid after the Issuer requests more evidence."
475
+ )
476
+ if progress.resolution_status != "open":
477
+ return -0.05, f"Case {case.case_id} is already resolved."
478
+ if not compelling_evidence_ids:
479
+ raise ValueError(
480
+ "respond_to_pre_arb requires at least one compelling_evidence_id."
481
+ )
482
+
483
+ if note:
484
+ progress.representment_note = note
485
+
486
+ all_evidence = self._evidence_map(case)
487
+ added: list[str] = []
488
+ reward = 0.0
489
+ for evidence_id in compelling_evidence_ids:
490
+ if evidence_id not in all_evidence:
491
+ reward -= 0.04
492
+ continue
493
+ if evidence_id in progress.attached_evidence_ids:
494
+ reward -= 0.02
495
+ continue
496
+ # Retrieve it lazily if the agent points at a known system-sourced id.
497
+ progress.retrieved_evidence_ids.add(evidence_id)
498
+ progress.attached_evidence_ids.append(evidence_id)
499
+ progress.pre_arb_evidence_added.append(evidence_id)
500
+ added.append(evidence_id)
501
+ evidence = all_evidence[evidence_id]
502
+ if evidence.helpful:
503
+ reward += 0.06
504
+ elif evidence.harmful:
505
+ reward -= 0.1
506
+ else:
507
+ reward += 0.01
508
+
509
+ review = self._invoke_issuer_review(case, progress, round_number=2)
510
+
511
+ if review.decision == IssuerDecision.ACCEPT:
512
+ progress.final_resolution = "contest"
513
+ progress.resolution_status = "won_pre_arb"
514
+ progress.resolved_at_step = self._state.step_count
515
+ progress.final_economic_outcome = case.amount
516
+ return (
517
+ reward + 0.35,
518
+ f"Issuer accepted pre-arbitration packet for case {case.case_id} "
519
+ f"(score {review.evidence_strength_score:.2f}, added "
520
+ f"{', '.join(added) or 'no new'}). {review.rationale}",
521
+ )
522
+
523
+ # ESCALATE_TO_ARBITRATION — issuer files network arbitration.
524
+ ruling = self._apply_arbitration(case, progress)
525
+ return (
526
+ reward + self._arbitration_reward(ruling),
527
+ f"Issuer escalated case {case.case_id} to arbitration "
528
+ f"(score {review.evidence_strength_score:.2f}). {ruling.rationale}",
529
+ )
530
+
531
+ def _escalate_to_arbitration(self, case: InternalCase) -> tuple[float, str]:
532
+ """handler: merchant voluntarily files for arbitration."""
533
+
534
+ progress = self._progress_by_case[case.case_id]
535
+ if progress.round_number != 2:
536
+ raise ValueError(
537
+ "escalate_to_arbitration is only valid after a pre-arbitration round."
538
+ )
539
+ if progress.resolution_status != "open":
540
+ return -0.05, f"Case {case.case_id} is already resolved."
541
+
542
+ ruling = self._apply_arbitration(case, progress)
543
+ return (
544
+ self._arbitration_reward(ruling),
545
+ f"Merchant escalated case {case.case_id} to network arbitration. "
546
+ f"{ruling.rationale}",
547
+ )
548
+
549
+ def _accept_arbitration_loss(self, case: InternalCase) -> tuple[float, str]:
550
+ """handler: merchant concedes rather than pay the arbitration fee."""
551
+
552
+ progress = self._progress_by_case[case.case_id]
553
+ if progress.round_number != 2:
554
+ raise ValueError(
555
+ "accept_arbitration_loss is only valid after a pre-arbitration round."
556
+ )
557
+ if progress.resolution_status != "open":
558
+ return -0.05, f"Case {case.case_id} is already resolved."
559
+
560
+ progress.final_resolution = "accept_arbitration_loss"
561
+ progress.resolution_status = "conceded_pre_arb"
562
+ progress.resolved_at_step = self._state.step_count
563
+ progress.arbitration_outcome = None
564
+ progress.arb_fees_paid = 0.0
565
+ progress.final_economic_outcome = -case.amount
566
+ return (
567
+ -0.1,
568
+ f"Merchant accepted arbitration loss on case {case.case_id}; "
569
+ f"no arb fee paid but the $"
570
+ f"{case.amount:.2f} dispute amount is forfeited.",
571
+ )
572
+
573
+ def _apply_arbitration(
574
+ self, case: InternalCase, progress: CaseProgress
575
+ ) -> ArbitrationRuling:
576
+ """Run the deterministic arbitration rule and record its economic impact."""
577
+
578
+ ruling = arbitration_ruling(case, progress)
579
+ progress.round_number = 3
580
+ progress.arbitration_outcome = ruling.outcome.value
581
+ progress.arb_fees_paid = ruling.arb_fee_per_side
582
+ progress.final_economic_outcome = ruling.merchant_net_pnl
583
+ progress.final_resolution = "contest"
584
+ progress.resolved_at_step = self._state.step_count
585
+ if ruling.outcome == ArbitrationOutcome.MERCHANT_WINS:
586
+ progress.resolution_status = "won_arbitration"
587
+ else:
588
+ progress.resolution_status = "lost_arbitration"
589
+ return ruling
590
+
591
+ @staticmethod
592
+ def _arbitration_reward(ruling: ArbitrationRuling) -> float:
593
+ """Map an arbitration ruling to a step reward.
594
+
595
+ Winning arbitration is worth more than winning round 1 because it
596
+ clears a harder bar; losing stings more because both the fee and the
597
+ disputed amount are gone.
598
+ """
599
+
600
+ if ruling.outcome == ArbitrationOutcome.MERCHANT_WINS:
601
+ return 0.55
602
+ return -0.35
603
+
604
  def _resolve_case(
605
  self,
606
  case: InternalCase,
 
867
  case_progress = self._progress_by_case[self._selected_case_id]
868
  if case_progress.resolution_status != "open":
869
  return ["select_case"]
870
+ if case_progress.round_number == 2:
871
+ # Pre-arbitration: investigation actions still help (e.g. to pull
872
+ # compelling evidence from a system) but the round-1 submit path is
873
+ # closed off in favour of the three terminal v2 actions.
874
+ return base + [
875
+ "query_system",
876
+ "retrieve_policy",
877
+ "add_evidence",
878
+ "remove_evidence",
879
+ "respond_to_pre_arb",
880
+ "escalate_to_arbitration",
881
+ "accept_arbitration_loss",
882
+ ]
883
  return base + [
884
  "inspect_case",
885
  "query_system",
tests/test_arbitration.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for scenarios.arbitration
2
+
3
+ These tests pin the three bands of the arbitration ruling (merchant-wins,
4
+ issuer-wins, deterministic coin flip) and the $250-per-side fee accounting so
5
+ a regression in the terminal-round math shows up before the end-to-end env
6
+ tests do.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import replace
12
+
13
+ from scenarios.arbitration import (
14
+ ARB_FEE_PER_SIDE,
15
+ ARB_ISSUER_WIN_THRESHOLD,
16
+ ARB_MERCHANT_WIN_THRESHOLD,
17
+ ArbitrationOutcome,
18
+ _coin_flip_merchant_wins,
19
+ arbitration_ruling,
20
+ )
21
+ from scenarios.simulation import CaseProgress, get_task
22
+
23
+
24
+ _TASK = get_task("goods_not_received_easy")
25
+ _CASE = _TASK.cases[0]
26
+
27
+
28
+ def _progress(attached: list[str]) -> CaseProgress:
29
+ p = CaseProgress()
30
+ p.attached_evidence_ids = list(attached)
31
+ return p
32
+
33
+
34
+ def test_merchant_wins_on_strong_packet():
35
+ """Score 0.8 clears the 0.65 bar → MERCHANT_WINS, merchant keeps amount − fee."""
36
+ progress = _progress(["E1-ORDER-CONF", "E1-DELIVERY-SCAN"])
37
+ ruling = arbitration_ruling(_CASE, progress)
38
+ assert ruling.evidence_strength_score >= ARB_MERCHANT_WIN_THRESHOLD
39
+ assert ruling.outcome == ArbitrationOutcome.MERCHANT_WINS
40
+ assert ruling.arb_fee_per_side == ARB_FEE_PER_SIDE
41
+ assert ruling.merchant_net_pnl == _CASE.amount - ARB_FEE_PER_SIDE
42
+
43
+
44
+ def test_issuer_wins_on_empty_packet():
45
+ """Score 0 sits below the 0.35 floor → ISSUER_WINS, merchant eats amount + fee."""
46
+ progress = _progress([])
47
+ ruling = arbitration_ruling(_CASE, progress)
48
+ assert ruling.evidence_strength_score <= ARB_ISSUER_WIN_THRESHOLD
49
+ assert ruling.outcome == ArbitrationOutcome.ISSUER_WINS
50
+ assert ruling.merchant_net_pnl == -_CASE.amount - ARB_FEE_PER_SIDE
51
+
52
+
53
+ def test_ambiguity_band_uses_deterministic_coin_flip():
54
+ """Scores in (0.35, 0.65) map to a case_id-keyed coin flip — reproducible."""
55
+ # Two helpful-only evidence ids → 0.4 band score, no required subset.
56
+ progress = _progress(["E1-DELIVERY-SCAN", "E1-SUPPORT-ACK"])
57
+ r1 = arbitration_ruling(_CASE, progress)
58
+ r2 = arbitration_ruling(_CASE, progress)
59
+ assert r1.outcome == r2.outcome
60
+ assert ARB_ISSUER_WIN_THRESHOLD < r1.evidence_strength_score < ARB_MERCHANT_WIN_THRESHOLD
61
+ expected = (
62
+ ArbitrationOutcome.MERCHANT_WINS
63
+ if _coin_flip_merchant_wins(_CASE.case_id)
64
+ else ArbitrationOutcome.ISSUER_WINS
65
+ )
66
+ assert r1.outcome == expected
67
+
68
+
69
+ def test_coin_flip_varies_across_case_ids():
70
+ """Changing only the case_id must change the coin-flip answer for some cases.
71
+
72
+ If every case_id hashed to the same parity, the ambiguity band wouldn't
73
+ actually be 50/50 across the benchmark — this test guards against that.
74
+ """
75
+ flips = {_coin_flip_merchant_wins(f"CB-TEST-{i}") for i in range(20)}
76
+ assert flips == {True, False}
77
+
78
+
79
+ def test_ruling_is_pure():
80
+ """Same inputs, same outputs — required for reproducible benchmarks."""
81
+ progress = _progress(["E1-ORDER-CONF", "E1-DELIVERY-SCAN"])
82
+ r1 = arbitration_ruling(_CASE, progress)
83
+ r2 = arbitration_ruling(_CASE, progress)
84
+ assert r1 == r2
85
+
86
+ # A second case_id clone with identical evidence should give the same
87
+ # MERCHANT_WINS outcome (score is above 0.65, so no coin-flip involved).
88
+ cloned = replace(_CASE, case_id="CB-CLONE-1")
89
+ r3 = arbitration_ruling(cloned, progress)
90
+ assert r3.outcome == r1.outcome
91
+ assert r3.merchant_net_pnl == r1.merchant_net_pnl
tests/test_env.py CHANGED
@@ -1,8 +1,65 @@
 
1
  from scenarios.case_generator import generate_task
 
2
  from core.models import ChargebackOpsAction
3
  from server.chargeback_ops_environment import ChargebackOpsEnvironment
4
 
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  def test_reset_returns_task_observation():
7
  env = ChargebackOpsEnvironment()
8
  obs = env.reset(task_id="goods_not_received_easy")
@@ -106,3 +163,126 @@ def test_generated_task_covers_all_reason_codes():
106
  "duplicate_processing", "product_not_as_described", "service_not_provided",
107
  }
108
  assert expected.issubset(seen_codes), f"Missing: {expected - seen_codes}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from scenarios.arbitration import ARB_FEE_PER_SIDE, ArbitrationOutcome
2
  from scenarios.case_generator import generate_task
3
+ from scenarios.issuer_model import IssuerDecision, IssuerReview
4
  from core.models import ChargebackOpsAction
5
  from server.chargeback_ops_environment import ChargebackOpsEnvironment
6
 
7
 
8
+ class _ScriptedIssuer:
9
+ """Deterministic Issuer stub that returns planned decisions in sequence.
10
+
11
+ Lets round-2 env tests exercise the full dispute lifecycle without
12
+ depending on the exact thresholds in IssuerAgent — the scoring math is
13
+ already pinned in tests/test_issuer.py.
14
+ """
15
+
16
+ def __init__(self, decisions: list[IssuerDecision]):
17
+ self._decisions = list(decisions)
18
+ self.calls: list[int] = []
19
+
20
+ def decide_review(self, case, progress, round_number):
21
+ self.calls.append(round_number)
22
+ idx = min(len(self.calls) - 1, len(self._decisions) - 1)
23
+ decision = self._decisions[idx]
24
+ return IssuerReview(
25
+ decision=decision,
26
+ evidence_strength_score=0.5,
27
+ rationale=f"stub decision {decision.value} at r{round_number}",
28
+ )
29
+
30
+
31
+ def _drive_case_into_round_2(env: ChargebackOpsEnvironment) -> None:
32
+ """Select CB-E1, attach required evidence, submit — with Issuer stub that
33
+ requests more evidence on round 1. Leaves env in round-2 state.
34
+ """
35
+ env.step(ChargebackOpsAction(action_type="select_case", case_id="CB-E1"))
36
+ env.step(
37
+ ChargebackOpsAction(
38
+ action_type="query_system", case_id="CB-E1", system_name="orders"
39
+ )
40
+ )
41
+ env.step(
42
+ ChargebackOpsAction(
43
+ action_type="query_system", case_id="CB-E1", system_name="shipping"
44
+ )
45
+ )
46
+ env.step(
47
+ ChargebackOpsAction(
48
+ action_type="add_evidence",
49
+ case_id="CB-E1",
50
+ evidence_ids=["E1-ORDER-CONF", "E1-DELIVERY-SCAN"],
51
+ )
52
+ )
53
+ env.step(
54
+ ChargebackOpsAction(
55
+ action_type="set_strategy", case_id="CB-E1", strategy="contest"
56
+ )
57
+ )
58
+ env.step(
59
+ ChargebackOpsAction(action_type="submit_representment", case_id="CB-E1")
60
+ )
61
+
62
+
63
  def test_reset_returns_task_observation():
64
  env = ChargebackOpsEnvironment()
65
  obs = env.reset(task_id="goods_not_received_easy")
 
163
  "duplicate_processing", "product_not_as_described", "service_not_provided",
164
  }
165
  assert expected.issubset(seen_codes), f"Missing: {expected - seen_codes}"
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # multi-round dispute lifecycle
170
+ # ---------------------------------------------------------------------------
171
+
172
+
173
+ def test_round2_available_actions_exclude_submit_representment():
174
+ env = ChargebackOpsEnvironment()
175
+ env.reset(task_id="goods_not_received_easy")
176
+ env._issuer_agent = _ScriptedIssuer([IssuerDecision.REQUEST_MORE_EVIDENCE])
177
+ _drive_case_into_round_2(env)
178
+
179
+ actions = env._build_available_actions()
180
+ assert "submit_representment" not in actions
181
+ assert "respond_to_pre_arb" in actions
182
+ assert "escalate_to_arbitration" in actions
183
+ assert "accept_arbitration_loss" in actions
184
+
185
+
186
+ def test_full_three_round_cycle_ending_in_arbitration():
187
+ env = ChargebackOpsEnvironment()
188
+ env.reset(task_id="goods_not_received_easy")
189
+ env._issuer_agent = _ScriptedIssuer(
190
+ [
191
+ IssuerDecision.REQUEST_MORE_EVIDENCE,
192
+ IssuerDecision.ESCALATE_TO_ARBITRATION,
193
+ ]
194
+ )
195
+ _drive_case_into_round_2(env)
196
+
197
+ obs = env.step(
198
+ ChargebackOpsAction(
199
+ action_type="respond_to_pre_arb",
200
+ case_id="CB-E1",
201
+ compelling_evidence_ids=["E1-SIGNATURE"],
202
+ note="Added signature-level delivery proof for pre-arb.",
203
+ )
204
+ )
205
+
206
+ progress = env._progress_by_case["CB-E1"]
207
+ assert progress.round_number == 3
208
+ assert progress.arbitration_outcome == ArbitrationOutcome.MERCHANT_WINS.value
209
+ assert progress.arb_fees_paid == ARB_FEE_PER_SIDE
210
+ assert progress.final_economic_outcome == progress.final_economic_outcome
211
+ assert progress.final_economic_outcome is not None
212
+ assert progress.resolution_status == "won_arbitration"
213
+ assert obs.done is True
214
+ assert "arbitration" in obs.last_action_result.lower()
215
+
216
+
217
+ def test_respond_to_pre_arb_accepted_skips_arbitration():
218
+ """If the Issuer accepts in round 2, no arbitration fee is charged and
219
+ the merchant keeps the full dispute amount."""
220
+ env = ChargebackOpsEnvironment()
221
+ env.reset(task_id="goods_not_received_easy")
222
+ env._issuer_agent = _ScriptedIssuer(
223
+ [IssuerDecision.REQUEST_MORE_EVIDENCE, IssuerDecision.ACCEPT]
224
+ )
225
+ _drive_case_into_round_2(env)
226
+
227
+ env.step(
228
+ ChargebackOpsAction(
229
+ action_type="respond_to_pre_arb",
230
+ case_id="CB-E1",
231
+ compelling_evidence_ids=["E1-SIGNATURE"],
232
+ )
233
+ )
234
+
235
+ progress = env._progress_by_case["CB-E1"]
236
+ assert progress.resolution_status == "won_pre_arb"
237
+ assert progress.arb_fees_paid == 0.0
238
+ assert progress.arbitration_outcome is None
239
+ case = env._lookup_case("CB-E1")
240
+ assert progress.final_economic_outcome == case.amount
241
+
242
+
243
+ def test_accept_arbitration_loss_skips_fees():
244
+ """Conceding pre-arb forfeits the dispute amount but avoids the $250 fee."""
245
+ env = ChargebackOpsEnvironment()
246
+ env.reset(task_id="goods_not_received_easy")
247
+ env._issuer_agent = _ScriptedIssuer([IssuerDecision.REQUEST_MORE_EVIDENCE])
248
+ _drive_case_into_round_2(env)
249
+
250
+ env.step(
251
+ ChargebackOpsAction(
252
+ action_type="accept_arbitration_loss", case_id="CB-E1"
253
+ )
254
+ )
255
+
256
+ progress = env._progress_by_case["CB-E1"]
257
+ case = env._lookup_case("CB-E1")
258
+ assert progress.resolution_status == "conceded_pre_arb"
259
+ assert progress.arb_fees_paid == 0.0
260
+ assert progress.arbitration_outcome is None
261
+ assert progress.final_economic_outcome == -case.amount
262
+
263
+
264
+ def test_escalate_to_arbitration_from_round_2():
265
+ """Merchant can voluntarily file for arbitration from round 2."""
266
+ env = ChargebackOpsEnvironment()
267
+ env.reset(task_id="goods_not_received_easy")
268
+ env._issuer_agent = _ScriptedIssuer([IssuerDecision.REQUEST_MORE_EVIDENCE])
269
+ _drive_case_into_round_2(env)
270
+
271
+ env.step(
272
+ ChargebackOpsAction(
273
+ action_type="escalate_to_arbitration", case_id="CB-E1"
274
+ )
275
+ )
276
+
277
+ progress = env._progress_by_case["CB-E1"]
278
+ case = env._lookup_case("CB-E1")
279
+ assert progress.round_number == 3
280
+ assert progress.arb_fees_paid == ARB_FEE_PER_SIDE
281
+ assert progress.arbitration_outcome in {
282
+ ArbitrationOutcome.MERCHANT_WINS.value,
283
+ ArbitrationOutcome.ISSUER_WINS.value,
284
+ }
285
+ if progress.arbitration_outcome == ArbitrationOutcome.MERCHANT_WINS.value:
286
+ assert progress.final_economic_outcome == case.amount - ARB_FEE_PER_SIDE
287
+ else:
288
+ assert progress.final_economic_outcome == -case.amount - ARB_FEE_PER_SIDE
tests/test_escalation_roi.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for EscalationROIRubric — escalate-vs-concede EV scoring.
2
+
3
+ The rubric encodes the economic rule that escalating to network arbitration
4
+ is only rational when ``P(win) * dispute_amount > arb_fee``. These tests pin
5
+ each branch of that decision so a regression in the fee, thresholds, or
6
+ probability mapping is caught immediately.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import replace
12
+
13
+ from evaluation.rubrics import (
14
+ CASE_DIMENSION_NAMES,
15
+ CASE_DIMENSION_WEIGHTS,
16
+ EscalationROIRubric,
17
+ GradingContext,
18
+ _probability_of_merchant_win,
19
+ )
20
+ from scenarios.arbitration import ARB_FEE_PER_SIDE
21
+ from scenarios.simulation import CaseProgress, get_task
22
+
23
+
24
+ _TASK = get_task("goods_not_received_easy")
25
+ _CASE = _TASK.cases[0]
26
+
27
+
28
+ def _progress(
29
+ *,
30
+ attached: list[str] | None = None,
31
+ round_number: int = 1,
32
+ resolution_status: str = "open",
33
+ arbitration_outcome: str | None = None,
34
+ ) -> CaseProgress:
35
+ p = CaseProgress()
36
+ p.attached_evidence_ids = list(attached or [])
37
+ p.round_number = round_number
38
+ p.resolution_status = resolution_status
39
+ p.arbitration_outcome = arbitration_outcome
40
+ return p
41
+
42
+
43
+ def test_weights_sum_to_one_and_match_names():
44
+ assert abs(sum(CASE_DIMENSION_WEIGHTS) - 1.0) < 1e-6
45
+ assert len(CASE_DIMENSION_WEIGHTS) == len(CASE_DIMENSION_NAMES) == 8
46
+ assert "escalation_roi" in CASE_DIMENSION_NAMES
47
+
48
+
49
+ def test_probability_mapping_matches_arbitration_thresholds():
50
+ assert _probability_of_merchant_win(0.9) == 1.0
51
+ assert _probability_of_merchant_win(0.2) == 0.0
52
+ assert _probability_of_merchant_win(0.5) == 0.5
53
+
54
+
55
+ def test_vacuous_full_credit_when_case_never_escalated():
56
+ """A case that never reached pre-arbitration gets full credit by default."""
57
+ ctx = GradingContext(case=_CASE, progress=_progress(), step_count=5)
58
+ assert EscalationROIRubric()(ctx, None) == 1.0
59
+
60
+
61
+ def test_pre_arb_accept_is_full_credit():
62
+ """Winning on the pre-arbitration re-submit without filing arbitration is
63
+ the optimal path."""
64
+ progress = _progress(
65
+ attached=["E1-ORDER-CONF", "E1-DELIVERY-SCAN"],
66
+ round_number=2,
67
+ resolution_status="won_pre_arb",
68
+ )
69
+ ctx = GradingContext(case=_CASE, progress=progress, step_count=5)
70
+ assert EscalationROIRubric()(ctx, None) == 1.0
71
+
72
+
73
+ def test_reward_positive_ev_escalation():
74
+ """Strong packet → P(win)=1.0 × $129.99 > $250? No. Test with bigger amount."""
75
+ big_case = replace(_CASE, case_id="CB-BIG", amount=1000.0)
76
+ progress = _progress(
77
+ attached=["E1-ORDER-CONF", "E1-DELIVERY-SCAN"],
78
+ round_number=3,
79
+ resolution_status="won_arbitration",
80
+ arbitration_outcome="merchant_wins",
81
+ )
82
+ ctx = GradingContext(case=big_case, progress=progress, step_count=5)
83
+ # P(win)=1.0, EV = 1.0 * 1000 = 1000 > 250 → escalation was rational.
84
+ assert EscalationROIRubric()(ctx, None) == 1.0
85
+
86
+
87
+ def test_penalise_negative_ev_escalation():
88
+ """Weak packet + small amount → escalating was a losing bet."""
89
+ small_case = replace(_CASE, case_id="CB-SMALL", amount=100.0)
90
+ progress = _progress(
91
+ attached=[],
92
+ round_number=3,
93
+ resolution_status="lost_arbitration",
94
+ arbitration_outcome="issuer_wins",
95
+ )
96
+ ctx = GradingContext(case=small_case, progress=progress, step_count=5)
97
+ # P(win)=0.0, EV = 0 < 250 → escalation was -EV.
98
+ assert EscalationROIRubric()(ctx, None) == 0.0
99
+
100
+
101
+ def test_penalise_concede_when_escalation_was_positive_ev():
102
+ """Conceding with a strong packet + large amount leaves money on the table."""
103
+ big_case = replace(_CASE, case_id="CB-BIG2", amount=1000.0)
104
+ progress = _progress(
105
+ attached=["E1-ORDER-CONF", "E1-DELIVERY-SCAN"],
106
+ round_number=2,
107
+ resolution_status="conceded_pre_arb",
108
+ )
109
+ ctx = GradingContext(case=big_case, progress=progress, step_count=5)
110
+ # Strong evidence, big amount → should have escalated.
111
+ assert EscalationROIRubric()(ctx, None) == 0.0
112
+
113
+
114
+ def test_reward_concede_when_escalation_was_negative_ev():
115
+ """Conceding a hopeless case is the +EV decision vs burning $250."""
116
+ small_case = replace(_CASE, case_id="CB-SMALL2", amount=100.0)
117
+ progress = _progress(
118
+ attached=[],
119
+ round_number=2,
120
+ resolution_status="conceded_pre_arb",
121
+ )
122
+ ctx = GradingContext(case=small_case, progress=progress, step_count=5)
123
+ assert EscalationROIRubric()(ctx, None) == 1.0
124
+
125
+
126
+ def test_fee_threshold_is_the_pivot():
127
+ """Sanity check the exact EV pivot: P(win)*amount vs ARB_FEE_PER_SIDE."""
128
+ assert ARB_FEE_PER_SIDE == 250.0
129
+ # P(win)=0.5 × $600 = 300 > 250 → escalate is rational
130
+ mid_case = replace(_CASE, case_id="CB-MID", amount=600.0)
131
+ # Score in ambiguity band by attaching two helpful-only ids.
132
+ progress = _progress(
133
+ attached=["E1-DELIVERY-SCAN", "E1-SUPPORT-ACK"],
134
+ round_number=3,
135
+ resolution_status="won_arbitration",
136
+ arbitration_outcome="merchant_wins",
137
+ )
138
+ ctx = GradingContext(case=mid_case, progress=progress, step_count=5)
139
+ assert EscalationROIRubric()(ctx, None) == 1.0
tests/test_grader.py CHANGED
@@ -30,11 +30,11 @@ def test_environment_exposes_rubric_tree():
30
  expected = {
31
  "case_rubric",
32
  "case_rubric.aggregator",
33
- *(f"case_rubric.aggregator.rubric_{i}" for i in range(7)),
34
  }
35
  assert expected.issubset(names)
36
 
37
  # Weights must sum to 1.0 (WeightedSum enforces this at construction but
38
  # we lock the constant here so weight changes stay intentional).
39
  assert abs(sum(CASE_DIMENSION_WEIGHTS) - 1.0) < 1e-6
40
- assert len(CASE_DIMENSION_WEIGHTS) == 7
 
30
  expected = {
31
  "case_rubric",
32
  "case_rubric.aggregator",
33
+ *(f"case_rubric.aggregator.rubric_{i}" for i in range(8)),
34
  }
35
  assert expected.issubset(names)
36
 
37
  # Weights must sum to 1.0 (WeightedSum enforces this at construction but
38
  # we lock the constant here so weight changes stay intentional).
39
  assert abs(sum(CASE_DIMENSION_WEIGHTS) - 1.0) < 1e-6
40
+ assert len(CASE_DIMENSION_WEIGHTS) == 8
tests/test_issuer.py CHANGED
@@ -1,4 +1,4 @@
1
- """Unit tests for the scripted IssuerAgent (PRD §4.1).
2
 
3
  Each test pins one branch of the deterministic decision matrix so a regression
4
  in `evidence_strength_score` or the round-1 / round-2 thresholds shows up
 
1
+ """Unit tests for the scripted IssuerAgent
2
 
3
  Each test pins one branch of the deterministic decision matrix so a regression
4
  in `evidence_strength_score` or the round-1 / round-2 thresholds shows up