diff --git a/KOHEN.md b/KOHEN.md index ca49e80d..7dc4679c 100644 --- a/KOHEN.md +++ b/KOHEN.md @@ -55,7 +55,7 @@ You inherit infrastructure that has been provisioned and verified: | cert-manager + nginx-ingress | Running. ClusterIssuer `letsencrypt-prod` working. | | Gitea (persistent storage) | Running. OAuth app + PAT exist for the operator's account. | | Gitea MCP server | Running at `https://mcp.shorashim.ch`. Connected to claude.ai. | -| L1 Torah filesystem | In the `shorashim` repo at `l1/torah/`. SHA-256 `f743b34b…3f1494` (full hash in `l1/integrity.sha256`; verified at Day 1). | +| L1 Torah filesystem | In the `shorashim` repo at `l1/torah/`. SHA-256 `9e1abe4e…3c3086` (full hash in `l1/integrity.sha256`; verified at Day 1). | | Image pull secret `shorashim-registry` | **Verify before assuming.** A prior partial setup may have created it. `kubectl get secrets -n shorashim` is the source of truth. | | `shorashim-secrets` (DATABASE_URL) | **Verify before assuming.** Same as above. If present, also verify the role it points at (the architecture requires `shorashim_app`, not `avnadmin`). | | `shorashim-config` ConfigMap | **Verify before assuming.** Runtime config (Shabbat lat/lng, paths, timeouts) — Construction §4.4. | @@ -105,7 +105,7 @@ cd ~/shorashim # or wherever you cloned # 1. The scroll itself hashes to the constant baked into the binaries. # (Until 2026-10-04 this step compared integrity.sha256 with the # constant — two copies of one string. This reads every verse.) -expected="f743b34b83617c328ec190e78ea1402470938e460b9701aec465c3f1613f1494" +expected="9e1abe4edabac825d8a7037606ff247c3e8f8167650f04553f46c34e4f3c3086" python3 scripts/verify_l1.py l1 "$expected" # Expect: computed = recorded = expected, "OK: the scroll matches" diff --git a/KOHEN_PROMPT.md b/KOHEN_PROMPT.md index dd1d40a0..a71cdd4d 100644 --- a/KOHEN_PROMPT.md +++ b/KOHEN_PROMPT.md @@ -77,7 +77,7 @@ test -d ~/shorashim/specs && test -d ~/shorashim/l1/torah && echo "repo OK" # 0d. L1 integrity matches the firmware constant cd ~/shorashim -expected="f743b34b83617c328ec190e78ea1402470938e460b9701aec465c3f1613f1494" +expected="9e1abe4edabac825d8a7037606ff247c3e8f8167650f04553f46c34e4f3c3086" python3 scripts/verify_l1.py l1 "$expected" || echo "L1 FAIL — STOP" # 0e. Specification identity (Doorpost) diff --git a/azazel_processor/src/azazel_processor/goral.py b/azazel_processor/src/azazel_processor/goral.py index 5952a773..46797c75 100644 --- a/azazel_processor/src/azazel_processor/goral.py +++ b/azazel_processor/src/azazel_processor/goral.py @@ -77,7 +77,7 @@ from enum import Enum # the hash changes, this constant updates too. The shared value is # how the goral's seed inherits L1's tamper-resistance. _L1_INTEGRITY_HASH = ( - "f743b34b83617c328ec190e78ea1402470938e460b9701aec465c3f1613f1494" + "9e1abe4edabac825d8a7037606ff247c3e8f8167650f04553f46c34e4f3c3086" ) diff --git a/l1/README.md b/l1/README.md index 220b759c..d2b3346d 100644 --- a/l1/README.md +++ b/l1/README.md @@ -90,7 +90,40 @@ structure, not text. Until 2026-10-04 the builder read past these marks and L1 had no paragraphs at all (broken tablet 39). Adding them added this one field to 686 verse files and changed nothing else; the hash moved from -`156b1d27…` to `f743b34b…`. +`156b1d27…` to `f743b34b…` (and since, with the inverted nuns, to +`9e1abe4e…`). + +## Inverted Nuns + +Two verses carry `inverted_nuns`: + +```json +"inverted_nuns": [ { "after_word": 7 } ] +``` + +The *nun hafukha* (׆) stands after Bamidbar 10:34 and after 10:36, +marking off *Vayehi binsoa ha'aron* (10:35–36) above and below — "because +it is a book of its own" (Shabbat 115b–116a). The WLC writes the sign +after the verse's sof pasuq, before the setumah; like a paragraph break +it is recorded by the word it follows, and it is not part of +`text_hebrew`. Added 2026-10-04 (broken tablet 40); the hash moved from +`f743b34b…` to `9e1abe4e…`. + +## Large and Small Letters + +A word with an enlarged letter carries `large_letters`. The WLC marks +four in Torah, and L1 has those four: the vav of גחון (Vayikra 11:42), +the nun of משפטן (Bamidbar 27:5), and the ayin and dalet of the Shema +(Devarim 6:4). + +**L1 records no small letters, because its source marks none.** The WLC +as the Open Scriptures Hebrew Bible carries it has no small-letter mark +anywhere in the five books — not the aleph of ויקרא (Vayikra 1:1), not +the he of בהבראם (Bereishit 2:4). The builder reads `x-small` and would +record one if the source had it. The enlarged and reduced letters of the +later Masoretic lists (the bet of בראשית among them) are a tradition of +the scroll that this edition of the Leningrad Codex does not encode; +adding them would mean engraving something the source does not say. ## Word Fields diff --git a/l1/build_report.json b/l1/build_report.json index 5a08e8fd..2474db8b 100644 --- a/l1/build_report.json +++ b/l1/build_report.json @@ -9,6 +9,7 @@ "petuchah": 293, "setumah": 394 }, + "inverted_nuns": 2, "words_with_shoresh": 57487, "shoresh_provenance": { "main": 23750, @@ -18,7 +19,7 @@ "none": 3002, "unlisted": 0 }, - "sha256": "f743b34b83617c328ec190e78ea1402470938e460b9701aec465c3f1613f1494", + "sha256": "9e1abe4edabac825d8a7037606ff247c3e8f8167650f04553f46c34e4f3c3086", "missing_teamim_refs": [], "words_without_lexicon_root": [ { diff --git a/l1/integrity.sha256 b/l1/integrity.sha256 index f62ce138..9fb75abd 100644 --- a/l1/integrity.sha256 +++ b/l1/integrity.sha256 @@ -1 +1 @@ -f743b34b83617c328ec190e78ea1402470938e460b9701aec465c3f1613f1494 \ No newline at end of file +9e1abe4edabac825d8a7037606ff247c3e8f8167650f04553f46c34e4f3c3086 \ No newline at end of file diff --git a/l1/torah/bamidbar/10/34.json b/l1/torah/bamidbar/10/34.json index 8492f465..2c4553e4 100644 --- a/l1/torah/bamidbar/10/34.json +++ b/l1/torah/bamidbar/10/34.json @@ -97,5 +97,10 @@ "after_word": 7, "type": "setumah" } + ], + "inverted_nuns": [ + { + "after_word": 7 + } ] } \ No newline at end of file diff --git a/l1/torah/bamidbar/10/36.json b/l1/torah/bamidbar/10/36.json index b61ebf63..433592fd 100644 --- a/l1/torah/bamidbar/10/36.json +++ b/l1/torah/bamidbar/10/36.json @@ -106,5 +106,10 @@ "after_word": 7, "type": "setumah" } + ], + "inverted_nuns": [ + { + "after_word": 7 + } ] } \ No newline at end of file diff --git a/scripts/build_l1.py b/scripts/build_l1.py index bec03565..3c6446c2 100644 --- a/scripts/build_l1.py +++ b/scripts/build_l1.py @@ -421,6 +421,7 @@ def parse_verse(verse_elem, book_osis: str, book_hebrew: str) -> dict: word_position = 0 ketiv_qere_entries = [] parashah_breaks = [] + inverted_nuns = [] children = list(verse_elem) i = 0 @@ -477,6 +478,16 @@ def parse_verse(verse_elem, book_osis: str, book_hebrew: str) -> dict: "after_word": word_position, "type": PARASHAH_BREAKS[seg_type], }) + elif seg_type == "x-reversednun": + # Nun hafukha (׆). Two in Torah, bracketing "Vayehi + # binsoa ha'aron" (Bamidbar 10:35–36): one after 10:34 + # and one after 10:36. Shabbat 115b–116a: the passage is + # marked off above and below because it is a book of its + # own. The WLC writes the sign after the verse's sof + # pasuq; it is recorded, like a paragraph break, by the + # word it follows. Until tablet 40 the builder read past + # it. + inverted_nuns.append({"after_word": word_position}) i += 1 @@ -507,6 +518,8 @@ def parse_verse(verse_elem, book_osis: str, book_hebrew: str) -> dict: verse_out["smikhut"] = smikhut if parashah_breaks: verse_out["parashah_breaks"] = parashah_breaks + if inverted_nuns: + verse_out["inverted_nuns"] = inverted_nuns return verse_out @@ -730,6 +743,7 @@ def build_l1(morphhb_dir: str, output_dir: str, lexicon_dir: str): total_verses += 1 for brk in verse_data.get("parashah_breaks", []): parashah_counts[brk["type"]] += 1 + parashah_counts["inverted_nun"] += len(verse_data.get("inverted_nuns", [])) # Count words and collect stats for word in verse_data["words"]: @@ -802,6 +816,7 @@ def build_l1(morphhb_dir: str, output_dir: str, lexicon_dir: str): print(f"Verses missing te'amim: {len(missing_teamim)}") print(f"Parashah breaks: {parashah_counts['petuchah']} petuchot, " f"{parashah_counts['setumah']} setumot") + print(f"Inverted nuns: {parashah_counts['inverted_nun']}") words_with_shoresh = sum(_SHORESH_STATS[k] for k in ("main", "sub", "single")) rootless = sorted(_ROOTLESS.items(), key=lambda kv: (-kv[1], kv[0])) print(f"Words with a shoresh: {words_with_shoresh}") @@ -840,6 +855,7 @@ def build_l1(morphhb_dir: str, output_dir: str, lexicon_dir: str): "petuchah": parashah_counts["petuchah"], "setumah": parashah_counts["setumah"], }, + "inverted_nuns": parashah_counts["inverted_nun"], "words_with_shoresh": words_with_shoresh, "shoresh_provenance": {k: _SHORESH_STATS[k] for k in ("main", "sub", "single", "particle", "none", "unlisted")}, diff --git a/shoresh-engine/src/Luchot/Firmware.hs b/shoresh-engine/src/Luchot/Firmware.hs index 2d0826f7..a8c167bb 100644 --- a/shoresh-engine/src/Luchot/Firmware.hs +++ b/shoresh-engine/src/Luchot/Firmware.hs @@ -30,7 +30,7 @@ import Data.Text (Text) -- From build_report.json and verified during L1 build. -- If the hash does not match, the specification is compromised. l1ExpectedHash :: Text -l1ExpectedHash = "f743b34b83617c328ec190e78ea1402470938e460b9701aec465c3f1613f1494" +l1ExpectedHash = "9e1abe4edabac825d8a7037606ff247c3e8f8167650f04553f46c34e4f3c3086" -- | Torah book count. Five books. Not four, not six. torahBookCount :: Int diff --git a/shoresh-engine/test/Spec.hs b/shoresh-engine/test/Spec.hs index d9423a60..d0316427 100644 --- a/shoresh-engine/test/Spec.hs +++ b/shoresh-engine/test/Spec.hs @@ -314,7 +314,7 @@ firmwareSpec :: Spec firmwareSpec = describe "Firmware" $ do it "has correct L1 hash" $ l1ExpectedHash `shouldBe` - "f743b34b83617c328ec190e78ea1402470938e460b9701aec465c3f1613f1494" + "9e1abe4edabac825d8a7037606ff247c3e8f8167650f04553f46c34e4f3c3086" it "has correct counts" $ do torahBookCount `shouldBe` 5 diff --git a/specs/shorashim_terumah.md b/specs/shorashim_terumah.md index 11d375ce..26ee4a77 100644 --- a/specs/shorashim_terumah.md +++ b/specs/shorashim_terumah.md @@ -121,6 +121,8 @@ Source: Westminster Leningrad Codex (WLC) 4.20, as carried by the Open Scripture | Full Torah text — voweled (nikkud) | ✅ | WLC via OSHB morphhb. | | Cantillation marks (raw Unicode codepoints U+0591–U+05AF) | ✅ | Present in WLC data. Stored as raw marks on the text, NOT pre-parsed into trees. The system reads them in the act of reading. | | Petuchah/Stumah paragraph markers | ✅ | In L1: 293 petuchot and 394 setumot, read from the WLC's `פ` / `ס` marks (OSIS ``, `x-samekh`) and stored per verse as `parashah_breaks`. | +| Inverted nuns (nun hafukha) | ✅ | In L1: the two signs after Bamidbar 10:34 and 10:36, from the WLC's own marks, stored per verse as `inverted_nuns`. | +| Large and small letters | ⚠️ | The WLC source marks four large letters in Torah (in L1 as `large_letters`) and NO small letters. The fuller Masoretic lists of enlarged and reduced letters are not in the source and not in L1. | | Ketiv/qere annotations | ✅ | WLC annotations: 67 pairs in 64 verses. Stored per verse as `{word_position, ketiv, ketiv_consonants, qere, qere_consonants}` — both readings preserved, never collapsed. | | Gematria (root → numerical value) | ✅ | Not stored in L1: computed from the consonants when read. Letter values fixed. No equivalence index is built. | | Smikhut compound index (attested construct-state pairs) | ✅ | Built: 9,203 pairs in 4,106 verses, extracted by `scripts/build_l1.py` from the morphology where a word is marked construct. | diff --git a/specs/shorashim_vayakhel.md b/specs/shorashim_vayakhel.md index 542389f2..df87c0d5 100644 --- a/specs/shorashim_vayakhel.md +++ b/specs/shorashim_vayakhel.md @@ -45,14 +45,15 @@ The specification that sits inside the aron must be specified with the same prec | **Source — roots** | `openscriptures/HebrewLexicon` at commit `21c9add` (`LexicalIndex.xml`, `AugIndex.xml`): each lemma filed under its Brown-Driver-Briggs root. BDB and Strong's public domain; the index CC BY 4.0 | | **Builder** | `scripts/build_l1.py`. Deterministic: the same two checkouts give the same bytes and the same hash. Both commits are recorded in `l1/build_report.json` | | **Extent** | 5 books, 187 chapters, 5,853 verses (WLC/BHS versification; the traditional count is 5,845), 79,982 words | -| **Format** | One JSON file per verse, `l1/torah/{book}/{chapter}/{verse}.json`: `{ "ref": "Bereishit 1:1", "book": "bereishit", "chapter": 1, "verse": 1, "text_hebrew": "...", "words": [...] }`, with `"ketiv_qere": [...]`, `"smikhut": [...]` and `"parashah_breaks": [...]` where the verse has them | -| **Word** | `{ "position": 1, "text": "בְּרֵאשִׁ֖ית", "consonants": "בראשית", "lemma": "7225", "morph": "HR/Ncfsa", "teamim": ["tipcha"], "prefix": "ב", "shoresh": "ראש" }`. `prefix` and `shoresh` are present only where the word has them; `large_letters` marks an enlarged letter (4 words) | +| **Format** | One JSON file per verse, `l1/torah/{book}/{chapter}/{verse}.json`: `{ "ref": "Bereishit 1:1", "book": "bereishit", "chapter": 1, "verse": 1, "text_hebrew": "...", "words": [...] }`, with `"ketiv_qere": [...]`, `"smikhut": [...]`, `"parashah_breaks": [...]` and `"inverted_nuns": [...]` where the verse has them | +| **Word** | `{ "position": 1, "text": "בְּרֵאשִׁ֖ית", "consonants": "בראשית", "lemma": "7225", "morph": "HR/Ncfsa", "teamim": ["tipcha"], "prefix": "ב", "shoresh": "ראש" }`. `prefix` and `shoresh` are present only where the word has them; `large_letters` marks an enlarged letter (4 words — all the source marks). No small letters are recorded: the source marks none in the five books | | **Integrity** | SHA-256 over every file of the L1 tree (path, then bytes), recorded at build time in `integrity.sha256`. The pipeline re-reads and re-hashes the tree at startup and every ten minutes | | **Cantillation marks** | Kept on the text as the Unicode codepoints the WLC carries (U+0591–U+05AF), and named per word in `teamim`. The parse tree is NOT pre-computed: the reader builds it from the marks | | **Ketiv/qere** | From the WLC's ketiv/qere annotations: 67 pairs in 64 verses, stored as `{ "word_position": 6, "ketiv": "...", "ketiv_consonants": "...", "qere": "...", "qere_consonants": "..." }` | | **Shoresh** | Read from the lexicon by the word's lemma — never derived from the consonants. 57,487 words carry one; 1,378 distinct roots. Particles and pronouns carry none, by rule; names the lexicon gives no etymology carry none | | **Gematria** | Not stored. Computed from the consonants when read (engine and pipeline) | | **Petuchah / setumah** | From the WLC's own `פ` / `ס` marks: 293 petuchot and 394 setumot in 686 verses, stored as `"parashah_breaks": [{ "after_word": 13, "type": "petuchah" }]`. The break is recorded by the word it follows; four fall inside a verse. These are the Leningrad Codex's divisions | +| **Inverted nuns** | The two *nun hafukha* signs, after Bamidbar 10:34 and after 10:36, from the WLC's own marks: `"inverted_nuns": [{ "after_word": 7 }]` | | **Smikhut compounds** | From the morphology, where a word is marked construct: 9,203 attested pairs in 4,106 verses, stored as `{ "position": 5, "construct": "...", "construct_consonants": "...", "absolute": "...", "absolute_consonants": "..." }` | **Verification procedure:** diff --git a/vav_pipeline/lib/vav_pipeline/doorpost.ex b/vav_pipeline/lib/vav_pipeline/doorpost.ex index 7bc6fd13..d45bb9c1 100644 --- a/vav_pipeline/lib/vav_pipeline/doorpost.ex +++ b/vav_pipeline/lib/vav_pipeline/doorpost.ex @@ -175,7 +175,7 @@ defmodule VavPipeline.Doorpost do # changes; the update is a deliberate operator commit. A bare # hash-update commit (without a corresponding spec change in the # same commit) is itself a drift signal. - @expected_hash "b62311e7541277f3881f074e2e884b742f6f85bd2de8b4890f44e978d06a3f21" + @expected_hash "3bc5dd6aa4d76eeb8c0bc2ee4d7d33b7f35bc1dc8e03dc76697acc33eec8e245" @doc "Return the list of files that constitute the category-grade specification." @spec files() :: [String.t()] diff --git a/vav_pipeline/lib/vav_pipeline/l1_reader.ex b/vav_pipeline/lib/vav_pipeline/l1_reader.ex index 2f851585..248710f4 100644 --- a/vav_pipeline/lib/vav_pipeline/l1_reader.ex +++ b/vav_pipeline/lib/vav_pipeline/l1_reader.ex @@ -17,7 +17,7 @@ defmodule VavPipeline.L1Reader do # Updated after build_l1.py fix: enlarged letters (Shema's ayin/dalet, gachon's # vav, mishpatan's nun), ketiv/qere pairs, and smikhut construct pairs are now # preserved in L1 rather than silently dropped. - @l1_expected_hash "f743b34b83617c328ec190e78ea1402470938e460b9701aec465c3f1613f1494" + @l1_expected_hash "9e1abe4edabac825d8a7037606ff247c3e8f8167650f04553f46c34e4f3c3086" @type letter_mark :: %{letter: String.t(), offset: non_neg_integer()} @@ -64,7 +64,8 @@ defmodule VavPipeline.L1Reader do words: [word()], ketiv_qere: [ketiv_qere_entry()], smikhut: [smikhut_pair()], - parashah_breaks: [parashah_break()] + parashah_breaks: [parashah_break()], + inverted_nuns: [%{after_word: pos_integer()}] } @doc """ @@ -386,7 +387,13 @@ defmodule VavPipeline.L1Reader do words: Enum.map(data["words"] || [], &normalize_word/1), ketiv_qere: Enum.map(data["ketiv_qere"] || [], &normalize_ketiv_qere/1), smikhut: Enum.map(data["smikhut"] || [], &normalize_smikhut/1), - parashah_breaks: Enum.flat_map(data["parashah_breaks"] || [], &normalize_parashah_break/1) + parashah_breaks: Enum.flat_map(data["parashah_breaks"] || [], &normalize_parashah_break/1), + # Nun hafukha (׆) — recorded, like a paragraph break, by the word it + # follows. Two in Torah: after Bamidbar 10:34 and after 10:36. + inverted_nuns: + for %{"after_word" => n} when is_integer(n) <- data["inverted_nuns"] || [] do + %{after_word: n} + end } end diff --git a/vav_pipeline/test/shema_voice_trajectory_test.exs b/vav_pipeline/test/shema_voice_trajectory_test.exs index 66d76d8e..f02c8925 100644 --- a/vav_pipeline/test/shema_voice_trajectory_test.exs +++ b/vav_pipeline/test/shema_voice_trajectory_test.exs @@ -20,7 +20,7 @@ defmodule VavPipeline.ShemaVoiceTrajectoryTest do stabilizing at one pole — which is the algorithmic shape of "you have forgotten the One who took you out of Egypt". - Anchoring verses (text from L1, hash f743b34b…): + Anchoring verses (text from L1, hash 9e1abe4e…): Devarim 6:5 וְאָהַבְתָּ … אֱלֹהֶיךָ … לְבָבְךָ — uniformly 2ms Devarim 11:13 וְהָיָה אִם־שָׁמֹעַ תִּשְׁמְעוּ … אֱלֹהֵיכֶם — 2mp Devarim 11:14 אַרְצְכֶם (2mp) → וְאָסַפְתָּ דְגָנֶךָ (2ms) diff --git a/vav_pipeline/test/tablet40_inverted_nuns_test.exs b/vav_pipeline/test/tablet40_inverted_nuns_test.exs new file mode 100644 index 00000000..03849201 --- /dev/null +++ b/vav_pipeline/test/tablet40_inverted_nuns_test.exs @@ -0,0 +1,55 @@ +defmodule VavPipeline.Tablet40InvertedNunsTest do + @moduledoc """ + Tablet 40 (2026-10-04) — the inverted nuns. + + *Vayehi binsoa ha'aron* (Bamidbar 10:35–36) is marked off in the scroll + by a nun hafukha before it and after it. The WLC carries both signs; the + builder read past them. They are now engraved on the verse each one + follows — 10:34 and 10:36 — and the reader carries them. + """ + + use ExUnit.Case, async: true + + alias VavPipeline.L1Reader + + test "an inverted nun follows Bamidbar 10:34 and another follows 10:36" do + for verse <- [34, 36] do + {:ok, v} = L1Reader.read_verse("bamidbar", 10, verse) + assert v.inverted_nuns == [%{after_word: length(v.words)}], "Bamidbar 10:#{verse}" + end + end + + test "the two verses between them carry none — the signs bracket the passage" do + {:ok, v35} = L1Reader.read_verse("bamidbar", 10, 35) + assert v35.inverted_nuns == [] + assert hd(v35.words).consonants == "ויהי" + assert Enum.at(v35.words, 1).consonants == "בנסע" + end + + test "each nun stands with a setumah, and neither is in the verse's text" do + for verse <- [34, 36] do + {:ok, v} = L1Reader.read_verse("bamidbar", 10, verse) + assert v.parashah_breaks == [%{after_word: length(v.words), type: :setumah}] + refute v.text_hebrew =~ "׆" + end + end + + test "there are exactly two in the whole scroll" do + index = L1Reader.l1_root() |> Path.join("torah/index.json") |> File.read!() |> Jason.decode!() + + refs = + for book <- index["books"], + {chapter, verses} <- book["chapter_verse_counts"], + verse <- 1..verses, + {:ok, v} = L1Reader.read_verse(book["book"], String.to_integer(chapter), verse), + v.inverted_nuns != [], + do: v.ref + + assert Enum.sort(refs) == ["Bamidbar 10:34", "Bamidbar 10:36"] + end + + test "every other verse carries an empty list, not a missing field" do + {:ok, v} = L1Reader.read_verse("bereishit", 1, 1) + assert v.inverted_nuns == [] + end +end