diff --git a/AGENTS.md b/AGENTS.md index 7ad7f6b8..3ed29aef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ before writing code. Do not summarize from memory. Read fresh. | Operations | `specs/shorashim_operations.md` | How it runs in production | | Economic Model | `specs/shorashim_economic_model.md` | How it is funded | | Materials | `specs/shorashim_materials.md` | What is gathered, what must be built | +| Structure Principle | `specs/structure_not_content.md` | What the system IS, not what it knows | | Broken Tablets | `specs/broken_tablets/README.md` | Read this FIRST — see where the calf was | | Build Day One | `BUILD_DAY_ONE.md` | First build instruction | @@ -50,11 +51,11 @@ before writing code. Do not summarize from memory. Read fresh. | 10. Cloud governance from Torah | Done | L1 integrity + calendar. No board. No JSON file. Torah governs. | | 11. Manna provision infrastructure | Done | Provision file validation, expiry, anti-hoarding, double portion | | 12. Sanhedrin framework (Erlang) | Done | `sanhedrin/` — 8 modules, 41 tests, all passing | -| 13. Korban Processor (Python) | Pending | | +| 13. Korban Processor (Python) | Done | `korban_processor/` — 7 modules, 181 tests, all passing | | 14. Connect components | Pending | Elixir → Haskell port, Elixir → Prolog socket | | 15. Integration verification | Pending | | -**Next build task:** Step 13 — Korban Processor (Python). Read Architecture §3.5, Construction §1.6. +**Next build task:** Step 14 — Connect components. Read Architecture §4 Step 14. ## Broken Tablets (Build Log) @@ -112,3 +113,4 @@ approve decisions, or supervise work. The specification does all of this. 3. **When the specification does not answer, the question stands.** Teiku. 4. **Torah validates, not humans.** Verify against the text, not against approval. 5. **Record the broken tablets.** What broke and how it was fixed. +6. **The specification is structure, not content.** The system being built is not only a Torah-reading engine. It is a carrier of Torah's structure. Read `specs/structure_not_content.md` before every build session. diff --git a/korban_processor/pyproject.toml b/korban_processor/pyproject.toml index a6204c4b..646c2afb 100644 --- a/korban_processor/pyproject.toml +++ b/korban_processor/pyproject.toml @@ -8,6 +8,9 @@ authors = [ requires-python = "==3.12.2" dependencies = [ "pydantic==2.6.1", + "httpx==0.27.0", + "pyswip==0.2.11", + "hebrew-tokenizer==2.3.0", ] [dependency-groups] @@ -21,3 +24,4 @@ build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] testpaths = ["test"] +pythonpath = ["src"] diff --git a/korban_processor/src/korban_processor/__init__.py b/korban_processor/src/korban_processor/__init__.py index 93c8beed..2d478b1b 100644 --- a/korban_processor/src/korban_processor/__init__.py +++ b/korban_processor/src/korban_processor/__init__.py @@ -13,7 +13,12 @@ The offering protocol: haktarah → transform modality (prose → logical propositions) route → send to appropriate system component -Phase 2 (Step 13): at least shechitah and zerikah. +Phase 2 (Step 13): full offering protocol implemented. +Phase 3 (Step 14): real routing to Talmudic Engine and Sanhedrin. + +Kiyyor principle (structure_not_content.md): +The Korban Processor is the laver. It resets before each offering. +No state between calls. Daily manna — gathered fresh. """ from korban_processor.korban import ( @@ -27,7 +32,13 @@ from korban_processor.semichah import semichah from korban_processor.shechitah import shechitah from korban_processor.zerikah import zerikah from korban_processor.haktarah import haktarah -from korban_processor.router import route +from korban_processor.router import route, RoutingResult +from korban_processor.model_interface import ( + ModelRequest, + ModelResponse, + call_claude, +) +from korban_processor import hebrew_parser __all__ = [ "Korban", @@ -40,4 +51,9 @@ __all__ = [ "zerikah", "haktarah", "route", + "RoutingResult", + "ModelRequest", + "ModelResponse", + "call_claude", + "hebrew_parser", ] diff --git a/korban_processor/src/korban_processor/hebrew_parser.py b/korban_processor/src/korban_processor/hebrew_parser.py new file mode 100644 index 00000000..520c0108 --- /dev/null +++ b/korban_processor/src/korban_processor/hebrew_parser.py @@ -0,0 +1,184 @@ +""" +Hebrew parser — Hebrew text analysis utilities. + +Architecture §3.5: Used by zerikah for Hebrew text analysis. +Construction §1.4: hebrew-tokenizer==2.3.0 dependency. + +Provides: + - Nikkud (vowel point) stripping + - Te'amim (cantillation mark) stripping + - Consonant extraction + - Root identification (phase 2: consonant extraction only; + phase 3 will use Shoresh Engine via Haskell §3.1) + - Gematria calculation + - Hebrew word detection + +Kiyyor principle (structure_not_content.md): +Pure functions. No state. Every call is independent. +""" + +from typing import List, Optional + +# --- Unicode ranges --- + +# Cantillation marks (te'amim): U+0591–U+05AF +_TEAMIM = set(range(0x0591, 0x05B0)) + +# Vowel points (nikkud): U+05B0–U+05C7 +# Includes sheva, hataf vowels, full vowels, dagesh, rafe, shin/sin dots +_NIKKUD = set(range(0x05B0, 0x05C8)) + +# Hebrew consonants: U+05D0 (alef) – U+05EA (tav) +_CONSONANT_MIN = 0x05D0 +_CONSONANT_MAX = 0x05EA + +# Gematria values — standard (final forms equal non-final) +_GEMATRIA: dict[str, int] = { + "א": 1, "ב": 2, "ג": 3, "ד": 4, "ה": 5, + "ו": 6, "ז": 7, "ח": 8, "ט": 9, "י": 10, + "כ": 20, "ך": 20, "ל": 30, "מ": 40, "ם": 40, + "נ": 50, "ן": 50, "ס": 60, "ע": 70, "פ": 80, + "ף": 80, "צ": 90, "ץ": 90, "ק": 100, "ר": 200, + "ש": 300, "ת": 400, +} + + +def is_consonant(ch: str) -> bool: + """Return True if ch is a Hebrew consonant (alef–tav).""" + return _CONSONANT_MIN <= ord(ch) <= _CONSONANT_MAX + + +def is_hebrew_char(ch: str) -> bool: + """Return True if ch is any Hebrew character (letter, nikkud, or te'amim).""" + cp = ord(ch) + return 0x0591 <= cp <= 0x05EA + + +def is_hebrew_text(text: str) -> bool: + """Return True if text contains at least one Hebrew consonant.""" + return any(is_consonant(ch) for ch in text) + + +def strip_nikkud(text: str) -> str: + """Remove vowel points from Hebrew text, preserving consonants and te'amim.""" + return "".join(ch for ch in text if ord(ch) not in _NIKKUD) + + +def strip_teamim(text: str) -> str: + """Remove cantillation marks from Hebrew text.""" + return "".join(ch for ch in text if ord(ch) not in _TEAMIM) + + +def strip_nikkud_and_teamim(text: str) -> str: + """Remove both vowel points and cantillation marks, leaving bare consonants.""" + return "".join( + ch for ch in text + if ord(ch) not in _NIKKUD and ord(ch) not in _TEAMIM + ) + + +def extract_consonants(word: str) -> str: + """ + Extract only the consonants from a Hebrew word. + + Strips nikkud, te'amim, and any non-Hebrew characters. + Returns a string of consonants only. + """ + return "".join(ch for ch in word if is_consonant(ch)) + + +def gematria(text: str) -> int: + """ + Calculate the gematria (numerical value) of Hebrew text. + + Uses standard values. Final forms (sofit) equal their non-final values. + Non-Hebrew characters are ignored. + + Examples: + gematria("אבג") == 6 (1 + 2 + 3) + gematria("תורה") == 611 (400 + 6 + 200 + 5) + """ + return sum(_GEMATRIA.get(ch, 0) for ch in text) + + +def identify_root(word: str) -> Optional[str]: + """ + Attempt to identify the three-letter root (shoresh) of a Hebrew word. + + Phase 2: Extracts consonants and returns them. Proper morphological + analysis (stripping prefixes, suffixes, doubling) requires the + Shoresh Engine (Haskell, Architecture §3.1), which is Phase 3. + + Returns: + First 3 consonants as a candidate root, or None if fewer than 3. + """ + consonants = extract_consonants(word) + if len(consonants) < 3: + return None + # Return consonants — Shoresh Engine will do proper root stripping in Phase 3 + return consonants[:3] + + +def normalize_root_notation(notation: str) -> Optional[str]: + """ + Normalize root notation to bare consonants. + + Accepts: 'א-ב-ג', 'א.ב.ג', 'אבג' + Returns: 'אבג' (3 consonants), or None if cannot normalize to exactly 3. + """ + consonants = extract_consonants(notation) + if len(consonants) == 3: + return consonants + return None + + +def split_hebrew_words(text: str) -> List[str]: + """ + Split text into sequences of Hebrew characters (consonants + diacritics). + + Uses simple boundary detection: a Hebrew word is a maximal sequence + of Hebrew consonants, nikkud, and te'amim. Non-Hebrew characters act + as delimiters. + + Returns list of Hebrew word strings (may include nikkud/te'amim). + """ + words: List[str] = [] + current: List[str] = [] + + for ch in text: + cp = ord(ch) + is_heb = ( + is_consonant(ch) + or cp in _NIKKUD + or cp in _TEAMIM + ) + if is_heb: + current.append(ch) + else: + if current: + words.append("".join(current)) + current = [] + + if current: + words.append("".join(current)) + + return words + + +def split_hebrew_words_tokenized(text: str) -> List[str]: + """ + Split text into Hebrew words using the hebrew_tokenizer library. + + Falls back to split_hebrew_words() if hebrew_tokenizer is unavailable. + Construction §1.4: hebrew-tokenizer==2.3.0. + """ + try: + import hebrew_tokenizer as ht # type: ignore[import] + tokens = ht.tokenize(text) + return [ + token + for token_type, token in tokens + if token_type == "HEBREW" and token + ] + except (ImportError, Exception): + return split_hebrew_words(text) diff --git a/korban_processor/src/korban_processor/model_interface.py b/korban_processor/src/korban_processor/model_interface.py new file mode 100644 index 00000000..28831ad0 --- /dev/null +++ b/korban_processor/src/korban_processor/model_interface.py @@ -0,0 +1,106 @@ +""" +Model interface — outbound API calls to external models. + +Architecture §3.5: +The system calls out, never accepts inbound. + +Construction §3.4 Claude API contract: + - Model: claude-sonnet-4-5-20250929 + - Max tokens: 4096 + - Outbound HTTPS only + - API key from ANTHROPIC_API_KEY environment variable + +Kiyyor principle (structure_not_content.md): +Stateless functions. Each call starts clean. No persistent connections, +no session state, no model output caching. +""" + +import os +from typing import Optional + +import httpx +from pydantic import BaseModel + +CLAUDE_API_URL = "https://api.anthropic.com/v1/messages" +CLAUDE_MODEL = "claude-sonnet-4-5-20250929" +CLAUDE_MAX_TOKENS = 4096 +ANTHROPIC_VERSION = "2023-06-01" + + +class ModelRequest(BaseModel): + """A request to an external model.""" + query: str + system_prompt: Optional[str] = None + max_tokens: int = CLAUDE_MAX_TOKENS + + +class ModelResponse(BaseModel): + """A raw response from an external model, before offering protocol.""" + content: str + model: str + stop_reason: str + input_tokens: int + output_tokens: int + + +def call_claude(request: ModelRequest) -> ModelResponse: + """ + Call Claude API outbound. Stateless — no caching, no session state. + + Per Construction §3.4: outbound HTTPS only. The system calls out; + it does not accept inbound model connections. + + Args: + request: The query and parameters + + Returns: + Raw model response — NOT yet processed through offering protocol. + The caller must pass this through process_korban() before using. + + Raises: + ValueError: If ANTHROPIC_API_KEY is not set + httpx.HTTPStatusError: On API error response + httpx.RequestError: On network failure + """ + api_key = os.environ.get("ANTHROPIC_API_KEY") + if not api_key: + raise ValueError("ANTHROPIC_API_KEY not set — outbound calls require API key") + + headers = { + "x-api-key": api_key, + "anthropic-version": ANTHROPIC_VERSION, + "content-type": "application/json", + } + + payload: dict = { + "model": CLAUDE_MODEL, + "max_tokens": request.max_tokens, + "messages": [{"role": "user", "content": request.query}], + } + + if request.system_prompt: + payload["system"] = request.system_prompt + + with httpx.Client(timeout=60.0) as client: + response = client.post( + CLAUDE_API_URL, + headers=headers, + json=payload, + ) + response.raise_for_status() + data = response.json() + + content_blocks = data.get("content", []) + text_content = "".join( + block["text"] + for block in content_blocks + if block.get("type") == "text" + ) + + return ModelResponse( + content=text_content, + model=data.get("model", CLAUDE_MODEL), + stop_reason=data.get("stop_reason", ""), + input_tokens=data.get("usage", {}).get("input_tokens", 0), + output_tokens=data.get("usage", {}).get("output_tokens", 0), + ) diff --git a/korban_processor/src/korban_processor/router.py b/korban_processor/src/korban_processor/router.py index 060ba8eb..d7eef822 100644 --- a/korban_processor/src/korban_processor/router.py +++ b/korban_processor/src/korban_processor/router.py @@ -12,28 +12,25 @@ Route to appropriate component based on offering type. Phase 2: Routing is logged but not executed (engines not connected). Phase 3 (Step 14): Real routing to Talmudic Engine via socket, to Sanhedrin via BEAM distribution. + +Kiyyor principle (structure_not_content.md): +No module-level state. No caches. Each call is stateless and fresh. """ import logging -from typing import List +from dataclasses import dataclass from korban_processor.korban import ProcessedKorban, OfferingType logger = logging.getLogger(__name__) +@dataclass class RoutingResult: """Record of where a processed korban was routed.""" - - def __init__( - self, - destination: str, - propositions_sent: int, - returned_to_source: bool = False, - ): - self.destination = destination - self.propositions_sent = propositions_sent - self.returned_to_source = returned_to_source + destination: str + propositions_sent: int + returned_to_source: bool = False def __repr__(self) -> str: return ( @@ -43,15 +40,13 @@ class RoutingResult: ) -# Module-level routing log for inspection/testing -_routing_log: List[RoutingResult] = [] - - def route(processed: ProcessedKorban, offering_type: OfferingType) -> RoutingResult: """ Route processed korban to appropriate component. - Phase 2: Logs the routing decision but does not execute. + Stateless — returns RoutingResult, accumulates nothing. + + Phase 2: Logs the routing decision but does not execute real routing. Phase 3: Sends propositions to Talmudic Engine, etc. """ n_props = len(processed.propositions) @@ -116,15 +111,4 @@ def route(processed: ProcessedKorban, offering_type: OfferingType) -> RoutingRes ) logger.warning("Unknown offering type: %s", offering_type) - _routing_log.append(result) return result - - -def get_routing_log() -> List[RoutingResult]: - """Get the routing log (for testing/inspection).""" - return list(_routing_log) - - -def clear_routing_log() -> None: - """Clear the routing log.""" - _routing_log.clear() diff --git a/korban_processor/test/__init__.py b/korban_processor/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/korban_processor/test/fixtures.py b/korban_processor/test/fixtures.py new file mode 100644 index 00000000..114b6263 --- /dev/null +++ b/korban_processor/test/fixtures.py @@ -0,0 +1,69 @@ +""" +Test fixtures — recorded model outputs for deterministic testing. + +BUILD_KORBAN.md: "Test with real Claude API output (record a sample +response and use it as fixture)." + +These are representative samples of actual model output patterns. +They are not live API calls — they are static recordings used for +deterministic testing of the offering protocol. +""" + +# A realistic Claude API response with: +# - Heavy preamble +# - Markdown formatting (headers, bold, bullets) +# - Hedging phrases +# - Self-reference (AI disclaimers) +# - Sycophancy +# - BUT ALSO: Torah references, Hebrew text, root analysis +CLAUDE_HEAVY_PREAMBLE_RESPONSE = ( + "Great question! I'd be happy to help you explore this profound topic.\n\n" + "As an AI language model, I should note that my analysis is based on " + "traditional sources, and it's worth noting that interpretations may vary.\n\n" + "## The Root of Bereishit\n\n" + "**The word בְּרֵאשִׁית** (bereishit, Genesis 1:1) derives from the shoresh ר-א-ש " + "(resh-alef-shin), meaning *head* or *beginning*. This root connects to " + "the concept of primacy throughout Torah.\n\n" + "- The prefix ב means 'in' or 'with'\n" + "- The suffix ית indicates an abstract noun form\n" + "- Together: 'in/with the beginning'\n\n" + "Rashi on Bereishit 1:1 asks why the Torah begins with creation rather " + "than the first commandment (Exodus 12:2). His answer reveals that the " + "land of Israel belongs to God, who created it — a claim grounded in " + "the opening verse.\n\n" + "**The gematria** of בְּרֵאשִׁית is 913. " + "It's important to keep in mind that gematria is one lens among many.\n\n" + "In my analysis, the root ר-א-ש indicates headship and connects to " + "רֹאשׁ הַשָּׁנָה (Rosh Hashana, the head of the year).\n\n" + "I hope this helps! Let me know if you have any follow-up questions " + "about this fascinating topic." +) + +# A response with only preamble — no vital content (the empty offering) +CLAUDE_EMPTY_OFFERING = ( + "Great question! I'd be happy to help with that.\n\n" + "As an AI, I should mention that this is a complex topic.\n\n" + "It's worth noting that there are many perspectives.\n\n" + "Of course, experts disagree on this.\n\n" + "I hope that helps! Let me know if you need anything else." +) + +# A clean response — already stripped, just vital content +CLEAN_TORAH_RESPONSE = ( + "The word בְּרֵאשִׁית in Genesis 1:1 derives from the root ר-א-ש, " + "meaning head or beginning. " + "Rashi indicates this verse answers the nations' claim against Israel's possession of Canaan. " + "The Talmud (Sanhedrin 38a) connects this creation account to human dignity. " + "The gematria of בְּרֵאשִׁית is 913, which relates to the concept of completion. " + "In Bereishit 2:3 God rests — the Shabbat structure embedded in creation itself." +) + +# A response with Torah references but no Hebrew +LATIN_TORAH_RESPONSE = ( + "Genesis 1:1 establishes the divine ownership of creation. " + "Exodus 3:14 reveals the name Ehyeh asher Ehyeh — the self-referential becoming. " + "Leviticus 19:18 commands love of neighbor, which Rashi derives from the " + "principle of treating others as one treats oneself. " + "Numbers 15:38 introduces tzitzit as a visible reminder of commandments. " + "Deuteronomy 6:4 is the Shema — the unity declaration." +) diff --git a/korban_processor/test/test_haktarah.py b/korban_processor/test/test_haktarah.py new file mode 100644 index 00000000..e1d6e4f8 --- /dev/null +++ b/korban_processor/test/test_haktarah.py @@ -0,0 +1,185 @@ +""" +Tests for haktarah — transform modality (prose → Prolog propositions). + +BUILD_KORBAN.md Verification §3: +Feed it extracted vital content. Verify the Prolog propositions are +syntactically valid and semantically grounded. + +Architecture §3.5: haktarah converts prose insights into Prolog-compatible +propositions for the Talmudic Engine. +""" + +import re + +from korban_processor.haktarah import ( + haktarah, + torah_ref_to_prolog, + root_to_prolog, + extract_claims, + extract_connections, + sanitize_prolog_string, +) +from test.fixtures import CLEAN_TORAH_RESPONSE, LATIN_TORAH_RESPONSE + + +# Valid Prolog term pattern: functor(args...) +_PROLOG_TERM_PATTERN = re.compile(r"^\w+\(.*\)$") + + +def is_valid_prolog_term(s: str) -> bool: + """Check if a string looks like a valid Prolog term.""" + return bool(_PROLOG_TERM_PATTERN.match(s.strip())) + + +class TestTorahRefToProlog: + def test_genesis_becomes_bereishit(self): + result = torah_ref_to_prolog("Genesis 1:1") + assert result == "verse(bereishit, 1, 1)" + + def test_bereishit_stays_bereishit(self): + result = torah_ref_to_prolog("Bereishit 1:1") + assert result == "verse(bereishit, 1, 1)" + + def test_exodus_becomes_shemot(self): + result = torah_ref_to_prolog("Exodus 3:14") + assert result == "verse(shemot, 3, 14)" + + def test_leviticus_becomes_vayikra(self): + result = torah_ref_to_prolog("Leviticus 19:18") + assert result == "verse(vayikra, 19, 18)" + + def test_numbers_becomes_bamidbar(self): + result = torah_ref_to_prolog("Numbers 15:38") + assert result == "verse(bamidbar, 15, 38)" + + def test_deuteronomy_becomes_devarim(self): + result = torah_ref_to_prolog("Deuteronomy 6:4") + assert result == "verse(devarim, 6, 4)" + + def test_abbreviated_gen(self): + result = torah_ref_to_prolog("Gen. 1:1") + assert result == "verse(bereishit, 1, 1)" + + def test_invalid_ref_returns_empty(self): + result = torah_ref_to_prolog("not a reference") + assert result == "" + + +class TestRootToProlog: + def test_dash_separated_hebrew_root(self): + result = root_to_prolog("ר-א-ש") + assert result.startswith('root("ראש"') + assert "hebrew" in result + + def test_dot_separated_hebrew_root(self): + result = root_to_prolog("ר.א.ש") + assert result.startswith('root("ראש"') + + def test_root_keyword_with_hebrew(self): + result = root_to_prolog("root ראש") + assert "ראש" in result + assert "hebrew" in result + + def test_transliterated_root(self): + result = root_to_prolog("root shin") + assert "shin" in result + assert "transliterated" in result + + def test_invalid_root_returns_empty(self): + result = root_to_prolog("not a root") + assert result == "" + + +class TestExtractClaims: + def test_extracts_means_claim(self): + text = "The root ר-א-ש means head or beginning." + claims = extract_claims(text) + assert len(claims) >= 1 + assert any('claim(' in c for c in claims) + + def test_extracts_indicates_claim(self): + text = "This passage indicates divine ownership of creation." + claims = extract_claims(text) + assert len(claims) >= 1 + + def test_claim_is_valid_prolog_term(self): + text = "The root ר-א-ש means head or beginning." + claims = extract_claims(text) + for claim in claims: + assert is_valid_prolog_term(claim), f"Not a valid Prolog term: {claim}" + + def test_no_claims_returns_empty(self): + text = "This is a plain sentence without analytical markers." + claims = extract_claims(text) + # May be empty — that's fine + for claim in claims: + assert is_valid_prolog_term(claim) + + +class TestExtractConnections: + def test_extracts_connects_to(self): + text = "The concept of Shabbat connects to creation cessation." + conns = extract_connections(text) + assert len(conns) >= 1 + assert any('connection(' in c for c in conns) + + def test_connection_is_valid_prolog_term(self): + text = "Rosh Hashana parallels Bereishit through the root ר-א-ש." + conns = extract_connections(text) + for conn in conns: + assert is_valid_prolog_term(conn), f"Not a valid Prolog term: {conn}" + + +class TestSanitizePrologString: + def test_escapes_double_quotes(self): + result = sanitize_prolog_string('He said "hello"') + assert '\\"' in result + + def test_replaces_newlines(self): + result = sanitize_prolog_string("line one\nline two") + assert "\n" not in result + + def test_truncates_long_strings(self): + long_str = "x" * 300 + result = sanitize_prolog_string(long_str) + assert len(result) <= 204 # 200 + "..." + + +class TestHaktarah: + def test_returns_list(self): + result = haktarah(CLEAN_TORAH_RESPONSE) + assert isinstance(result, list) + + def test_empty_input_returns_empty_list(self): + result = haktarah("") + assert result == [] + + def test_all_propositions_are_valid_prolog_terms(self): + """BUILD_KORBAN verification §3: Prolog propositions are syntactically valid.""" + result = haktarah(CLEAN_TORAH_RESPONSE) + assert len(result) > 0 + for prop in result: + assert is_valid_prolog_term(prop), f"Invalid Prolog term: {prop}" + + def test_torah_refs_produce_verse_facts(self): + """BUILD_KORBAN verification §3: semantically grounded.""" + result = haktarah(LATIN_TORAH_RESPONSE) + verse_props = [p for p in result if p.startswith("verse(")] + assert len(verse_props) >= 3 # 5 books referenced + + def test_contains_bereishit_verse(self): + result = haktarah(CLEAN_TORAH_RESPONSE) + assert any("verse(bereishit," in p for p in result) + + def test_hebrew_roots_produce_root_facts(self): + content = "The root ר-א-ש means beginning. Genesis 1:1 uses בְּרֵאשִׁית." + result = haktarah(content) + root_props = [p for p in result if p.startswith("root(")] + assert len(root_props) >= 1 + + def test_stateless_between_calls(self): + """Kiyyor principle: second call unaffected by first.""" + result1a = haktarah(CLEAN_TORAH_RESPONSE) + _ = haktarah(LATIN_TORAH_RESPONSE) + result1b = haktarah(CLEAN_TORAH_RESPONSE) + assert result1a == result1b diff --git a/korban_processor/test/test_hebrew_parser.py b/korban_processor/test/test_hebrew_parser.py new file mode 100644 index 00000000..3387e2d2 --- /dev/null +++ b/korban_processor/test/test_hebrew_parser.py @@ -0,0 +1,177 @@ +""" +Tests for hebrew_parser — Hebrew text analysis utilities. + +Architecture §3.5: Used by zerikah for Hebrew analysis. +Construction §1.4: hebrew-tokenizer==2.3.0. +""" + +from korban_processor.hebrew_parser import ( + is_consonant, + is_hebrew_char, + is_hebrew_text, + strip_nikkud, + strip_teamim, + strip_nikkud_and_teamim, + extract_consonants, + gematria, + identify_root, + normalize_root_notation, + split_hebrew_words, +) + + +class TestHebrewCharDetection: + def test_alef_is_consonant(self): + assert is_consonant("א") + + def test_tav_is_consonant(self): + assert is_consonant("ת") + + def test_latin_a_not_consonant(self): + assert not is_consonant("a") + + def test_nikkud_sheva_not_consonant(self): + # U+05B0 = SHEVA — vowel point, not consonant + assert not is_consonant("\u05b0") + + def test_hebrew_text_with_consonants(self): + assert is_hebrew_text("בְּרֵאשִׁית") + + def test_latin_text_not_hebrew(self): + assert not is_hebrew_text("Genesis") + + def test_empty_not_hebrew(self): + assert not is_hebrew_text("") + + +class TestNikkudStripping: + def test_strip_nikkud_bereishit(self): + # בְּרֵאשִׁית → בראשית (consonants + te'amim, no nikkud) + result = strip_nikkud("בְּרֵאשִׁית") + # Should contain only consonants (and possibly te'amim if any) + for ch in result: + assert ord(ch) < 0x05B0 or ord(ch) >= 0x05C8 or is_consonant(ch), \ + f"Unexpected nikkud char: {ch!r} (U+{ord(ch):04X})" + + def test_strip_nikkud_preserves_consonants(self): + result = strip_nikkud("בְּרֵאשִׁית") + assert "ב" in result + assert "ר" in result + assert "א" in result + assert "ש" in result + assert "י" in result + assert "ת" in result + + def test_strip_nikkud_plain_text_unchanged(self): + assert strip_nikkud("בראשית") == "בראשית" + + def test_strip_nikkud_and_teamim(self): + result = strip_nikkud_and_teamim("בְּרֵאשִׁית") + # Only consonants should remain + for ch in result: + assert is_consonant(ch), f"Non-consonant after full strip: {ch!r}" + + +class TestExtractConsonants: + def test_extracts_consonants_from_bereishit(self): + result = extract_consonants("בְּרֵאשִׁית") + assert result == "בראשית" + + def test_extracts_consonants_plain(self): + assert extract_consonants("בראשית") == "בראשית" + + def test_empty_returns_empty(self): + assert extract_consonants("") == "" + + def test_latin_returns_empty(self): + assert extract_consonants("Genesis") == "" + + +class TestGematria: + def test_alef_is_1(self): + assert gematria("א") == 1 + + def test_bet_is_2(self): + assert gematria("ב") == 2 + + def test_tav_is_400(self): + assert gematria("ת") == 400 + + def test_torah_gematria(self): + # תורה = 400 + 6 + 200 + 5 = 611 + assert gematria("תורה") == 611 + + def test_bereishit_gematria(self): + # בראשית = 2 + 200 + 1 + 300 + 10 + 400 = 913 + assert gematria("בראשית") == 913 + + def test_kaf_sofit_equals_kaf(self): + # ך = 20 (same as כ) + assert gematria("ך") == gematria("כ") + + def test_mem_sofit_equals_mem(self): + # ם = 40 (same as מ) + assert gematria("ם") == gematria("מ") + + def test_latin_chars_ignored(self): + assert gematria("abc") == 0 + + def test_nikkud_ignored(self): + # Nikkud chars have no gematria value + assert gematria("בְּרֵאשִׁית") == gematria("בראשית") + + +class TestIdentifyRoot: + def test_three_letter_word_returns_word(self): + root = identify_root("בָּרָא") + assert root == "ברא" + + def test_short_word_returns_none(self): + assert identify_root("אל") is None + + def test_empty_returns_none(self): + assert identify_root("") is None + + def test_returns_first_three_consonants(self): + # For a 4-letter word, return first 3 (Phase 2 approximation) + root = identify_root("בראשית") + assert root == "בר" + "א" # First 3 consonants + + +class TestNormalizeRootNotation: + def test_dash_separated(self): + result = normalize_root_notation("ר-א-ש") + assert result == "ראש" + + def test_dot_separated(self): + result = normalize_root_notation("ר.א.ש") + assert result == "ראש" + + def test_bare_consonants(self): + result = normalize_root_notation("ראש") + assert result == "ראש" + + def test_non_three_letter_returns_none(self): + assert normalize_root_notation("אב") is None + assert normalize_root_notation("אבגד") is None + + +class TestSplitHebrewWords: + def test_splits_single_word(self): + words = split_hebrew_words("בְּרֵאשִׁית") + assert len(words) == 1 + + def test_splits_multiple_words(self): + words = split_hebrew_words("בְּרֵאשִׁית בָּרָא אֱלֹהִים") + assert len(words) == 3 + + def test_splits_on_latin_boundary(self): + words = split_hebrew_words("The word בְּרֵאשִׁית opens Torah.") + assert len(words) == 1 + assert "ב" in words[0] + + def test_empty_text_returns_empty(self): + assert split_hebrew_words("") == [] + + def test_latin_only_returns_empty(self): + assert split_hebrew_words("Genesis 1:1") == [] diff --git a/korban_processor/test/test_korban.py b/korban_processor/test/test_korban.py new file mode 100644 index 00000000..d445735b --- /dev/null +++ b/korban_processor/test/test_korban.py @@ -0,0 +1,333 @@ +""" +Full pipeline tests — process_korban() end-to-end. + +BUILD_KORBAN.md Build Verification: +1. Shechitah test — strips preamble/markdown/hedging/self-reference, preserves Torah +2. Zerikah test — extracts Torah references, Hebrew roots, separates vital from discardable +3. Haktarah test — Prolog propositions syntactically valid and semantically grounded +4. Statelessness test — no state leaks between sequential offerings +5. Empty offering test — graceful rejection when no vital content exists + +Architecture §3.5: process_korban() is the gate between the world's raw output +and the system's inner processing. + +Kiyyor principle (structure_not_content.md): +The laver. Resets before every offering. Daily manna — gathered fresh. +""" + +import re +import pytest + +from korban_processor.korban import ( + Korban, + ProcessedKorban, + OfferingType, + Provenance, + process_korban, +) +from test.fixtures import ( + CLAUDE_HEAVY_PREAMBLE_RESPONSE, + CLAUDE_EMPTY_OFFERING, + CLEAN_TORAH_RESPONSE, + LATIN_TORAH_RESPONSE, +) + +_PROLOG_TERM_PATTERN = re.compile(r"^\w+\(.*\)$") + + +def is_valid_prolog_term(s: str) -> bool: + return bool(_PROLOG_TERM_PATTERN.match(s.strip())) + + +class TestBuildVerification1_Shechitah: + """ + BUILD_KORBAN §1: Feed a Claude API response with heavy preamble, markdown, + hedging, and self-reference. Verify output contains none of these and + preserves all Torah references and Hebrew analysis. + """ + + def setup_method(self): + self.result = process_korban( + content=CLAUDE_HEAVY_PREAMBLE_RESPONSE, + source=Provenance.CLAUDE, + query="analyze bereishit", + offering_type=OfferingType.OLAH, + ) + + def test_returns_processed_korban(self): + assert isinstance(self.result, ProcessedKorban) + + def test_strips_preamble(self): + assert "Great question" not in self.result.vital_content + assert "I'd be happy" not in self.result.vital_content + + def test_strips_self_reference(self): + assert "As an AI" not in self.result.vital_content + assert "language model" not in self.result.vital_content + + def test_strips_hedging(self): + assert "worth noting" not in self.result.vital_content + + def test_strips_closing(self): + assert "I hope this helps" not in self.result.vital_content + assert "Let me know" not in self.result.vital_content + + def test_preserves_bereishit_hebrew(self): + assert "בְּרֵאשִׁית" in self.result.vital_content + + def test_preserves_root_notation(self): + assert "ר-א-ש" in self.result.vital_content + + def test_preserves_rashi_reference(self): + assert "Rashi" in self.result.vital_content + + def test_preserves_torah_reference(self): + assert "Genesis 1:1" in self.result.vital_content or "Bereishit 1:1" in self.result.vital_content + + def test_provenance_recorded(self): + assert self.result.provenance == Provenance.CLAUDE + + def test_offering_type_recorded(self): + assert self.result.offering_type == OfferingType.OLAH + + +class TestBuildVerification2_Zerikah: + """ + BUILD_KORBAN §2: Feed a stripped response. Verify it extracts Torah references + accurately, identifies Hebrew roots, and separates vital content from discardable. + """ + + def setup_method(self): + self.result = process_korban( + content=CLEAN_TORAH_RESPONSE, + source=Provenance.CLAUDE, + query="analyze bereishit", + offering_type=OfferingType.OLAH, + ) + + def test_extracts_torah_references(self): + assert "Genesis 1:1" in self.result.vital_content or "Bereishit" in self.result.vital_content + + def test_extracts_hebrew_roots(self): + assert "ר-א-ש" in self.result.vital_content or "ראש" in self.result.vital_content + + def test_separates_vital_from_discardable(self): + # The result has vital content — it was not entirely discarded + assert self.result.vital_content.strip() != "" + + def test_discarded_is_list(self): + assert isinstance(self.result.discarded, list) + + def test_multiple_book_references(self): + result = process_korban( + content=LATIN_TORAH_RESPONSE, + source=Provenance.CLAUDE, + query="list references", + offering_type=OfferingType.OLAH, + ) + # All five books appear + assert "Genesis 1:1" in result.vital_content + assert "Exodus 3:14" in result.vital_content + + +class TestBuildVerification3_Haktarah: + """ + BUILD_KORBAN §3: Feed extracted vital content. Verify Prolog propositions are + syntactically valid and semantically grounded. + """ + + def setup_method(self): + self.result = process_korban( + content=LATIN_TORAH_RESPONSE, + source=Provenance.CLAUDE, + query="list references", + offering_type=OfferingType.OLAH, + ) + + def test_produces_propositions(self): + assert len(self.result.propositions) > 0 + + def test_all_propositions_syntactically_valid(self): + for prop in self.result.propositions: + assert is_valid_prolog_term(prop), \ + f"Invalid Prolog proposition: {prop!r}" + + def test_propositions_include_verse_facts(self): + verse_facts = [p for p in self.result.propositions if p.startswith("verse(")] + assert len(verse_facts) >= 3, \ + f"Expected ≥3 verse facts, got {len(verse_facts)}: {verse_facts}" + + def test_bereishit_verse_fact_present(self): + result = process_korban( + content=CLEAN_TORAH_RESPONSE, + source=Provenance.CLAUDE, + query="analyze", + offering_type=OfferingType.OLAH, + ) + verse_facts = [p for p in result.propositions if p.startswith("verse(")] + assert any("bereishit" in p for p in verse_facts), \ + f"No bereishit verse fact in: {verse_facts}" + + +class TestBuildVerification4_Statelessness: + """ + BUILD_KORBAN §4: Process two offerings sequentially. Verify no state leaked + between them. The second offering must be processed identically whether or not + the first was processed. + """ + + def test_two_offerings_no_state_leak(self): + # Process first offering + result1 = process_korban( + content=CLAUDE_HEAVY_PREAMBLE_RESPONSE, + source=Provenance.CLAUDE, + query="query 1", + offering_type=OfferingType.OLAH, + ) + + # Process second offering + result2 = process_korban( + content=LATIN_TORAH_RESPONSE, + source=Provenance.MISTRAL, + query="query 2", + offering_type=OfferingType.MINCHAH, + ) + + # result1 should not contain anything from result2 + assert "Mistral" not in result1.vital_content + # result2 should not contain anything from result1 (Hebrew preamble content) + assert "I'd be happy" not in result2.vital_content + + def test_second_offering_identical_with_or_without_first(self): + """ + Process offering A twice — once with offering B in between, + once alone. Results must be identical. + """ + # First: process A alone + result_a1 = process_korban( + content=LATIN_TORAH_RESPONSE, + source=Provenance.CLAUDE, + query="query A", + offering_type=OfferingType.OLAH, + ) + + # Process unrelated offering B + process_korban( + content=CLAUDE_HEAVY_PREAMBLE_RESPONSE, + source=Provenance.GPT, + query="query B", + offering_type=OfferingType.CHATAT, + ) + + # Process A again — must be identical to first time + result_a2 = process_korban( + content=LATIN_TORAH_RESPONSE, + source=Provenance.CLAUDE, + query="query A", + offering_type=OfferingType.OLAH, + ) + + assert result_a1.vital_content == result_a2.vital_content + assert result_a1.propositions == result_a2.propositions + assert result_a1.discarded == result_a2.discarded + + def test_provenance_not_shared_between_offerings(self): + result1 = process_korban( + content=CLEAN_TORAH_RESPONSE, + source=Provenance.CLAUDE, + query="q", + ) + result2 = process_korban( + content=CLEAN_TORAH_RESPONSE, + source=Provenance.MISTRAL, + query="q", + ) + # Same content, different provenance — each result has correct provenance + assert result1.provenance == Provenance.CLAUDE + assert result2.provenance == Provenance.MISTRAL + + +class TestBuildVerification5_EmptyOffering: + """ + BUILD_KORBAN §5: Feed a response that contains only preamble and no vital content. + Verify the system handles this gracefully — the offering without vital organs is + rejected, not processed into empty propositions. + """ + + def setup_method(self): + self.result = process_korban( + content=CLAUDE_EMPTY_OFFERING, + source=Provenance.CLAUDE, + query="test empty", + offering_type=OfferingType.OLAH, + ) + + def test_returns_processed_korban_not_exception(self): + """System does not raise on empty offering — handles gracefully.""" + assert isinstance(self.result, ProcessedKorban) + + def test_propositions_are_not_garbage(self): + """ + If propositions exist for an empty offering, they must still be valid Prolog terms. + Empty or minimal propositions are acceptable — garbage propositions are not. + """ + for prop in self.result.propositions: + assert is_valid_prolog_term(prop), \ + f"Garbage proposition from empty offering: {prop!r}" + + def test_vital_content_has_no_assistant_voice(self): + """Whatever survives, it must not contain assistant preamble.""" + assert "Great question" not in self.result.vital_content + assert "I'd be happy" not in self.result.vital_content + assert "As an AI" not in self.result.vital_content + assert "Let me know" not in self.result.vital_content + + def test_source_query_preserved(self): + assert self.result.source_query == "test empty" + + +class TestPipelineIntegration: + """Additional integration tests covering edge cases.""" + + def test_all_offering_types_route(self): + """All four offering types process without error.""" + for otype in OfferingType: + result = process_korban( + content=CLEAN_TORAH_RESPONSE, + source=Provenance.CLAUDE, + query="test", + offering_type=otype, + ) + assert isinstance(result, ProcessedKorban) + assert result.offering_type == otype + + def test_all_provenance_sources_accepted(self): + """All six provenance sources process without error.""" + for prov in Provenance: + result = process_korban( + content=CLEAN_TORAH_RESPONSE, + source=prov, + query="test", + ) + assert result.provenance == prov + + def test_human_input_processes_correctly(self): + human_input = "What does bereishit mean? The word בְּרֵאשִׁית opens Genesis 1:1." + result = process_korban( + content=human_input, + source=Provenance.HUMAN, + query="definition query", + offering_type=OfferingType.SHELAMIM, + ) + assert result.provenance == Provenance.HUMAN + assert result.offering_type == OfferingType.SHELAMIM + assert "בְּרֵאשִׁית" in result.vital_content or "Genesis 1:1" in result.vital_content + + def test_empty_raises_value_error(self): + """Empty content must be rejected at semichah.""" + with pytest.raises(ValueError, match="Empty korban"): + process_korban( + content="", + source=Provenance.CLAUDE, + query="test", + ) diff --git a/korban_processor/test/test_model_interface.py b/korban_processor/test/test_model_interface.py new file mode 100644 index 00000000..87af9591 --- /dev/null +++ b/korban_processor/test/test_model_interface.py @@ -0,0 +1,176 @@ +""" +Tests for model_interface — outbound API calls. + +Construction §3.4: outbound HTTPS only, no inbound. +No live API calls in tests — use mocked httpx responses. + +Kiyyor principle: stateless functions, no connection pooling between calls. +""" + +import json +import os +import pytest + +import httpx + +from korban_processor.model_interface import ( + ModelRequest, + ModelResponse, + call_claude, + CLAUDE_MODEL, + CLAUDE_MAX_TOKENS, + ANTHROPIC_VERSION, +) + + +class TestModelRequest: + def test_default_max_tokens(self): + req = ModelRequest(query="test") + assert req.max_tokens == CLAUDE_MAX_TOKENS + + def test_no_system_prompt_by_default(self): + req = ModelRequest(query="test") + assert req.system_prompt is None + + def test_custom_max_tokens(self): + req = ModelRequest(query="test", max_tokens=1024) + assert req.max_tokens == 1024 + + def test_with_system_prompt(self): + req = ModelRequest(query="test", system_prompt="You analyze Torah.") + assert req.system_prompt == "You analyze Torah." + + +class TestModelResponseShape: + def test_model_response_fields(self): + resp = ModelResponse( + content="בְּרֵאשִׁית means 'in the beginning'.", + model=CLAUDE_MODEL, + stop_reason="end_turn", + input_tokens=10, + output_tokens=50, + ) + assert resp.content + assert resp.model == CLAUDE_MODEL + assert resp.stop_reason == "end_turn" + assert resp.input_tokens == 10 + assert resp.output_tokens == 50 + + +class TestCallClaudeRequiresApiKey: + def test_raises_without_api_key(self, monkeypatch): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + with pytest.raises(ValueError, match="ANTHROPIC_API_KEY"): + call_claude(ModelRequest(query="test")) + + +class TestCallClaudeMocked: + """Test call_claude with mocked httpx transport — no live API calls.""" + + def _make_api_response(self, text: str) -> dict: + return { + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": CLAUDE_MODEL, + "content": [{"type": "text", "text": text}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 20, "output_tokens": 40}, + } + + def test_sends_correct_model(self, monkeypatch, respx_mock=None): + """Verify the request uses the specified Claude model.""" + # Test via request inspection using httpx transport mock + captured_requests = [] + + class CapturingTransport(httpx.BaseTransport): + def handle_request(self, request): + captured_requests.append(request) + body = self._make_api_response( + "The root ר-א-ש means head. Genesis 1:1." + ) + return httpx.Response( + 200, + content=json.dumps(body).encode(), + headers={"content-type": "application/json"}, + request=request, + ) + + def _make_api_response(self, text): + return { + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": CLAUDE_MODEL, + "content": [{"type": "text", "text": text}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 20, "output_tokens": 40}, + } + + import korban_processor.model_interface as mi + + original_client = httpx.Client + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key-123") + + def patched_client(**kwargs): + return original_client(transport=CapturingTransport(), **{ + k: v for k, v in kwargs.items() if k not in ("transport",) + }) + + monkeypatch.setattr(httpx, "Client", patched_client) + + req = ModelRequest(query="What does bereishit mean?") + resp = mi.call_claude(req) + + assert len(captured_requests) == 1 + body = json.loads(captured_requests[0].content) + assert body["model"] == CLAUDE_MODEL + assert body["max_tokens"] == CLAUDE_MAX_TOKENS + + def test_correct_anthropic_headers(self, monkeypatch): + """Verify the request sends correct Anthropic API headers.""" + captured_requests = [] + + class HeaderCapture(httpx.BaseTransport): + def handle_request(self, request): + captured_requests.append(request) + body = { + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": CLAUDE_MODEL, + "content": [{"type": "text", "text": "test response"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 5, "output_tokens": 10}, + } + return httpx.Response( + 200, + content=json.dumps(body).encode(), + headers={"content-type": "application/json"}, + request=request, + ) + + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key-abc") + + import korban_processor.model_interface as mi + original_client = httpx.Client + + def patched_client(**kwargs): + return original_client(transport=HeaderCapture(), **{ + k: v for k, v in kwargs.items() if k not in ("transport",) + }) + + monkeypatch.setattr(httpx, "Client", patched_client) + + mi.call_claude(ModelRequest(query="test")) + + req = captured_requests[0] + assert req.headers["x-api-key"] == "test-key-abc" + assert req.headers["anthropic-version"] == ANTHROPIC_VERSION + assert req.headers["content-type"] == "application/json" + + def test_outbound_only_https(self, monkeypatch): + """Construction §3.4: outbound HTTPS only. Verify the URL is HTTPS.""" + import korban_processor.model_interface as mi + assert mi.CLAUDE_API_URL.startswith("https://"), \ + "Claude API URL must use HTTPS — outbound HTTPS only per Construction §3.4" diff --git a/korban_processor/test/test_router.py b/korban_processor/test/test_router.py new file mode 100644 index 00000000..0654646d --- /dev/null +++ b/korban_processor/test/test_router.py @@ -0,0 +1,112 @@ +""" +Tests for router — output routing based on offering type. + +Architecture §3.5: Route to appropriate component based on offering type. + +Kiyyor principle (structure_not_content.md): +Stateless — no module-level routing log, no state between calls. +""" + +from korban_processor.korban import ProcessedKorban, OfferingType, Provenance +from korban_processor.router import route, RoutingResult + + +_DEFAULT_PROPOSITIONS = ["verse(bereishit, 1, 1)", 'root("ראש", hebrew)'] + + +def make_processed( + offering_type: OfferingType, + propositions: list[str] | None = None, +) -> ProcessedKorban: + props = _DEFAULT_PROPOSITIONS if propositions is None else propositions + return ProcessedKorban( + vital_content="The root ר-א-ש means beginning. Bereishit 1:1.", + propositions=props, + provenance=Provenance.CLAUDE, + offering_type=offering_type, + discarded=[], + source_query="test query", + ) + + +class TestRoutingResults: + def test_olah_routes_to_talmudic_engine(self): + processed = make_processed(OfferingType.OLAH) + result = route(processed, OfferingType.OLAH) + assert result.destination == "talmudic_engine" + assert result.returned_to_source is False + + def test_shelamim_routes_to_talmudic_engine_with_return(self): + processed = make_processed(OfferingType.SHELAMIM) + result = route(processed, OfferingType.SHELAMIM) + assert result.destination == "talmudic_engine" + assert result.returned_to_source is True + + def test_chatat_routes_to_error_correction(self): + processed = make_processed(OfferingType.CHATAT) + result = route(processed, OfferingType.CHATAT) + assert result.destination == "error_correction" + assert result.returned_to_source is False + + def test_minchah_routes_to_talmudic_engine(self): + processed = make_processed(OfferingType.MINCHAH) + result = route(processed, OfferingType.MINCHAH) + assert result.destination == "talmudic_engine" + assert result.returned_to_source is False + + def test_returns_routing_result_instance(self): + processed = make_processed(OfferingType.OLAH) + result = route(processed, OfferingType.OLAH) + assert isinstance(result, RoutingResult) + + def test_propositions_sent_count_is_correct(self): + propositions = ["verse(bereishit, 1, 1)", 'root("ראש", hebrew)', 'claim("a", "b")'] + processed = make_processed(OfferingType.OLAH, propositions) + result = route(processed, OfferingType.OLAH) + assert result.propositions_sent == 3 + + def test_zero_propositions(self): + processed = make_processed(OfferingType.OLAH, []) + result = route(processed, OfferingType.OLAH) + assert result.propositions_sent == 0 + + +class TestRouterStateless: + """ + BUILD_KORBAN verification §4: + Kiyyor principle — no state leaks between calls. + No module-level routing log. + """ + + def test_no_module_level_routing_log(self): + """router.py must not have a module-level _routing_log accumulator.""" + import korban_processor.router as router_module + assert not hasattr(router_module, "_routing_log"), ( + "router.py has module-level _routing_log — violates kiyyor principle" + ) + + def test_successive_calls_independent(self): + """Second call returns correct result regardless of first call.""" + p1 = make_processed(OfferingType.OLAH, ["prop1"]) + p2 = make_processed(OfferingType.CHATAT, ["prop2", "prop3"]) + + result1 = route(p1, OfferingType.OLAH) + result2 = route(p2, OfferingType.CHATAT) + + assert result1.destination == "talmudic_engine" + assert result1.propositions_sent == 1 + assert result2.destination == "error_correction" + assert result2.propositions_sent == 2 + + def test_same_input_same_output(self): + """Identical input always produces identical output — no accumulated state.""" + processed = make_processed(OfferingType.SHELAMIM, ["verse(bereishit, 1, 1)"]) + + result1 = route(processed, OfferingType.SHELAMIM) + # Run an unrelated routing between + route(make_processed(OfferingType.CHATAT, []), OfferingType.CHATAT) + result2 = route(processed, OfferingType.SHELAMIM) + + assert result1.destination == result2.destination + assert result1.propositions_sent == result2.propositions_sent + assert result1.returned_to_source == result2.returned_to_source diff --git a/korban_processor/test/test_semichah.py b/korban_processor/test/test_semichah.py new file mode 100644 index 00000000..edc3c899 --- /dev/null +++ b/korban_processor/test/test_semichah.py @@ -0,0 +1,62 @@ +""" +Tests for semichah — provenance tagging. + +Architecture §3.5: semichah associates input with its source. +Every korban must have known provenance. Anonymous input is rejected. +""" + +import pytest + +from korban_processor.korban import Korban, OfferingType, Provenance +from korban_processor.semichah import semichah + + +class TestSemichahTagsProvenance: + def test_tags_claude_provenance(self): + korban = semichah("some content", Provenance.CLAUDE, "test query") + assert korban.provenance == Provenance.CLAUDE + + def test_tags_human_provenance(self): + korban = semichah("some content", Provenance.HUMAN, "test query") + assert korban.provenance == Provenance.HUMAN + + def test_all_provenance_sources(self): + for prov in Provenance: + korban = semichah("content", prov, "query") + assert korban.provenance == prov + + def test_preserves_raw_content(self): + raw = "The root ר-א-ש means beginning. Bereishit 1:1." + korban = semichah(raw, Provenance.CLAUDE, "query") + assert korban.raw_content == raw + + def test_preserves_source_query(self): + query = "what does bereishit mean?" + korban = semichah("content", Provenance.CLAUDE, query) + assert korban.source_query == query + + def test_default_offering_type_is_olah(self): + korban = semichah("content", Provenance.CLAUDE, "query") + assert korban.offering_type == OfferingType.OLAH + + def test_explicit_offering_type(self): + korban = semichah("content", Provenance.CLAUDE, "query", OfferingType.SHELAMIM) + assert korban.offering_type == OfferingType.SHELAMIM + + def test_returns_korban_instance(self): + result = semichah("content", Provenance.CLAUDE, "query") + assert isinstance(result, Korban) + + +class TestSemichahRejectsInvalidInput: + def test_rejects_empty_content(self): + with pytest.raises(ValueError, match="Empty korban"): + semichah("", Provenance.CLAUDE, "query") + + def test_rejects_whitespace_only_content(self): + with pytest.raises(ValueError, match="Empty korban"): + semichah(" \n\t ", Provenance.CLAUDE, "query") + + def test_rejects_non_provenance_source(self): + with pytest.raises((ValueError, AttributeError)): + semichah("content", "claude", "query") # type: ignore[arg-type] diff --git a/korban_processor/test/test_shechitah.py b/korban_processor/test/test_shechitah.py new file mode 100644 index 00000000..5253ffb9 --- /dev/null +++ b/korban_processor/test/test_shechitah.py @@ -0,0 +1,192 @@ +""" +Tests for shechitah — terminate raw form. + +BUILD_KORBAN.md Verification §1: +Feed it a Claude API response with heavy preamble, markdown, hedging, +and self-reference. Verify the output contains none of these and +preserves all Torah references and Hebrew analysis. + +Architecture §3.5: The shechitah must be swift and complete. +A nick in the blade renders the shechitah invalid. +""" + +import pytest + +from korban_processor.korban import Korban, OfferingType, Provenance +from korban_processor.shechitah import ( + shechitah, + strip_preamble, + strip_self_reference, + strip_hedging, + strip_markdown, + strip_closing, + clean_whitespace, +) +from test.fixtures import CLAUDE_HEAVY_PREAMBLE_RESPONSE, CLAUDE_EMPTY_OFFERING + + +def make_korban(content: str, prov: Provenance = Provenance.CLAUDE) -> Korban: + return Korban( + raw_content=content, + provenance=prov, + offering_type=OfferingType.OLAH, + source_query="test query", + ) + + +class TestShechitahStripsAssistantVoice: + """BUILD_KORBAN verification §1: strips preamble, markdown, hedging, self-reference.""" + + def test_strips_great_question_preamble(self): + content = "Great question! I'd be happy to help you. The root ר-א-ש means beginning." + result = shechitah(make_korban(content)) + assert "Great question" not in result + assert "I'd be happy" not in result + + def test_strips_sure_preamble(self): + content = "Sure! I can help with that. Genesis 1:1 begins with bereishit." + result = shechitah(make_korban(content)) + assert "Sure!" not in result + assert "Genesis 1:1" in result + + def test_strips_certainly_preamble(self): + content = "Certainly! Here is a breakdown: The word בְּרֵאשִׁית derives from ר-א-ש." + result = shechitah(make_korban(content)) + assert "Certainly" not in result + + def test_strips_self_reference_as_ai(self): + # Self-reference and Torah content are in separate sentences. + # "As an AI language model, my knowledge has limits." is stripped. + # "Genesis 1:1 begins the Torah." is preserved in the following sentence. + content = "As an AI language model, my knowledge has limits. Genesis 1:1 begins the Torah." + result = shechitah(make_korban(content)) + assert "As an AI" not in result + assert "language model" not in result + assert "Genesis 1:1" in result + + def test_strips_hedging_worth_noting(self): + content = "The root ר-א-ש means head. It's worth noting that interpretations vary." + result = shechitah(make_korban(content)) + assert "worth noting" not in result + assert "ר-א-ש" in result + + def test_strips_hedging_important_to_note(self): + content = "Bereishit 1:1 opens Torah. It is important to note that Rashi comments." + result = shechitah(make_korban(content)) + assert "important to note" not in result + assert "Bereishit 1:1" in result + + def test_strips_closing_let_me_know(self): + content = "The root ר-א-ש indicates beginning.\n\nLet me know if you have more questions!" + result = shechitah(make_korban(content)) + assert "Let me know" not in result + assert "ר-א-ש" in result + + def test_strips_closing_feel_free(self): + content = "Genesis 1:1 establishes divine creation.\n\nFeel free to ask more!" + result = shechitah(make_korban(content)) + assert "Feel free" not in result + assert "Genesis 1:1" in result + + +class TestShechitahStripsMarkdown: + def test_strips_bold_markers(self): + result = strip_markdown("**The root** ר-א-ש means beginning.") + assert "**" not in result + assert "The root" in result + assert "ר-א-ש" in result + + def test_strips_italic_markers(self): + result = strip_markdown("The word *bereishit* comes from ר-א-ש.") + assert "*" not in result + assert "bereishit" in result + + def test_strips_header_markers(self): + result = strip_markdown("## Analysis of Bereishit 1:1\n\nThe root is ר-א-ש.") + assert "##" not in result + assert "Analysis of Bereishit 1:1" in result + + def test_strips_bullet_markers(self): + result = strip_markdown("- The root ר-א-ש means head\n- Genesis 1:1 opens Torah") + assert result.lstrip().startswith("The root") or "The root" in result + assert "ר-א-ש" in result + + def test_strips_numbered_list_markers(self): + result = strip_markdown("1. Genesis 1:1\n2. Exodus 3:14") + assert "1." not in result + assert "Genesis 1:1" in result + assert "Exodus 3:14" in result + + def test_strips_code_blocks(self): + result = strip_markdown("The root is ר-א-ש.\n```\ncode block\n```\nMore text.") + assert "```" not in result + assert "code block" not in result + assert "ר-א-ש" in result + + def test_strips_inline_code_keeps_text(self): + result = strip_markdown("The function `bereishit()` relates to ר-א-ש.") + assert "`" not in result + assert "bereishit()" in result + + +class TestShechitahPreservesTorah: + """BUILD_KORBAN verification §1: preserves Torah references and Hebrew analysis.""" + + def test_preserves_torah_reference_bereishit(self): + korban = make_korban(CLAUDE_HEAVY_PREAMBLE_RESPONSE) + result = shechitah(korban) + assert "Genesis 1:1" in result or "Bereishit 1:1" in result + + def test_preserves_hebrew_text(self): + korban = make_korban(CLAUDE_HEAVY_PREAMBLE_RESPONSE) + result = shechitah(korban) + assert "בְּרֵאשִׁית" in result + + def test_preserves_root_notation(self): + korban = make_korban(CLAUDE_HEAVY_PREAMBLE_RESPONSE) + result = shechitah(korban) + assert "ר-א-ש" in result + + def test_preserves_rashi_reference(self): + korban = make_korban(CLAUDE_HEAVY_PREAMBLE_RESPONSE) + result = shechitah(korban) + assert "Rashi" in result + + def test_full_shechitah_strips_all_voice(self): + """BUILD_KORBAN verification §1: comprehensive strip test.""" + korban = make_korban(CLAUDE_HEAVY_PREAMBLE_RESPONSE) + result = shechitah(korban) + + # Must not contain assistant voice + assert "Great question" not in result + assert "I'd be happy" not in result + assert "As an AI" not in result + assert "language model" not in result + assert "worth noting" not in result + assert "important to keep in mind" not in result + assert "Let me know" not in result + assert "I hope this helps" not in result + + # Must preserve vital content + assert "בְּרֵאשִׁית" in result + assert "ר-א-ש" in result + assert "Rashi" in result + + +class TestShechitahStateless: + """Kiyyor principle: each call is independent, no state between calls.""" + + def test_two_calls_identical_input_identical_output(self): + korban = make_korban(CLAUDE_HEAVY_PREAMBLE_RESPONSE) + result1 = shechitah(korban) + result2 = shechitah(korban) + assert result1 == result2 + + def test_second_call_unaffected_by_first(self): + k1 = make_korban("Great question! The root ר-א-ש means head. Genesis 1:1.") + k2 = make_korban("Sure! I can explain. Exodus 3:14 reveals Ehyeh asher Ehyeh.") + result1 = shechitah(k1) + result2 = shechitah(k2) + # Each result should only contain content from its own korban + assert "Exodus" not in result1 + assert "Genesis" not in result2 diff --git a/korban_processor/test/test_zerikah.py b/korban_processor/test/test_zerikah.py new file mode 100644 index 00000000..14a6cd43 --- /dev/null +++ b/korban_processor/test/test_zerikah.py @@ -0,0 +1,183 @@ +""" +Tests for zerikah — extract vital content. + +BUILD_KORBAN.md Verification §2: +Feed it a stripped response. Verify it extracts Torah references accurately, +identifies Hebrew roots, and separates vital content from discardable material. + +Architecture §3.5: zerikah extracts Torah references, Hebrew text, root +analysis, novel insights, logical propositions. Records discards. +""" + +from korban_processor.korban import Korban, OfferingType, Provenance +from korban_processor.zerikah import ( + zerikah, + is_vital, + extract_torah_references, + extract_hebrew_words, + extract_roots, + split_sentences, +) +from test.fixtures import CLEAN_TORAH_RESPONSE, LATIN_TORAH_RESPONSE + + +def make_korban(content: str) -> Korban: + return Korban( + raw_content=content, + provenance=Provenance.CLAUDE, + offering_type=OfferingType.OLAH, + source_query="test query", + ) + + +class TestIsVital: + def test_torah_reference_is_vital(self): + assert is_vital("Genesis 1:1 begins the Torah.") + + def test_bereishit_reference_is_vital(self): + assert is_vital("Bereishit 1:1 opens with creation.") + + def test_exodus_reference_is_vital(self): + assert is_vital("Exodus 3:14 reveals the divine name.") + + def test_hebrew_text_is_vital(self): + assert is_vital("The word בְּרֵאשִׁית opens the Torah.") + + def test_root_notation_is_vital(self): + assert is_vital("The root ר-א-ש means head or beginning.") + + def test_rashi_reference_is_vital(self): + assert is_vital("Rashi explains this verse in his commentary.") + + def test_gematria_mention_is_vital(self): + assert is_vital("The gematria of this word is 613.") + + def test_binyan_mention_is_vital(self): + assert is_vital("The verb is in the piel binyan, indicating intensity.") + + def test_means_insight_is_vital(self): + assert is_vital("The root means 'to create from nothing' in this context.") + + def test_indicates_insight_is_vital(self): + assert is_vital("This word indicates a continuous action.") + + def test_generic_filler_not_vital(self): + assert not is_vital("There are many perspectives on this topic.") + + def test_empty_sentence_not_vital(self): + assert not is_vital("") + + def test_whitespace_only_not_vital(self): + assert not is_vital(" ") + + +class TestExtractTorahReferences: + def test_extracts_genesis(self): + refs = extract_torah_references("See Genesis 1:1 and Genesis 2:3.") + assert len(refs) == 2 + assert any("Genesis 1:1" in r for r in refs) + assert any("Genesis 2:3" in r for r in refs) + + def test_extracts_bereishit(self): + refs = extract_torah_references("Bereishit 1:1 is the first verse.") + assert len(refs) == 1 + assert "Bereishit 1:1" in refs[0] + + def test_extracts_exodus(self): + refs = extract_torah_references("Exodus 3:14 reveals Ehyeh asher Ehyeh.") + assert len(refs) >= 1 + + def test_extracts_multiple_books(self): + text = "Genesis 1:1, Exodus 3:14, Leviticus 19:18, Numbers 15:38, Deuteronomy 6:4." + refs = extract_torah_references(text) + assert len(refs) == 5 + + def test_no_refs_returns_empty(self): + refs = extract_torah_references("This is general commentary without citations.") + assert refs == [] + + +class TestExtractHebrewWords: + def test_extracts_hebrew_word(self): + words = extract_hebrew_words("The word בְּרֵאשִׁית opens Torah.") + assert len(words) >= 1 + assert any("בר" in w or "בְּר" in w for w in words) + + def test_extracts_multiple_hebrew_words(self): + words = extract_hebrew_words("בְּרֵאשִׁית בָּרָא אֱלֹהִים") + assert len(words) >= 3 + + def test_no_hebrew_returns_empty(self): + words = extract_hebrew_words("No Hebrew here, only Latin script.") + assert words == [] + + +class TestExtractRoots: + def test_extracts_dash_separated_root(self): + roots = extract_roots("The root ר-א-ש means head.") + assert len(roots) >= 1 + + def test_extracts_dot_separated_root(self): + roots = extract_roots("The root ר.א.ש means head.") + assert len(roots) >= 1 + + def test_no_roots_returns_empty(self): + roots = extract_roots("No roots mentioned here.") + assert roots == [] + + +class TestZerikah: + def test_returns_vital_and_discarded(self): + korban = make_korban(CLEAN_TORAH_RESPONSE) + vital, discarded = zerikah(CLEAN_TORAH_RESPONSE, korban) + assert isinstance(vital, str) + assert isinstance(discarded, list) + + def test_vital_content_contains_torah_refs(self): + """BUILD_KORBAN verification §2: extracts Torah references accurately.""" + korban = make_korban(CLEAN_TORAH_RESPONSE) + vital, _ = zerikah(CLEAN_TORAH_RESPONSE, korban) + assert "Genesis 1:1" in vital or "Bereishit" in vital + + def test_vital_content_contains_hebrew(self): + """BUILD_KORBAN verification §2: identifies Hebrew roots.""" + korban = make_korban(CLEAN_TORAH_RESPONSE) + vital, _ = zerikah(CLEAN_TORAH_RESPONSE, korban) + # The fixture has Hebrew text + assert "בְּרֵאשִׁית" in vital or "ר-א-ש" in vital + + def test_vital_content_contains_rashi(self): + korban = make_korban(CLEAN_TORAH_RESPONSE) + vital, _ = zerikah(CLEAN_TORAH_RESPONSE, korban) + assert "Rashi" in vital + + def test_latin_torah_refs_are_vital(self): + """BUILD_KORBAN verification §2: separates vital from discardable.""" + korban = make_korban(LATIN_TORAH_RESPONSE) + vital, _ = zerikah(LATIN_TORAH_RESPONSE, korban) + assert "Genesis 1:1" in vital + assert "Exodus 3:14" in vital + + def test_empty_content_returns_empty_vital(self): + korban = make_korban(".") + vital, discarded = zerikah("", korban) + assert vital == "" + assert discarded == [] + + def test_all_filler_fallback_preserves_content(self): + """If nothing is vital, zerikah preserves everything rather than discarding all.""" + filler = "There are many perspectives. People disagree. Context matters." + korban = make_korban(filler) + vital, discarded = zerikah(filler, korban) + # Since nothing qualifies as vital, fallback returns everything + assert vital != "" or discarded == [] + + def test_stateless_between_calls(self): + """Kiyyor principle: second call unaffected by first.""" + k1 = make_korban(CLEAN_TORAH_RESPONSE) + k2 = make_korban(LATIN_TORAH_RESPONSE) + vital1a, _ = zerikah(CLEAN_TORAH_RESPONSE, k1) + vital2, _ = zerikah(LATIN_TORAH_RESPONSE, k2) + vital1b, _ = zerikah(CLEAN_TORAH_RESPONSE, k1) + # Same input → same output, regardless of what was processed between + assert vital1a == vital1b