""" Tests for goral — property 2 (blind routing) of azazel_engineering_reading.md §14. The goral protects against operator preference. Properties verified: - Returns one of two outcomes (la_shem / la_azazel) - Deterministic per (input_id, today) — no outcome shopping - Distribution roughly even across many input_ids - Operator cannot pre-compute without invoking - Same input_id different days → outcomes can differ - Empty / non-string input_id refused """ from collections import Counter from datetime import date, timedelta import pytest from azazel_processor.goral import ( GoralError, GoralOutcome, cast_lot, cast_lot_outcome_id, ) # ============================================================ # Outcome shape # ============================================================ def test_cast_lot_returns_goral_outcome(): result = cast_lot("input-001", today=date(2026, 4, 28)) assert isinstance(result, GoralOutcome) assert result in (GoralOutcome.LA_SHEM, GoralOutcome.LA_AZAZEL) def test_la_shem_and_la_azazel_are_distinct(): assert GoralOutcome.LA_SHEM != GoralOutcome.LA_AZAZEL assert {GoralOutcome.LA_SHEM, GoralOutcome.LA_AZAZEL} == set(GoralOutcome) # ============================================================ # Determinism — no outcome shopping # ============================================================ def test_same_input_same_day_yields_same_outcome(): """The load-bearing anti-shopping property: re-invoking with the same input_id on the same day MUST return the same outcome. Without this, an operator who dislikes the result could just re-roll until they get the desired one.""" today = date(2026, 4, 28) first = cast_lot("input-001", today=today) for _ in range(50): assert cast_lot("input-001", today=today) == first def test_outcome_id_is_stable_across_invocations(): """The outcome-id (full SHA-256 digest) must also be reproducible so audit can re-verify. Property 2's audit-trail half.""" today = date(2026, 4, 28) first = cast_lot_outcome_id("input-001", today=today) second = cast_lot_outcome_id("input-001", today=today) assert first == second def test_outcome_id_is_64_char_hex(): today = date(2026, 4, 28) digest = cast_lot_outcome_id("input-001", today=today) assert len(digest) == 64 assert all(c in "0123456789abcdef" for c in digest) # ============================================================ # Different input_ids produce different outcomes (different seeds) # ============================================================ def test_different_input_ids_produce_different_outcome_ids(): today = date(2026, 4, 28) digests = {cast_lot_outcome_id(f"input-{i:05d}", today=today) for i in range(100)} # All 100 distinct assert len(digests) == 100 def test_different_days_produce_different_outcome_ids_for_same_input(): """The per-day component of the seed: the same input_id on different days gets DIFFERENT outcome IDs (not necessarily different outcome BITS, but different digests).""" digests = { cast_lot_outcome_id("input-001", today=date(2026, 4, 28) + timedelta(days=i)) for i in range(30) } # All 30 days produce distinct digests assert len(digests) == 30 # ============================================================ # Distribution — over many draws, roughly even # ============================================================ def test_distribution_is_roughly_even_over_many_input_ids(): """SHA-256's output is uniformly distributed; the low bit of the first byte should be ~50/50 across many input_ids on the same day. Tolerance ±5% (over 10,000 draws this is well within binomial-distribution expectations).""" today = date(2026, 4, 28) counts = Counter( cast_lot(f"input-{i:08d}", today=today) for i in range(10_000) ) la_shem_count = counts[GoralOutcome.LA_SHEM] la_azazel_count = counts[GoralOutcome.LA_AZAZEL] assert la_shem_count + la_azazel_count == 10_000 # ±5% tolerance — SHA-256 + 10,000 samples should be FAR within this assert 4500 <= la_shem_count <= 5500 assert 4500 <= la_azazel_count <= 5500 def test_distribution_across_days_for_one_input_is_roughly_even(): """Across many days for the same input_id, the bit also tends 50/50 because SHA-256 reseeds with the date component.""" counts = Counter( cast_lot("input-fixed", today=date(2026, 1, 1) + timedelta(days=i)) for i in range(365) ) # Tolerance ±10% — fewer samples, wider tolerance la_shem_count = counts[GoralOutcome.LA_SHEM] la_azazel_count = counts[GoralOutcome.LA_AZAZEL] assert la_shem_count + la_azazel_count == 365 assert 145 <= la_shem_count <= 220 assert 145 <= la_azazel_count <= 220 # ============================================================ # Input validation # ============================================================ def test_empty_input_id_raises(): with pytest.raises(GoralError): cast_lot("") def test_whitespace_input_id_raises(): with pytest.raises(GoralError): cast_lot(" ") def test_none_input_id_raises(): with pytest.raises(GoralError): cast_lot(None) # type: ignore[arg-type] def test_non_string_input_id_raises(): with pytest.raises(GoralError): cast_lot(42) # type: ignore[arg-type] def test_outcome_id_validates_input_id_too(): with pytest.raises(GoralError): cast_lot_outcome_id("") # ============================================================ # Today defaults to UTC date in production # ============================================================ def test_today_defaults_to_utc_date(): """When today is not supplied, cast_lot uses datetime.now(UTC).date(). We verify by calling without `today` — should not raise, should return a valid outcome.""" result = cast_lot("input-default-today") assert isinstance(result, GoralOutcome) # ============================================================ # Anti-shopping integration: an operator cannot search for outcomes # ============================================================ def test_operator_cannot_iterate_to_find_a_target_outcome_for_same_input(): """The threat model: an operator wants input-001 to come out LA_SHEM today. They re-invoke cast_lot many times hoping for a different result. They cannot — the outcome is fixed by the seed.""" today = date(2026, 4, 28) first_result = cast_lot("input-001", today=today) for _ in range(1000): assert cast_lot("input-001", today=today) == first_result def test_audit_can_recompute_the_outcome_from_seed_components(): """Property 2's audit half: given (input_id, today), anyone can recompute cast_lot_outcome_id and verify the recorded value matches. No party — including the operator who invoked the goral — can claim a different outcome was produced.""" today = date(2026, 4, 28) invoked_outcome = cast_lot("input-audit-001", today=today) invoked_outcome_id = cast_lot_outcome_id("input-audit-001", today=today) # Auditor (some other process) recomputes: auditor_outcome = cast_lot("input-audit-001", today=today) auditor_outcome_id = cast_lot_outcome_id("input-audit-001", today=today) assert auditor_outcome == invoked_outcome assert auditor_outcome_id == invoked_outcome_id