azazel_processor: commit 4 — goral (property 2, blind routing)
Pass 4 / Finding F, commit 4 of 10 from BUILD_AZAZEL.md.
Vayikra 16:8 — וְנָתַן אַהֲרֹן עַל־שְׁנֵי הַשְּׂעִירִם גֹּרָלוֹת,
גּוֹרָל אֶחָד לַה׳ וְגוֹרָל אֶחָד לַעֲזָאזֵל.
And Aharon shall place lots on the two goats — one lot for HaShem,
one lot for Azazel.
Yoma 62a: a kalpi (shuffled vessel), drawn with both hands
simultaneously. The high priest cannot see the lots in advance.
He cannot shape the outcome.
THREAT MODEL:
An operator who, given the choice, would route preferentially —
assigning the "better" goat to processing and the "worse" to
disposal. The specification makes that preference structurally
impossible.
DESIGN (BUILD_AZAZEL.md §6, Q4):
cast_lot(input_id) is DETERMINISTIC per (input_id, today). The
seed is SHA-256 over (L1 integrity hash, today's UTC date,
input_id). The first bit of the digest is the outcome.
Properties guaranteed:
- Same (input_id, today) → SAME outcome (no shopping by re-roll)
- Different input_ids same day → outcomes can differ (~50/50)
- Same input_id across days → outcomes can differ (~50/50)
- Operators cannot pre-compute without invoking (SHA-256 over
L1 hash + date is not mentally computable)
- Seed inherits L1 tamper-resistance: rewriting the daily seed
requires rewriting L1, which has its own halt rules
The L1 hash mirrored from vav_pipeline/lib/vav_pipeline/l1_reader.ex
(@l1_expected_hash). Both must move together if L1 is rebuilt.
CREATED:
src/azazel_processor/goral.py ~155 lines
GoralOutcome enum LA_SHEM | LA_AZAZEL
(both legitimate; the goral
enforces blindness, not a
"good vs. bad" distinction)
cast_lot(input_id, *, today=None) deterministic per (id, day)
outcome
cast_lot_outcome_id(input_id, full SHA-256 hex digest;
*, today=None) used by L3 records (commit 7)
so audit can recompute
GoralError on malformed input
test/test_goral.py 17 tests covering:
- outcome shape (one of two; enum members distinct)
- same input same day → same outcome (anti-shopping; verified
across 50 re-invocations + 1000-iteration audit-shopping test)
- outcome_id is reproducible 64-char hex
- 100 different input_ids → 100 distinct outcome_ids
- same input across 30 days → 30 distinct outcome_ids
- distribution across 10,000 input_ids on one day → ~50/50
(±5% tolerance, well within SHA-256 binomial expectations)
- distribution across 365 days for one input → ~50/50 (±10%)
- input validation (empty, whitespace, None, non-string raise)
- default today = UTC date
- audit can recompute outcome_id from (input_id, today) — the
audit-trail half of property 2
NOTE: when the orchestrator (commit 5) decides WHEN to invoke the
goral. For inputs that are cleanly processable or cleanly
un-processable, the goral is NOT invoked — they go to their channel
deterministically. The goral is the tie-breaker for genuinely
borderline inputs (Q1 in BUILD_AZAZEL.md §6).
Tests: azazel_processor 88 passed (was 71, +17). korban_processor
unchanged at 203 passed, 1 skipped. No regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
85155b23fb
commit
e6fee82dd7
3 files changed
+407
-4
No files matched your search
@@ -6,11 +6,12 @@ uv'd'mim, Yoma 62a). One goat to HaShem (processing); one goat to
|
|||||||
Azazel (disposal). The two channels are provisioned at parity, from
|
Azazel (disposal). The two channels are provisioned at parity, from
|
||||||
the same pool, with assignment blind to the operator.
|
the same pool, with assignment blind to the operator.
|
||||||
|
|
||||||
Build state: commit 3 — shaliach + tevilah added. See BUILD_AZAZEL.md
|
Build state: commit 4 — goral added. See BUILD_AZAZEL.md for the
|
||||||
for the full 10-commit staging plan and the 12-property → code
|
full 10-commit staging plan and the 12-property → code mapping from
|
||||||
mapping from azazel_engineering_reading.md §14.
|
azazel_engineering_reading.md §14.
|
||||||
|
|
||||||
Properties defended so far:
|
Properties defended so far:
|
||||||
|
- Property 2 (blind routing / goral) — cast_lot
|
||||||
- Property 3 (enumerative commit) — Vidui + confess()
|
- Property 3 (enumerative commit) — Vidui + confess()
|
||||||
- Property 4 (role-separated operator) — IshItti + tevilah
|
- Property 4 (role-separated operator) — IshItti + tevilah
|
||||||
- Property 11 (classification discipline) — AzazelStage*
|
- Property 11 (classification discipline) — AzazelStage*
|
||||||
@@ -24,14 +25,21 @@ Public surface:
|
|||||||
confess_boundary_violation,
|
confess_boundary_violation,
|
||||||
confess_malformed_input — categorization helpers
|
confess_malformed_input — categorization helpers
|
||||||
IshItti, ShaliachError, assert_shaliach_active — disposal handler
|
IshItti, ShaliachError, assert_shaliach_active — disposal handler
|
||||||
|
GoralOutcome, cast_lot, cast_lot_outcome_id,
|
||||||
|
GoralError — blind routing
|
||||||
|
|
||||||
Pending in subsequent commits:
|
Pending in subsequent commits:
|
||||||
goral (commit 4 — blind routing)
|
|
||||||
azazel orchestrator (commit 5 — full state machine)
|
azazel orchestrator (commit 5 — full state machine)
|
||||||
protocol envelope (commit 6 — protocol-restricted access)
|
protocol envelope (commit 6 — protocol-restricted access)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from azazel_processor.eretz_gezerah import ERETZ_GEZERAH, is_eretz_gezerah
|
from azazel_processor.eretz_gezerah import ERETZ_GEZERAH, is_eretz_gezerah
|
||||||
|
from azazel_processor.goral import (
|
||||||
|
GoralError,
|
||||||
|
GoralOutcome,
|
||||||
|
cast_lot,
|
||||||
|
cast_lot_outcome_id,
|
||||||
|
)
|
||||||
from azazel_processor.shaliach import (
|
from azazel_processor.shaliach import (
|
||||||
IshItti,
|
IshItti,
|
||||||
ShaliachError,
|
ShaliachError,
|
||||||
@@ -64,4 +72,8 @@ __all__ = [
|
|||||||
"IshItti",
|
"IshItti",
|
||||||
"ShaliachError",
|
"ShaliachError",
|
||||||
"assert_shaliach_active",
|
"assert_shaliach_active",
|
||||||
|
"GoralOutcome",
|
||||||
|
"cast_lot",
|
||||||
|
"cast_lot_outcome_id",
|
||||||
|
"GoralError",
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""
|
||||||
|
Goral — the blind routing decision (property 2 of
|
||||||
|
azazel_engineering_reading.md §14).
|
||||||
|
|
||||||
|
Vayikra 16:8 — וְנָתַן אַהֲרֹן עַל־שְׁנֵי הַשְּׂעִירִם גֹּרָלוֹת,
|
||||||
|
גּוֹרָל אֶחָד לַה׳ וְגוֹרָל אֶחָד לַעֲזָאזֵל.
|
||||||
|
And Aharon shall place lots on the two goats — one lot for HaShem,
|
||||||
|
one lot for Azazel.
|
||||||
|
|
||||||
|
Yoma 62a specifies a kalpi (shuffled vessel) from which the lots are
|
||||||
|
drawn. The high priest draws with both hands simultaneously. The lot
|
||||||
|
drawn by the right hand determines the goat on the right. The priest
|
||||||
|
cannot see the lots in advance. He cannot shape the outcome.
|
||||||
|
|
||||||
|
THREAT MODEL:
|
||||||
|
|
||||||
|
An operator who, given the choice, would route preferentially —
|
||||||
|
assigning the "better" goat to the processing path and the "worse"
|
||||||
|
one to disposal. The specification makes that preference structurally
|
||||||
|
impossible. The operator participates in the selection but does not
|
||||||
|
determine it.
|
||||||
|
|
||||||
|
DESIGN DECISION (BUILD_AZAZEL.md §6, Q4):
|
||||||
|
|
||||||
|
`cast_lot(input_id)` is DETERMINISTIC per (input_id, today). The seed
|
||||||
|
combines:
|
||||||
|
|
||||||
|
- The L1 integrity hash (the system's cryptographic anchor;
|
||||||
|
operators cannot rewrite without tampering with L1, which has
|
||||||
|
its own halt rules)
|
||||||
|
- Today's UTC date (operators cannot rewrite the calendar)
|
||||||
|
- The caller-supplied input_id (different inputs get different
|
||||||
|
outcomes; same input gets the same outcome)
|
||||||
|
|
||||||
|
This makes "outcome shopping" structurally impossible: re-invoking
|
||||||
|
cast_lot with the same input_id on the same day returns the same
|
||||||
|
result. An operator who dislikes the outcome cannot re-roll. An
|
||||||
|
operator who wants to compute the outcome in their head before
|
||||||
|
deciding to invoke cannot — SHA-256 over the (L1_hash, date,
|
||||||
|
input_id) tuple is not mentally computable.
|
||||||
|
|
||||||
|
The same input_id ACROSS DAYS produces different outcomes (because
|
||||||
|
the daily date component changes). This means an operator who tries
|
||||||
|
to "wait for tomorrow" loses the per-input determinism (the outcome
|
||||||
|
genuinely changes day-to-day). They cannot pre-compute, cannot re-roll,
|
||||||
|
cannot shop.
|
||||||
|
|
||||||
|
PARITY WITH the MISHKAN GORAL:
|
||||||
|
|
||||||
|
The Mishkan goral has TWO outcomes — la'Shem (processing) or
|
||||||
|
la'Azazel (disposal). Both routes are legitimate; the goral chooses
|
||||||
|
which goat goes which way. In Shorashim, the goral applies to
|
||||||
|
borderline cases (Q1 in BUILD_AZAZEL.md): inputs that COULD be
|
||||||
|
processed with low confidence OR safely disposed. The threshold
|
||||||
|
above which inputs are routed deterministically (no goral) is a
|
||||||
|
configuration choice; below it, the goral fires.
|
||||||
|
|
||||||
|
For inputs that are cleanly processable (zerikah returns vital with
|
||||||
|
high confidence), the goral is NOT invoked — they go to korban
|
||||||
|
deterministically. For inputs that are cleanly un-processable
|
||||||
|
(empty-vital, halt-tripping, malformed), the goral is also NOT
|
||||||
|
invoked — they go to azazel deterministically. The goral is the
|
||||||
|
tie-breaker for the genuinely-uncertain middle.
|
||||||
|
|
||||||
|
This module provides cast_lot(); commit 5 (the orchestrator) decides
|
||||||
|
WHEN to invoke it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
# The L1 integrity hash — Shorashim's cryptographic anchor. The
|
||||||
|
# value is mirrored from vav_pipeline/lib/vav_pipeline/l1_reader.ex
|
||||||
|
# (@l1_expected_hash). Both must move together: if L1 is rebuilt and
|
||||||
|
# the hash changes, this constant updates too. The shared value is
|
||||||
|
# how the goral's seed inherits L1's tamper-resistance.
|
||||||
|
_L1_INTEGRITY_HASH = (
|
||||||
|
"99bbd07a5406e8acfc9d77ace277fe3e3ccee379a7fc4e48c61cc7c4ab7ea377"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GoralError(ValueError):
|
||||||
|
"""Raised when cast_lot is called with malformed input."""
|
||||||
|
|
||||||
|
|
||||||
|
class GoralOutcome(Enum):
|
||||||
|
"""The two-goat assignment.
|
||||||
|
|
||||||
|
LA_SHEM — process via the Korban path
|
||||||
|
LA_AZAZEL — dispose via the Azazel path
|
||||||
|
|
||||||
|
Both are LEGITIMATE outcomes. The goral is not "good vs. bad";
|
||||||
|
it is the architectural enforcement that the operator does not
|
||||||
|
bias which goat goes which way.
|
||||||
|
"""
|
||||||
|
|
||||||
|
LA_SHEM = "la_shem"
|
||||||
|
LA_AZAZEL = "la_azazel"
|
||||||
|
|
||||||
|
|
||||||
|
def cast_lot(
|
||||||
|
input_id: str,
|
||||||
|
*,
|
||||||
|
today: date | None = None,
|
||||||
|
) -> GoralOutcome:
|
||||||
|
"""Cast the goral for a given input.
|
||||||
|
|
||||||
|
Returns GoralOutcome.LA_SHEM or GoralOutcome.LA_AZAZEL, determined
|
||||||
|
by SHA-256 over (L1 integrity hash, today's UTC date, input_id).
|
||||||
|
The first bit of the digest is the outcome.
|
||||||
|
|
||||||
|
Properties guaranteed:
|
||||||
|
- Deterministic per (input_id, today): no outcome shopping
|
||||||
|
- Different input_ids same day → outcomes can differ (~50/50)
|
||||||
|
- Same input_id across days → outcomes can differ (~50/50)
|
||||||
|
- Operators cannot pre-compute without invoking
|
||||||
|
- The seed depends on L1 integrity, which operators cannot
|
||||||
|
rewrite without tampering with the L1 filesystem
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_id: A stable identifier for the input being routed.
|
||||||
|
Required, non-empty. Different input_ids must
|
||||||
|
produce reproducibly different seeds; the caller
|
||||||
|
should use a content-addressable id (e.g. a hash
|
||||||
|
of the input payload), NOT a sequential number,
|
||||||
|
to prevent operators from picking input_ids that
|
||||||
|
bias toward a desired outcome.
|
||||||
|
today: Optional UTC date for testing. In production, defaults
|
||||||
|
to datetime.now(timezone.utc).date().
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
GoralError: if input_id is not a non-empty string.
|
||||||
|
"""
|
||||||
|
if not isinstance(input_id, str) or not input_id.strip():
|
||||||
|
raise GoralError(
|
||||||
|
"cast_lot requires a non-empty input_id. The Mishkan "
|
||||||
|
"goral is cast over a goat — there is always something "
|
||||||
|
"specific being routed; an unlabeled invocation is not "
|
||||||
|
"a goral."
|
||||||
|
)
|
||||||
|
|
||||||
|
if today is None:
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
|
||||||
|
seed_input = f"{_L1_INTEGRITY_HASH}|{today.isoformat()}|{input_id}"
|
||||||
|
digest = hashlib.sha256(seed_input.encode("utf-8")).digest()
|
||||||
|
|
||||||
|
# Use the low bit of the first byte. SHA-256's output is uniformly
|
||||||
|
# distributed, so the bit is uniformly 0 or 1 across inputs.
|
||||||
|
if digest[0] & 0x01:
|
||||||
|
return GoralOutcome.LA_AZAZEL
|
||||||
|
return GoralOutcome.LA_SHEM
|
||||||
|
|
||||||
|
|
||||||
|
def cast_lot_outcome_id(
|
||||||
|
input_id: str,
|
||||||
|
*,
|
||||||
|
today: date | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Return a stable identifier for the cast lot — the SHA-256
|
||||||
|
hex digest of the seed. Used by L3 records (commit 7) so the
|
||||||
|
operator can audit a posteriori which (input_id, day) pair
|
||||||
|
produced which outcome.
|
||||||
|
|
||||||
|
The full digest, not just the bit, so the audit can be verified
|
||||||
|
by recomputing — proof that the goral was not tampered with.
|
||||||
|
"""
|
||||||
|
if not isinstance(input_id, str) or not input_id.strip():
|
||||||
|
raise GoralError(
|
||||||
|
"cast_lot_outcome_id requires a non-empty input_id."
|
||||||
|
)
|
||||||
|
|
||||||
|
if today is None:
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
|
||||||
|
seed_input = f"{_L1_INTEGRITY_HASH}|{today.isoformat()}|{input_id}"
|
||||||
|
return hashlib.sha256(seed_input.encode("utf-8")).hexdigest()
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
Reference in new issue
Block a user