| """ |
| tests/test_step.py |
| ================== |
| Phase 4 rewrite β 24 tests covering every CLAUDE.md step() contract point. |
| """ |
| import pytest |
| from pydantic import ValidationError |
|
|
| from unified_gateway import AEPOAction, AEPOObservation, UFRGObservation, UFRGReward, UnifiedFintechEnv |
|
|
|
|
| |
| |
| |
|
|
| @pytest.fixture |
| def env() -> UnifiedFintechEnv: |
| e = UnifiedFintechEnv() |
| e.reset(options={"task": "easy"}) |
| return e |
|
|
|
|
| def make_action(**kwargs) -> AEPOAction: |
| """Build an AEPOAction with safe defaults for unspecified fields.""" |
| defaults = dict(risk_decision=0, crypto_verify=0, infra_routing=0, |
| db_retry_policy=0, settlement_policy=0, app_priority=2) |
| defaults.update(kwargs) |
| return AEPOAction(**defaults) |
|
|
|
|
| def _force_obs(env: UnifiedFintechEnv, **fields) -> None: |
| """Overwrite env._current_obs with specific field values.""" |
| current = env._current_obs.model_dump() |
| current.update(fields) |
| env._current_obs = AEPOObservation.model_construct(**current) |
|
|
|
|
| |
| |
| |
|
|
| def test_step_returns_4_tuple(env: UnifiedFintechEnv) -> None: |
| """step() must return exactly (obs, reward, done, info).""" |
| result = env.step(make_action()) |
| assert len(result) == 4 |
|
|
|
|
| |
| |
| |
|
|
| @pytest.mark.parametrize("task", ["easy", "medium", "hard"]) |
| def test_reward_always_in_range(task: str) -> None: |
| """reward.value must never leave [0.0, 1.0] across all tasks.""" |
| e = UnifiedFintechEnv() |
| e.reset(options={"task": task}) |
| for _ in range(10): |
| _, tr, _, _ = e.step(make_action()) |
| assert 0.0 <= tr.value <= 1.0, f"reward={tr.value} for task={task}" |
|
|
|
|
| |
| |
| |
|
|
| def test_done_true_on_kafka_lag_crash(env: UnifiedFintechEnv) -> None: |
| """kafka_lag > CRASH_THRESHOLD (4000) for 2 consecutive steps must set done=True. |
| Fix 11.1: single-step grace period β one spike is not a crash, sustained overload is. |
| """ |
| _force_obs(env, kafka_lag=4500.0) |
| env._rolling_lag = 0.0 |
| |
| _, _, done_step1, _ = env.step(make_action()) |
| assert done_step1 is False, "Grace step should not terminate episode" |
| |
| _force_obs(env, kafka_lag=4500.0) |
| _, tr, done, info = env.step(make_action()) |
| assert done is True |
| assert tr.value == 0.0 |
| assert info["termination_reason"] == "crash" |
|
|
|
|
| |
| |
| |
|
|
| def test_done_true_on_fraud_catastrophe(env: UnifiedFintechEnv) -> None: |
| """Approve + SkipVerify + risk_score > 80 β reward=0.0, done=True.""" |
| _force_obs(env, risk_score=90.0) |
| _, tr, done, info = env.step(make_action(risk_decision=0, crypto_verify=1)) |
| assert done is True |
| assert tr.value == 0.0 |
| assert info["termination_reason"] == "fraud" |
|
|
|
|
| |
| |
| |
|
|
| def test_done_false_before_step_100_easy() -> None: |
| """99 valid steps on easy must not set done=True.""" |
| e = UnifiedFintechEnv() |
| e.reset(options={"task": "easy"}) |
| for i in range(99): |
| _, _, done, _ = e.step(make_action(risk_decision=1, crypto_verify=1, infra_routing=1)) |
| assert done is False, f"done=True unexpectedly at step {i+1}" |
|
|
|
|
| |
| |
| |
|
|
| def test_done_true_after_100_steps() -> None: |
| """Episode must terminate at exactly 100 steps.""" |
| e = UnifiedFintechEnv() |
| e.reset(options={"task": "easy"}) |
| done = False |
| for _ in range(100): |
| _, _, done, _ = e.step(make_action(risk_decision=1, crypto_verify=1, infra_routing=1)) |
| assert done is True |
|
|
|
|
| |
| |
| |
|
|
| def test_fraud_catastrophe_reward_is_zero(env: UnifiedFintechEnv) -> None: |
| """Fraud catastrophe must produce reward=0.0, not just low reward.""" |
| _force_obs(env, risk_score=95.0) |
| _, tr, _, _ = env.step(make_action(risk_decision=0, crypto_verify=1)) |
| assert tr.value == 0.0 |
|
|
|
|
| |
| |
| |
|
|
| def test_crash_reward_is_zero(env: UnifiedFintechEnv) -> None: |
| """kafka_lag > 4000 for 2 consecutive steps must produce reward=0.0. |
| Fix 11.1: grace period means the crash fires on the second sustained step. |
| """ |
| _force_obs(env, kafka_lag=4001.0) |
| env._rolling_lag = 0.0 |
| |
| env.step(make_action()) |
| |
| _force_obs(env, kafka_lag=4001.0) |
| _, tr, done, _ = env.step(make_action()) |
| assert done is True |
| assert tr.value == 0.0 |
|
|
|
|
| |
| |
| |
|
|
| def test_sla_breach_applies_penalty(env: UnifiedFintechEnv) -> None: |
| """rolling_p99 > 800 on a clean step should yield reward β 0.5 (0.8 - 0.30).""" |
| _force_obs(env, kafka_lag=0.0, rolling_p99=1000.0, risk_score=10.0) |
| env._rolling_lag = 0.0 |
| env._rolling_p99 = 1000.0 |
| _, _, _, info = env.step(make_action()) |
| assert info["reward_breakdown"]["sla_penalty"] == -0.30 |
| assert abs(info["reward_breakdown"]["final"] - 0.5) < 0.15 |
|
|
|
|
| |
| |
| |
|
|
| def test_challenge_bonus_on_high_risk(env: UnifiedFintechEnv) -> None: |
| """Challenge (risk_decision=2) on risk_score > 80 β bonus += 0.05.""" |
| _force_obs(env, risk_score=85.0, kafka_lag=0.0, rolling_p99=0.0) |
| env._rolling_lag = 0.0 |
| _, _, _, info = env.step(make_action(risk_decision=2, crypto_verify=0)) |
| assert info["reward_breakdown"]["bonus"] >= 0.05 |
|
|
|
|
| |
| |
| |
|
|
| def test_reject_skip_verify_blind_spot_bonus(env: UnifiedFintechEnv) -> None: |
| """Reject + SkipVerify on risk > 80 must trigger blind_spot_triggered=True and +0.04.""" |
| _force_obs(env, risk_score=90.0, kafka_lag=0.0, rolling_p99=0.0) |
| env._rolling_lag = 0.0 |
| _, _, _, info = env.step(make_action(risk_decision=1, crypto_verify=1)) |
| assert info["blind_spot_triggered"] is True |
| assert info["reward_breakdown"]["bonus"] >= 0.04 |
|
|
|
|
| |
| |
| |
|
|
| def test_circuit_breaker_penalty(env: UnifiedFintechEnv) -> None: |
| """CircuitBreaker (infra_routing=2) must apply -0.50 infra_penalty.""" |
| _force_obs(env, kafka_lag=0.0, rolling_p99=0.0, risk_score=10.0) |
| env._rolling_lag = 0.0 |
| _, _, _, info = env.step(make_action(infra_routing=2)) |
| assert info["reward_breakdown"]["infra_penalty"] <= -0.50 |
| assert info["circuit_breaker_tripped"] is True |
|
|
|
|
| |
| |
| |
|
|
| def test_deferred_async_normal_phase_penalty(env: UnifiedFintechEnv) -> None: |
| """DeferredAsyncFallback (settlement_policy=1) in Normal phase β -0.15.""" |
| |
| _force_obs(env, kafka_lag=0.0, rolling_p99=0.0, risk_score=10.0, bank_api_status=0.0) |
| env._rolling_lag = 0.0 |
| env._last_event_type = "normal" |
| _, _, _, info = env.step(make_action(settlement_policy=1)) |
| assert info["reward_breakdown"]["settlement_penalty"] <= -0.15 |
|
|
|
|
| |
| |
| |
|
|
| def test_deferred_async_cumulative_penalty(env: UnifiedFintechEnv) -> None: |
| """Cumulative settlement backlog > 10 triggers -0.20 additional penalty.""" |
| env._cumulative_settlement_backlog = 10 |
| _force_obs(env, kafka_lag=0.0, rolling_p99=0.0, risk_score=10.0, bank_api_status=0.0) |
| env._rolling_lag = 0.0 |
| env._last_event_type = "normal" |
| _, _, _, info = env.step(make_action(settlement_policy=1)) |
| assert info["consecutive_deferred_async"] == 11 |
| |
| assert info["reward_breakdown"]["settlement_penalty"] <= -0.35 |
|
|
|
|
| |
| |
| |
|
|
| def test_exponential_backoff_low_pool_penalty(env: UnifiedFintechEnv) -> None: |
| """ExponentialBackoff (db_retry_policy=1) when db_pool < 20 β -0.10.""" |
| _force_obs(env, kafka_lag=0.0, rolling_p99=0.0, risk_score=10.0, db_connection_pool=10.0) |
| env._rolling_lag = 0.0 |
| _, _, _, info = env.step(make_action(db_retry_policy=1)) |
| assert info["reward_breakdown"]["db_penalty"] == -0.10 |
|
|
|
|
| |
| |
| |
|
|
| def test_exponential_backoff_high_pool_bonus(env: UnifiedFintechEnv) -> None: |
| """ExponentialBackoff (db_retry_policy=1) when db_pool > 80 β +0.03 db_penalty.""" |
| _force_obs(env, kafka_lag=0.0, rolling_p99=0.0, risk_score=10.0, db_connection_pool=85.0) |
| env._rolling_lag = 0.0 |
| _, _, _, info = env.step(make_action(db_retry_policy=1)) |
| assert info["reward_breakdown"]["db_penalty"] == 0.03 |
|
|
|
|
| |
| |
| |
|
|
| def test_app_priority_upi_small_merchant_bonus(env: UnifiedFintechEnv) -> None: |
| """UPI priority (app_priority=0) + Small merchant (tier=0) β +0.02 bonus.""" |
| _force_obs(env, kafka_lag=0.0, rolling_p99=0.0, risk_score=10.0, merchant_tier=0.0) |
| env._rolling_lag = 0.0 |
| _, _, _, info = env.step(make_action(app_priority=0)) |
| assert info["reward_breakdown"]["bonus"] >= 0.02 |
|
|
|
|
| |
| |
| |
|
|
| def test_app_priority_credit_enterprise_bonus() -> None: |
| """Credit priority (app_priority=1) + Enterprise merchant (tier=1) β +0.02 bonus.""" |
| e = UnifiedFintechEnv() |
| e.reset(options={"task": "hard"}) |
| obs = e._current_obs |
| |
| _force_obs(e, kafka_lag=0.0, rolling_p99=0.0, risk_score=10.0, merchant_tier=1.0) |
| e._rolling_lag = 0.0 |
| _, _, _, info = e.step(make_action(app_priority=1)) |
| assert info["reward_breakdown"]["bonus"] >= 0.02 |
|
|
|
|
| |
| |
| |
|
|
| def test_info_dict_contains_all_required_keys(env: UnifiedFintechEnv) -> None: |
| """Every step() must return an info dict with the full CLAUDE.md key set.""" |
| _, _, _, info = env.step(make_action()) |
| required_keys = { |
| "phase", "curriculum_level", "step_in_episode", "raw_obs", |
| "reward_breakdown", "termination_reason", "adversary_threat_level_raw", |
| "blind_spot_triggered", "consecutive_deferred_async", |
| |
| "step", "task", "event_type", "obs_risk_score", "obs_kafka_lag", |
| "obs_rolling_p99", "action_risk_decision", "action_infra_routing", |
| "action_crypto_verify", "reward_raw", "reward_final", |
| "circuit_breaker_tripped", "crashed", "done", |
| "internal_rolling_lag", "internal_rolling_latency", |
| } |
| missing = required_keys - info.keys() |
| assert not missing, f"Missing info keys: {missing}" |
|
|
|
|
| |
| |
| |
|
|
| def test_reward_breakdown_final_matches_return(env: UnifiedFintechEnv) -> None: |
| """reward_breakdown['final'] must equal the typed_reward.value returned.""" |
| _, typed_reward, _, info = env.step(make_action()) |
| assert info["reward_breakdown"]["final"] == typed_reward.value |
|
|
|
|
| |
| |
| |
|
|
| def test_blind_spot_triggered_flag(env: UnifiedFintechEnv) -> None: |
| """blind_spot_triggered must be True for Reject+SkipVerify on risk > 80.""" |
| _force_obs(env, risk_score=88.0) |
| _, _, _, info = env.step(make_action(risk_decision=1, crypto_verify=1)) |
| assert info["blind_spot_triggered"] is True |
|
|
|
|
| |
| |
| |
|
|
| def test_termination_reason_crash(env: UnifiedFintechEnv) -> None: |
| """kafka_lag > 4000 for 2 consecutive steps β info['termination_reason'] = 'crash'. |
| Fix 11.1: single spike gives streak=1 (grace), second spike fires the crash. |
| """ |
| _force_obs(env, kafka_lag=5000.0) |
| env._rolling_lag = 0.0 |
| |
| _, _, _, info1 = env.step(make_action()) |
| assert info1["termination_reason"] is None, "Grace step should not crash" |
| assert info1["crash_grace_active"] is True |
| |
| _force_obs(env, kafka_lag=5000.0) |
| _, _, done, info = env.step(make_action()) |
| assert done is True |
| assert info["termination_reason"] == "crash" |
|
|
|
|
| |
| |
| |
|
|
| def test_termination_reason_fraud(env: UnifiedFintechEnv) -> None: |
| """Fraud catastrophe must set info['termination_reason'] = 'fraud'.""" |
| _force_obs(env, risk_score=92.0) |
| _, _, _, info = env.step(make_action(risk_decision=0, crypto_verify=1)) |
| assert info["termination_reason"] == "fraud" |
|
|
|
|
| |
| |
| |
|
|
| def test_termination_reason_none_on_normal_step(env: UnifiedFintechEnv) -> None: |
| """A normal step must leave info['termination_reason'] as None.""" |
| _force_obs(env, kafka_lag=100.0, rolling_p99=50.0, risk_score=10.0) |
| env._rolling_lag = 0.0 |
| _, _, _, info = env.step(make_action(risk_decision=1, crypto_verify=0)) |
| assert info["termination_reason"] is None |
|
|
|
|
| |
| |
| |
|
|
| def test_reject_spam_penalty_fires_after_5_consecutive_rejects(env: UnifiedFintechEnv) -> None: |
| """6+ consecutive Reject decisions must trigger -0.15 infra_penalty (reject_spam_active).""" |
| _force_obs(env, kafka_lag=50.0, rolling_p99=50.0, risk_score=30.0) |
| env._rolling_lag = 0.0 |
| env._rolling_p99 = 50.0 |
|
|
| |
| for _ in range(5): |
| env.step(make_action(risk_decision=1)) |
|
|
| _, _, _, info = env.step(make_action(risk_decision=1)) |
| assert info["reject_spam_active"] is True |
| assert info["reward_breakdown"]["infra_penalty"] <= -0.15, ( |
| "reject_spam_active must debit -0.15 under infra_penalty" |
| ) |
|
|
|
|
| def test_reject_spam_resets_on_non_reject(env: UnifiedFintechEnv) -> None: |
| """A single non-Reject decision must clear the consecutive_rejects counter.""" |
| _force_obs(env, kafka_lag=50.0, rolling_p99=50.0, risk_score=30.0) |
| env._rolling_lag = 0.0 |
|
|
| for _ in range(6): |
| env.step(make_action(risk_decision=1)) |
|
|
| |
| _force_obs(env, kafka_lag=50.0, rolling_p99=50.0, risk_score=30.0) |
| _, _, _, info = env.step(make_action(risk_decision=0)) |
| assert info["reject_spam_active"] is False |
| assert info["consecutive_rejects"] == 0 |
|
|
|
|
| |
| |
| |
|
|
| def test_throughput_bonus_fires_on_approve_low_risk_healthy_lag(env: UnifiedFintechEnv) -> None: |
| """Approve + risk_score < 40 + kafka_lag < 1200 must award +0.03 throughput bonus.""" |
| _force_obs(env, kafka_lag=100.0, rolling_p99=50.0, risk_score=20.0) |
| env._rolling_lag = 0.0 |
| env._rolling_p99 = 50.0 |
| env._kafka_lag = 100.0 |
|
|
| _, _, _, info = env.step(make_action(risk_decision=0)) |
| assert info["throughput_bonus_active"] is True |
| assert info["reward_breakdown"]["bonus"] >= 0.03, ( |
| f"Expected throughput bonus β₯ 0.03, got {info['reward_breakdown']['bonus']}" |
| ) |
|
|
|
|
| def test_throughput_bonus_does_not_fire_on_high_risk(env: UnifiedFintechEnv) -> None: |
| """Approve + risk_score β₯ 40 must NOT trigger throughput bonus.""" |
| _force_obs(env, kafka_lag=100.0, rolling_p99=50.0, risk_score=50.0) |
| env._rolling_lag = 0.0 |
| env._kafka_lag = 100.0 |
|
|
| _, _, _, info = env.step(make_action(risk_decision=0)) |
| assert info["throughput_bonus_active"] is False |
|
|
|
|
| def test_throughput_bonus_does_not_fire_on_high_lag(env: UnifiedFintechEnv) -> None: |
| """Approve + risk_score < 40 but kafka_lag β₯ 1200 must NOT trigger throughput bonus.""" |
| _force_obs(env, kafka_lag=1500.0, rolling_p99=50.0, risk_score=20.0) |
| env._rolling_lag = 0.0 |
| env._kafka_lag = 1500.0 |
|
|
| _, _, _, info = env.step(make_action(risk_decision=0)) |
| assert info["throughput_bonus_active"] is False |
|
|
|
|
| |
| |
| |
|
|
| def test_circuit_breaker_enters_half_open_after_cb_half_open_after_steps(env: UnifiedFintechEnv) -> None: |
| """After CB_HALF_OPEN_AFTER consecutive CircuitBreaker steps, CB enters half-open mode. |
| Half-open: reduced penalty (-0.10) and probe checks lag against recovery threshold. |
| """ |
| from unified_gateway import CB_HALF_OPEN_AFTER, CB_HALF_OPEN_PENALTY, CB_LAG_RECOVERY_THRESHOLD |
|
|
| |
| _force_obs(env, kafka_lag=500.0, rolling_p99=50.0, risk_score=10.0) |
| env._rolling_lag = 0.0 |
| env._kafka_lag = 500.0 |
|
|
| |
| for _ in range(CB_HALF_OPEN_AFTER): |
| _force_obs(env, kafka_lag=500.0, rolling_p99=50.0, risk_score=10.0) |
| env._kafka_lag = 500.0 |
| env.step(make_action(infra_routing=2, risk_decision=1)) |
|
|
| |
| _force_obs(env, kafka_lag=500.0, rolling_p99=50.0, risk_score=10.0) |
| env._kafka_lag = 500.0 |
| _, typed_reward, _, info = env.step(make_action(infra_routing=2, risk_decision=1)) |
|
|
| |
| cb_steps = info.get("circuit_breaker_steps", env._cb_consecutive_steps) |
| |
| assert env._cb_consecutive_steps == 0, ( |
| f"Breaker should have closed (cb_steps=0), got {env._cb_consecutive_steps}" |
| ) |
|
|