azazel_processor: commit 2 — vidui (property 3, enumerative commit)

Pass 4 / Finding F, commit 2 of 10 from BUILD_AZAZEL.md.

Vayikra 16:21 — v'hitvadah alav et kol avonot b'nei Yisrael v'et kol
pish'eihem l'chol chatotam. The high priest confesses ALL iniquities,
ALL transgressions, for ALL sins. The verb is hitpa'el (reflexive) —
the confession is self-addressed before it is addressed to the
system. The kol-three-times structure is mandatory.

ENGINEERING READ:

The commit message for a disposal must enumerate what is being
disposed of. Untagged disposal is not disposal; it is loss. Azazel
does not accept uncommitted writes. The enumeration is produced at
the moment of commit, not retrieved from a pre-existing log.

WHY OPERATOR-PRODUCED, NOT AUTO-DERIVED:

The Mishkan high priest speaks the confession aloud. The system did
not generate it from a classification enum. In software: the caller
of confess() MUST supply the descriptions, not have them
auto-generated from a classification → string table. If the table
generated the confession, the operator could dispose state without
ever naming it themselves; the confession would become an
autogenerated label, not a self-addressed enumeration. That is the
failure mode property 3 defends against.

The convenience helpers (confess_empty_vital, confess_halt,
confess_boundary_violation, confess_malformed_input) DO NOT hide
this requirement. Each requires the operator to supply the specific
item; the helper only routes which category it goes into.

THE THREE CATEGORIES:

  avonot   — iniquities. CATEGORICAL errors: halt-trips
             (avodah_zarah, gilui_arayot, shfichut_damim — the
             cardinal sins). Sins of orientation.
  peshaim  — transgressions. BOUNDARY violations: distinction
             collapses (gilui-shaped), L3 schema rejections.
             Sins of boundary-keeping.
  chatot   — sins. TARGET errors: empty-vital, malformed responses.
             The literal meaning of chet — missing the mark.

Most disposals fill exactly one category. The Vidui dataclass
REQUIRES all three fields (the enumeration is comprehensive at the
shape level), but the lists may be empty. What is forbidden is the
ABSENCE of a slot — and the case where ALL THREE slots are empty
(EmptyConfessionError, no untagged disposal).

CREATED:

  src/azazel_processor/vidui.py            ~250 lines
    Vidui                — frozen dataclass: avonot, peshaim, chatot,
                           confessor, confessed_at (all required;
                           categories are tuples for deep immutability;
                           __post_init__ rejects all-empty, empty-string
                           items, non-string items, anonymous confessor)
    EmptyConfessionError — raised on untagged-disposal attempts
    confess(*, avonot, peshaim, chatot, confessor)
                         — the operator-produced builder
    Vidui.to_text()      — kol-three-times rendering for L3 storage
    Vidui.category_summary()
                         — counts only, no items (for the public
                           observability endpoint, commit 8 / property 9)

  Convenience helpers (each requires operator-supplied item):
    confess_empty_vital(korban_summary, *, confessor)
    confess_halt(condition, detail, *, confessor)
    confess_boundary_violation(violation, *, confessor)
    confess_malformed_input(description, *, confessor)

  test/test_vidui.py                       27 tests covering:
    - structural (exactly five fields, no silent additions)
    - all category-fill combinations succeed
    - all-empty / empty-string / whitespace / non-string items raise
    - confessor required (TypeError) + non-empty/non-whitespace
      validated (EmptyConfessionError)
    - frozen dataclass (cannot mutate after construction)
    - categories are tuples (deep immutability — frozen=True does
      not propagate through container types)
    - confessed_at: timezone-aware UTC, set to current time
    - to_text() preserves kol-three-times structure; omits empty
      categories
    - category_summary() returns counts WITHOUT items
    - all four convenience helpers route correctly + still require
      confessor

Public surface updated in __init__.py.

Tests: azazel_processor 48 passed (was 21, +27). 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:
Scott KarpandClaude Opus 4.7 committed 2026-04-27 18:49:34 -04:00
1 parent d06ecd244d
commit a2744c012a
3 files changed
+584 -13

No files matched your search

+283
View File
@@ -0,0 +1,283 @@
"""
Tests for vidui — property 3 (enumerative commit) of
azazel_engineering_reading.md §14.
The vidui must:
- Have all THREE category slots present (avonot/peshaim/chatot),
even when individual slots are empty
- Refuse construction when ALL slots are empty (no untagged
disposal — the failure mode property 3 defends against)
- Be immutable after construction (the moment of confession
is the moment of fixing)
- Carry an attributable confessor (anonymous confession defeats
property 7's authorized-routing requirement)
- Render to readable text preserving the kol-three-times structure
- Expose category counts WITHOUT the items themselves (for the
public observability endpoint, commit 8 — property 9)
"""
from datetime import datetime, timedelta, timezone
import pytest
from azazel_processor.vidui import (
EmptyConfessionError,
Vidui,
confess,
confess_boundary_violation,
confess_empty_vital,
confess_halt,
confess_malformed_input,
)
# ============================================================
# The three-fold structure
# ============================================================
def test_vidui_has_exactly_the_five_expected_fields():
"""Structural test: the dataclass has exactly avonot, peshaim,
chatot, confessor, confessed_at — no silent additions and no
missing fields. A future contributor who adds a sixth field
(or removes one) breaks this and forces an explicit decision."""
fields = {f.name for f in Vidui.__dataclass_fields__.values()}
assert fields == {"avonot", "peshaim", "chatot", "confessor", "confessed_at"}
def test_confess_with_avonot_only_succeeds():
v = confess(avonot=("halt avodah_zarah",), confessor="kohen_gadol")
assert v.avonot == ("halt avodah_zarah",)
assert v.peshaim == ()
assert v.chatot == ()
def test_confess_with_peshaim_only_succeeds():
v = confess(peshaim=("ketiv/qere collapse",), confessor="kohen_gadol")
assert v.peshaim == ("ketiv/qere collapse",)
def test_confess_with_chatot_only_succeeds():
v = confess(chatot=("empty-vital korban",), confessor="kohen_gadol")
assert v.chatot == ("empty-vital korban",)
def test_confess_with_all_three_categories_filled_succeeds():
v = confess(
avonot=("halt 1",),
peshaim=("collapse 1",),
chatot=("missing 1", "missing 2"),
confessor="kohen_gadol",
)
assert v.avonot == ("halt 1",)
assert v.peshaim == ("collapse 1",)
assert v.chatot == ("missing 1", "missing 2")
# ============================================================
# Untagged disposal is forbidden
# ============================================================
def test_confess_with_all_slots_empty_raises():
"""Property 3: no untagged disposal. The act of naming what is
being disposed is load-bearing. confess() with all three slots
empty is the system trying to dispose without naming — refuse."""
with pytest.raises(EmptyConfessionError) as exc:
confess(confessor="kohen_gadol")
assert "without naming" in str(exc.value).lower() or "loss" in str(exc.value).lower()
def test_confess_with_explicit_empty_tuples_in_all_slots_raises():
with pytest.raises(EmptyConfessionError):
confess(avonot=(), peshaim=(), chatot=(), confessor="kohen_gadol")
def test_vidui_construction_with_empty_string_item_raises():
"""An empty string in a category list would degrade the enumeration
to a count — refuse at construction."""
with pytest.raises(EmptyConfessionError):
confess(avonot=("",), confessor="kohen_gadol")
def test_vidui_construction_with_whitespace_only_item_raises():
with pytest.raises(EmptyConfessionError):
confess(chatot=(" ",), confessor="kohen_gadol")
def test_vidui_construction_with_non_string_item_raises():
with pytest.raises(EmptyConfessionError):
confess(avonot=(None,), confessor="kohen_gadol") # type: ignore[arg-type]
# ============================================================
# Confessor attribution
# ============================================================
def test_confessor_required():
"""Anonymous confession defeats property 7 (authorized routing).
confess() must take a confessor (keyword-only) — no default."""
with pytest.raises(TypeError):
# No confessor argument
confess(avonot=("x",)) # type: ignore[call-arg]
def test_confessor_must_be_non_empty_string():
with pytest.raises(EmptyConfessionError):
confess(avonot=("x",), confessor="")
def test_confessor_must_be_non_whitespace():
with pytest.raises(EmptyConfessionError):
confess(avonot=("x",), confessor=" ")
def test_confessor_recorded_on_vidui():
v = confess(avonot=("x",), confessor="aharon_ha_kohen")
assert v.confessor == "aharon_ha_kohen"
# ============================================================
# Immutability
# ============================================================
def test_vidui_is_frozen():
"""The moment of confession is the moment of fixing. No path to
mutating the vidui after construction — that would let an
operator re-write what they confessed."""
v = confess(avonot=("x",), confessor="kohen_gadol")
with pytest.raises((AttributeError, Exception)):
v.confessor = "someone_else" # type: ignore[misc]
def test_vidui_categories_are_tuples_not_lists():
"""Tuples are immutable; lists are not. Even though the dataclass
is frozen, a list-valued field could be mutated in place
(frozen=True doesn't propagate through container types). Tuples
are the right shape for the enumeration."""
v = confess(avonot=("x",), peshaim=("y",), chatot=("z",), confessor="x")
assert isinstance(v.avonot, tuple)
assert isinstance(v.peshaim, tuple)
assert isinstance(v.chatot, tuple)
def test_iterable_input_normalized_to_tuple():
"""confess() should accept any iterable for category lists and
normalize to tuple at construction. A list passed in cannot be
held as a list-valued field — that would re-introduce the
mutability gap."""
v = confess(avonot=["x", "y"], confessor="kohen_gadol")
assert isinstance(v.avonot, tuple)
assert v.avonot == ("x", "y")
# ============================================================
# Timestamp
# ============================================================
def test_confessed_at_set_to_current_utc_time():
before = datetime.now(timezone.utc)
v = confess(avonot=("x",), confessor="kohen_gadol")
after = datetime.now(timezone.utc)
assert before - timedelta(seconds=1) <= v.confessed_at <= after + timedelta(seconds=1)
def test_confessed_at_is_timezone_aware_utc():
v = confess(avonot=("x",), confessor="kohen_gadol")
assert v.confessed_at.tzinfo is not None
assert v.confessed_at.utcoffset() == timedelta(0)
# ============================================================
# Rendering
# ============================================================
def test_to_text_preserves_kol_structure_for_all_three():
v = confess(
avonot=("a1", "a2"),
peshaim=("p1",),
chatot=("c1",),
confessor="kohen",
)
text = v.to_text()
assert "kol avonot" in text
assert "kol pish'eihem" in text
assert "l'kol chatotam" in text
assert "a1" in text and "a2" in text
assert "p1" in text
assert "c1" in text
def test_to_text_omits_empty_categories():
v = confess(avonot=("only-avonot",), confessor="kohen")
text = v.to_text()
assert "kol avonot" in text
assert "kol pish'eihem" not in text
assert "l'kol chatotam" not in text
def test_category_summary_returns_counts_without_items():
"""The shape exposed by the public observability endpoint —
counts only, no item content. Property 9 at the public boundary."""
v = confess(
avonot=("a1", "a2"),
peshaim=("p1",),
chatot=(),
confessor="kohen",
)
summary = v.category_summary()
assert summary == {"avonot": 2, "peshaim": 1, "chatot": 0}
# Items themselves are NOT in the summary — only counts.
assert "a1" not in str(summary)
assert "p1" not in str(summary)
# ============================================================
# Convenience helpers
# ============================================================
def test_confess_empty_vital_routes_to_chatot():
"""The literal meaning of chet — missing the mark — is exactly
the empty-vital case: the system fired and nothing landed."""
v = confess_empty_vital("Claude response on bereishit 1:1", confessor="x")
assert v.chatot
assert v.avonot == ()
assert v.peshaim == ()
assert "empty-vital" in v.chatot[0]
def test_confess_halt_routes_to_avonot():
"""Halt-trips are categorical orientation failures —
yehareg-v'al-ya'avor cardinal sins."""
v = confess_halt("avodah_zarah", "claude_model treated as authority", confessor="x")
assert v.avonot
assert "avodah_zarah" in v.avonot[0]
assert "claude_model" in v.avonot[0]
def test_confess_boundary_violation_routes_to_peshaim():
"""Boundary-keeping failures (gilui-shaped) — distinction
collapses."""
v = confess_boundary_violation("ketiv/qere collapsed", confessor="x")
assert v.peshaim
assert "boundary collapse" in v.peshaim[0]
def test_confess_malformed_input_routes_to_chatot():
"""Different from empty-vital — empty-vital is content with no
Torah; malformed is content with no SHAPE."""
v = confess_malformed_input("truncated JSON, missing closing brace", confessor="x")
assert v.chatot
assert "malformed" in v.chatot[0]
def test_helpers_still_require_confessor():
"""The convenience helpers do not weaken property-3's
operator-attribution requirement."""
with pytest.raises(TypeError):
confess_empty_vital("x") # type: ignore[call-arg]