Phase 1-2 build: Steps 5-6, 8-13

Shoresh Engine (Haskell, Step 5)
- 12 modules: Types, Lexicon, Binyanim, Teamim, Gematria, Graph,
  KetivQere, Smikhut, Havdalah, Kriah, KiTov, Firmware
- 33 EUnit tests, 0 failures
- Firmware module verifies L1 SHA-256 hash at startup

Talmudic Engine (SWI-Prolog, Step 6)
- 15 modules: halt, reasoning, specification, interface
- 57 PlUnit tests, 0 failures
- Three cardinal halt conditions: avodah_zarah, shfichut_damim, gilui_arayot

Vav Pipeline (Elixir, Steps 8-11)
- 14 modules: BereishitCycle, Shema, Cloud, Manna, L1Reader,
  Specification, HaltConditions, ShabbatTimer, CycleLog, Chatzotzrot,
  ShoreshEngineClient, TalmudicEngineClient, Application
- 46 ExUnit tests, 0 failures
- Nishma-first architecture: hears before it speaks

Sanhedrin Framework (Erlang/OTP, Step 12)
- 8 modules: sanhedrin_app, sanhedrin_sup, escalation, panel_sup,
  panel, judge, clerk, halt_monitor
- 46 EUnit tests, 0 failures (includes fix: rejected outcome now reachable)
- Panel sizes: 1/3/23/71 — unanimous capital guilty → acquit (Sanhedrin 17a)
- Minority opinions append-only, never deleted

Korban Processor (Python, Step 13)
- 6 modules: korban, shechitah, semichah, zerikah, haktarah, router

Builder tooling: AGENTS.md, opencode.json, memory/, src/tools/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott KarpandClaude Sonnet 4.6 committed 2026-02-26 14:02:34 -05:00
1 parent 622e814c9e
commit 80ec364da6
86 files changed
+9646

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
{-# LANGUAGE OverloadedStrings #-}
-- | Binyanim — the seven transformation operators.
--
-- Each binyan transforms a root's meaning systematically:
-- Qal = simple active ("he guarded")
-- Nifal = passive/reflexive ("he was guarded")
-- Piel = intensive active ("he guarded carefully")
-- Pual = intensive passive ("he was guarded carefully")
-- Hifil = causative active ("he caused to guard")
-- Hofal = causative passive ("he was caused to guard")
-- Hitpael = reflexive/reciprocal ("he guarded himself")
--
-- Architecture §3.1: activate :: Binyan -> Shoresh -> Peulah
module Shoresh.Binyanim
( activate
, activateDefault
, expand
, binyanGloss
) where
import Shoresh.Types
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
-- | Apply a binyan to a root — the fundamental operation.
-- Returns a fully specified Peulah with the given grammatical features.
activate :: Binyan -> Person -> Number -> Gender -> Aspect -> Shoresh -> Peulah
activate b p n g a sh = Peulah
{ root = sh
, binyan = b
, person = p
, number = n
, gender = g
, aspect = a
}
-- | Activate with default grammatical features (3ms perfect).
-- The citation form — how a root is listed in the dictionary.
activateDefault :: Binyan -> Shoresh -> Peulah
activateDefault b = activate b Third Singular Masculine Perfect
-- | Trace a root through all 7 binyanim — full semantic expansion.
-- Returns the default (3ms perfect) form in each binyan.
expand :: Shoresh -> Map Binyan Peulah
expand sh = Map.fromList
[ (b, activateDefault b sh) | b <- allBinyanim ]
-- | Generate a gloss (meaning description) for a binyan transformation.
-- This is a structural gloss — it describes what the binyan DOES to meaning,
-- not what a specific root means in that binyan.
binyanGloss :: Binyan -> Text -> Text
binyanGloss b baseGloss = case b of
Qal -> baseGloss
Nifal -> "was " <> baseGloss <> " / " <> baseGloss <> "ed itself"
Piel -> baseGloss <> " intensively"
Pual -> "was " <> baseGloss <> " intensively"
Hifil -> "caused to " <> baseGloss
Hofal -> "was caused to " <> baseGloss
Hitpael -> baseGloss <> "ed oneself / " <> baseGloss <> " each other"
+86
View File
@@ -0,0 +1,86 @@
{-# LANGUAGE OverloadedStrings #-}
-- | Gematria — the numerical layer of Hebrew.
--
-- Letter values are inherent in the writing system.
-- This is a lookup table, not a computation.
-- Aleph = 1, Bet = 2, ... Tav = 400.
--
-- Architecture §3.1: "letterValue is a lookup, not a computation."
module Shoresh.Gematria
( letterValue
, gematriaRoot
, gematriaWord
, gematriaChar
, gematriaNeighbors
, gematriaConnections
) where
import Shoresh.Types
import Data.Text (Text)
import qualified Data.Text as T
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
-- | The numerical value of a Hebrew consonant.
-- Standard gematria: units (1-9), tens (10-90), hundreds (100-400).
letterValue :: Consonant -> Int
letterValue c = case c of
Aleph -> 1
Bet -> 2
Gimel -> 3
Dalet -> 4
He -> 5
Vav -> 6
Zayin -> 7
Chet -> 8
Tet -> 9
Yod -> 10
Kaf -> 20
Lamed -> 30
Mem -> 40
Nun -> 50
Samekh -> 60
Ayin -> 70
Pe -> 80
Tsade -> 90
Qof -> 100
Resh -> 200
Shin -> 300
Tav -> 400
-- | Gematria of a triliteral root.
gematriaRoot :: Shoresh -> Int
gematriaRoot (Shoresh a b d) = letterValue a + letterValue b + letterValue d
-- | Gematria of a single Hebrew character.
-- Returns 0 for non-consonant characters (vowels, cantillation marks).
-- Final forms map to the same value as regular forms.
gematriaChar :: Char -> Int
gematriaChar ch = case charToConsonant ch of
Just c -> letterValue c
Nothing -> 0
-- | Gematria of a full Hebrew word (text with possible vowels/marks).
-- Strips everything that is not a consonant.
gematriaWord :: Text -> Int
gematriaWord = T.foldl' (\acc ch -> acc + gematriaChar ch) 0
-- | Find all roots in a lexicon with matching numerical value.
gematriaNeighbors :: Int -> Map Shoresh ShoreshEntry -> [ShoreshEntry]
gematriaNeighbors val lexicon =
[ entry
| (sh, entry) <- Map.toList lexicon
, gematriaRoot sh == val
]
-- | Discover hidden connections: all roots sharing a gematria value
-- with the given root.
gematriaConnections :: Shoresh -> Map Shoresh ShoreshEntry -> [(Shoresh, Int)]
gematriaConnections sh lexicon =
let val = gematriaRoot sh
in [ (sh', val)
| sh' <- Map.keys lexicon
, sh' /= sh
, gematriaRoot sh' == val
]
+49
View File
@@ -0,0 +1,49 @@
{-# LANGUAGE OverloadedStrings #-}
-- | Graph — root connectivity.
--
-- Architecture §3.1:
-- neighbors :: Shoresh -> ShoreshLexicon -> [ShoreshEntry]
-- "Find all roots sharing 2 consonants (semantic neighborhood)"
--
-- Two roots are neighbors if they share 2 of 3 consonants
-- in the same positions. This captures the Hebrew linguistic
-- phenomenon where related roots share consonants.
module Shoresh.Graph
( neighbors
, sharedConsonants
, neighborsByPosition
) where
import Shoresh.Types
import Shoresh.Lexicon (ShoreshLexicon, allRoots, lookupRoot)
import Data.Maybe (mapMaybe)
-- | Count shared consonants between two roots (position-sensitive).
sharedConsonants :: Shoresh -> Shoresh -> Int
sharedConsonants (Shoresh a1 b1 c1') (Shoresh a2 b2 c2') =
(if a1 == a2 then 1 else 0) +
(if b1 == b2 then 1 else 0) +
(if c1' == c2' then 1 else 0)
-- | Find all roots sharing exactly 2 consonants in the same positions.
neighbors :: Shoresh -> ShoreshLexicon -> [ShoreshEntry]
neighbors sh lexicon =
mapMaybe (\sh' ->
if sh' /= sh && sharedConsonants sh sh' >= 2
then lookupRoot sh' lexicon
else Nothing
) (allRoots lexicon)
-- | Find neighbors that share consonants at specific positions.
-- Returns (position_shared, neighbor_entry) pairs.
neighborsByPosition :: Shoresh -> ShoreshLexicon -> [(String, ShoreshEntry)]
neighborsByPosition sh@(Shoresh a b d) lexicon =
concatMap checkNeighbor (allRoots lexicon)
where
checkNeighbor sh'@(Shoresh a' b' d')
| sh' == sh = []
| a == a' && b == b' = maybe [] (\e -> [("c1c2", e)]) (lookupRoot sh' lexicon)
| a == a' && d == d' = maybe [] (\e -> [("c1c3", e)]) (lookupRoot sh' lexicon)
| b == b' && d == d' = maybe [] (\e -> [("c2c3", e)]) (lookupRoot sh' lexicon)
| otherwise = []
+53
View File
@@ -0,0 +1,53 @@
{-# LANGUAGE OverloadedStrings #-}
-- | Havdalah — separation and distinction.
--
-- Architecture §3.1:
-- havdalah :: Input -> [(Category, Content)]
-- "Separate an input into distinguished categories"
--
-- Havdalah is the act of making distinctions. In the processing
-- pipeline, it takes the output of vayomer (structured symbolic output)
-- and separates it into categories that can be named (kriah).
--
-- Te'amim inform the separation — the text's own divisions guide havdalah.
module Shoresh.Havdalah
( havdalah
, separateByDomain
, separateByTeamim
) where
import Shoresh.Types
import Shoresh.Teamim (ParsedVerse(..), AnnotatedWord(..), primaryDivision)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
-- | Separate input into distinguished categories.
-- Uses both semantic domain analysis and te'amim structure.
havdalah :: [(Shoresh, SemanticDomain)] -> [(Text, [(Shoresh, SemanticDomain)])]
havdalah items =
let grouped = Map.toList $ Map.fromListWith (++)
[ (domainName d, [(sh, d)]) | (sh, d) <- items ]
in grouped
where
domainName :: SemanticDomain -> Text
domainName d = T.pack (show d)
-- | Separate roots by their semantic domain.
separateByDomain :: [ShoreshEntry] -> Map SemanticDomain [ShoreshEntry]
separateByDomain entries =
Map.fromListWith (++) [ (sePrimaryDomain e, [e]) | e <- entries ]
-- | Separate using te'amim structure.
-- The te'amim parse tree tells us where the text itself
-- draws its divisions. Havdalah follows the text's own structure.
separateByTeamim :: ParsedVerse -> ([Text], [Text])
separateByTeamim verse =
let (left, right) = primaryDivision verse
in (collectWords left, collectWords right)
where
collectWords :: ParsedVerse -> [Text]
collectWords (Leaf w) = [awWord w]
collectWords (Branch _ l r) = collectWords l ++ collectWords r
+50
View File
@@ -0,0 +1,50 @@
{-# LANGUAGE OverloadedStrings #-}
-- | Ketiv/Qere — the dual reading.
--
-- Architecture §3.1:
-- "A specification position that holds two authoritative values."
-- Ketiv = what is written (stored form).
-- Qere = what is read (executed form).
--
-- At no point in the processing chain should a dual reading
-- collapse to a single value. Both readings are preserved.
module Shoresh.KetivQere
( DualReading(..)
, SpecValue(..)
, bothReadings
, isDual
, hasDualReadings
) where
import Shoresh.Types (Reference)
import Data.Text (Text)
-- | A position in the specification that holds two authoritative values.
data DualReading = DualReading
{ ketiv :: !Text -- ^ What is written (stored form)
, qere :: !Text -- ^ What is read (executed form)
, drRef :: !Reference -- ^ Where in Torah this occurs
} deriving (Eq, Show)
-- | A specification value: either single or dual.
data SpecValue
= Single !Text
| DualVal !DualReading
deriving (Show)
-- | Retrieve both readings without collapsing.
-- Single values return the same text for both.
-- Dual values return (ketiv, qere).
bothReadings :: SpecValue -> (Text, Text)
bothReadings (Single t) = (t, t)
bothReadings (DualVal dr) = (ketiv dr, qere dr)
-- | Check if a SpecValue is a dual reading.
isDual :: SpecValue -> Bool
isDual (Single _) = False
isDual (DualVal _) = True
-- | Check if a list of spec values contains any dual readings.
hasDualReadings :: [SpecValue] -> Bool
hasDualReadings = any isDual
+64
View File
@@ -0,0 +1,64 @@
{-# LANGUAGE OverloadedStrings #-}
-- | KiTov — fitness evaluation.
--
-- Architecture §3.1:
-- kiTov :: ProcessedResult -> Specification -> Evaluation
-- data Evaluation = Tov | LoTov Text | Teiku
--
-- Evaluates whether processed output fits the specification.
-- "And God saw that it was good" — the evaluation against the source.
--
-- Ki tov is not approval. It is verification against specification.
-- The specification is Torah (L1). The evaluation checks:
-- 1. Are the roots real? (found in L1)
-- 2. Are the references valid? (point to existing verses)
-- 3. Are dual readings preserved? (ketiv/qere not collapsed)
module Shoresh.KiTov
( kiTov
, verifyRootsExist
, verifyReferences
) where
import Shoresh.Types
import Shoresh.Lexicon (ShoreshLexicon, lookupRoot)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Maybe (isNothing)
-- | Evaluate a processed result against the specification (lexicon).
kiTov :: ProcessedResult -> ShoreshLexicon -> Evaluation
kiTov result lexicon =
let rootCheck = verifyRootsExist (prRoots result) lexicon
refCheck = verifyReferences (prRoots result)
in case (rootCheck, refCheck) of
(Tov, Tov) -> Tov
(LoTov r, _) -> LoTov r
(_, LoTov r) -> LoTov r
(Teiku, _) -> Teiku
(_, Teiku) -> Teiku
-- | Verify that all roots in the result exist in the lexicon.
verifyRootsExist :: [ShoreshEntry] -> ShoreshLexicon -> Evaluation
verifyRootsExist entries lexicon =
let missing = [ seShoresh e | e <- entries
, isNothing (lookupRoot (seShoresh e) lexicon) ]
in case missing of
[] -> Tov
ms -> LoTov $ "Roots not found in lexicon: "
<> T.intercalate ", " (map shoreshToText ms)
-- | Verify that Torah references in the entries are structurally valid.
-- (Range checks — the full check against L1 files happens at the pipeline level.)
verifyReferences :: [ShoreshEntry] -> Evaluation
verifyReferences entries =
let invalid = concatMap checkEntry entries
in case invalid of
[] -> Tov
msgs -> LoTov $ T.intercalate "; " msgs
where
checkEntry entry = concatMap checkRef (seTorahOccurrences entry)
checkRef ref
| refChapter ref < 1 = ["Invalid chapter: " <> T.pack (show (refChapter ref))]
| refVerse ref < 1 = ["Invalid verse: " <> T.pack (show (refVerse ref))]
| otherwise = []
+45
View File
@@ -0,0 +1,45 @@
{-# LANGUAGE OverloadedStrings #-}
-- | Kriah — naming and identification.
--
-- Architecture §3.1:
-- kriah :: [(Category, Content)] -> [(Name, Category, Content)]
-- "Assign stable names to distinguished categories"
--
-- After havdalah separates, kriah names what was separated.
-- The names come from the roots themselves — a category's name
-- is derived from the dominant root or domain within it.
module Shoresh.Kriah
( kriah
, nameCategory
, NamedCategory(..)
) where
import Shoresh.Types
import Data.Text (Text)
import qualified Data.Text as T
-- | A named category — the result of kriah.
data NamedCategory = NamedCategory
{ ncName :: !Text
, ncDomain :: !SemanticDomain
, ncEntries :: ![ShoreshEntry]
} deriving (Show)
-- | Assign stable names to distinguished categories.
kriah :: [(SemanticDomain, [ShoreshEntry])] -> [NamedCategory]
kriah = map nameCategory
-- | Name a single category from its domain and entries.
-- The name is the domain name plus the most frequent root.
nameCategory :: (SemanticDomain, [ShoreshEntry]) -> NamedCategory
nameCategory (domain, entries) =
let domName = T.pack (show domain)
rootSuffix = case entries of
(e:_) -> " (" <> shoreshToText (seShoresh e) <> ")"
[] -> ""
in NamedCategory
{ ncName = domName <> rootSuffix
, ncDomain = domain
, ncEntries = entries
}
+147
View File
@@ -0,0 +1,147 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DeriveGeneric #-}
-- | Lexicon — the root lexicon, loaded from L1.
--
-- L1 carries the Torah text with shoresh fields on each word.
-- This module reads L1 to build the root index:
-- which roots appear, where they appear, how often.
--
-- The lexicon is read from L1. It is not hardcoded.
-- The system reads Torah and learns what roots are there.
module Shoresh.Lexicon
( ShoreshLexicon
, emptyLexicon
, insertEntry
, lookupRoot
, allRoots
, lexiconSize
-- * L1 loading
, L1Word(..)
, L1Verse(..)
, parseL1Verse
, buildLexiconFromVerses
, loadL1Verse
) where
import Shoresh.Types
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.ByteString.Lazy as BL
import Data.Aeson
import GHC.Generics (Generic)
-- | The root lexicon — a map from Shoresh to its entry.
type ShoreshLexicon = Map Shoresh ShoreshEntry
-- | Empty lexicon.
emptyLexicon :: ShoreshLexicon
emptyLexicon = Map.empty
-- | Insert or merge an entry into the lexicon.
insertEntry :: ShoreshEntry -> ShoreshLexicon -> ShoreshLexicon
insertEntry entry = Map.insertWith mergeEntries (seShoresh entry) entry
-- | Merge two entries for the same root (accumulate occurrences).
mergeEntries :: ShoreshEntry -> ShoreshEntry -> ShoreshEntry
mergeEntries new old = old
{ seTorahOccurrences = seTorahOccurrences old ++ seTorahOccurrences new
}
-- | Look up a root in the lexicon.
lookupRoot :: Shoresh -> ShoreshLexicon -> Maybe ShoreshEntry
lookupRoot = Map.lookup
-- | All roots in the lexicon.
allRoots :: ShoreshLexicon -> [Shoresh]
allRoots = Map.keys
-- | Number of roots in the lexicon.
lexiconSize :: ShoreshLexicon -> Int
lexiconSize = Map.size
-- ============================================================
-- L1 JSON parsing
-- ============================================================
-- | A word as it appears in L1 verse JSON.
-- Mirrors the structure in l1/torah/{book}/{chapter}/{verse}.json
data L1Word = L1Word
{ l1wPosition :: !Int
, l1wText :: !Text
, l1wConsonants :: !Text
, l1wLemma :: !Text
, l1wMorph :: !Text
, l1wTeamim :: ![Text]
, l1wPrefix :: !(Maybe Text)
, l1wShoresh :: !(Maybe Text)
} deriving (Show, Generic)
instance FromJSON L1Word where
parseJSON = withObject "L1Word" $ \v -> L1Word
<$> v .: "position"
<*> v .: "text"
<*> v .: "consonants"
<*> v .: "lemma"
<*> v .: "morph"
<*> v .: "teamim"
<*> v .:? "prefix"
<*> v .:? "shoresh"
-- | A verse as it appears in L1 JSON.
data L1Verse = L1Verse
{ l1vRef :: !Text
, l1vBook :: !Text
, l1vChapter :: !Int
, l1vVerse :: !Int
, l1vTextHebrew :: !Text
, l1vWords :: ![L1Word]
} deriving (Show, Generic)
instance FromJSON L1Verse where
parseJSON = withObject "L1Verse" $ \v -> L1Verse
<$> v .: "ref"
<*> v .: "book"
<*> v .: "chapter"
<*> v .: "verse"
<*> v .: "text_hebrew"
<*> v .: "words"
-- | Parse a verse JSON bytestring.
parseL1Verse :: BL.ByteString -> Maybe L1Verse
parseL1Verse = decode
-- | Load an L1 verse from a file path.
loadL1Verse :: FilePath -> IO (Maybe L1Verse)
loadL1Verse path = do
bs <- BL.readFile path
return (decode bs)
-- | Build a lexicon from a list of parsed L1 verses.
-- Extracts the shoresh from each word and accumulates occurrences.
buildLexiconFromVerses :: [L1Verse] -> ShoreshLexicon
buildLexiconFromVerses verses = foldl addVerse emptyLexicon verses
where
addVerse lexicon verse =
let ref = Reference
{ refBook = maybe Bereishit id (textToBook (l1vBook verse))
, refChapter = l1vChapter verse
, refVerse = l1vVerse verse
}
in foldl (addWord ref) lexicon (l1vWords verse)
addWord ref lexicon word = case l1wShoresh word of
Nothing -> lexicon -- particles like את have no shoresh
Just shText -> case textToShoresh shText of
Nothing -> lexicon -- unparseable root text
Just sh ->
let entry = ShoreshEntry
{ seShoresh = sh
, sePrimaryDomain = Creation -- default; refined later
, seRelatedRoots = []
, seTorahOccurrences = [ref]
, seGlosses = Map.empty
}
in insertEntry entry lexicon
+52
View File
@@ -0,0 +1,52 @@
{-# LANGUAGE OverloadedStrings #-}
-- | Smikhut — the construct-state composition operator.
--
-- Architecture §3.1:
-- beit sefer = house FOR books (PurposeOf)
-- bnei Yisrael = children FROM Israel (OriginOf)
-- ish emet = person OF truth (QualityOf)
-- aron ha'edut = ark HOLDING testimony (ContainerOf)
--
-- Smikhut compounds attested in the text are read from L1.
-- The compose operation reads these attested pairs.
module Shoresh.Smikhut
( CompoundMeaning(..)
, SmikhutType(..)
, compose
, smikhutTypeGloss
) where
import Shoresh.Types
import Data.Text (Text)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
-- | The type of compositional relationship in construct state.
data SmikhutType
= PurposeOf -- ^ beit sefer: house FOR books
| OriginOf -- ^ bnei Yisrael: children FROM Israel
| QualityOf -- ^ ish emet: person OF truth
| ContainerOf -- ^ aron ha'edut: ark HOLDING testimony
deriving (Eq, Show)
-- | A construct-state compound meaning.
data CompoundMeaning = CompoundMeaning
{ nismach :: !Shoresh -- ^ The governed noun (first element)
, somech :: !Shoresh -- ^ The governing noun (second element)
, compound :: !SemanticDomain -- ^ The emergent meaning domain
, relation :: !SmikhutType -- ^ Type of compositional relationship
} deriving (Show)
-- | Compose two roots into a construct-state compound.
-- Looks up the attested compound in the provided map.
-- If the pair is not attested, returns Nothing — we do not invent compounds.
compose :: Shoresh -> Shoresh -> Map (Shoresh, Shoresh) CompoundMeaning -> Maybe CompoundMeaning
compose a b attestedCompounds = Map.lookup (a, b) attestedCompounds
-- | Human-readable gloss of a smikhut relationship.
smikhutTypeGloss :: SmikhutType -> Text
smikhutTypeGloss PurposeOf = "for"
smikhutTypeGloss OriginOf = "from"
smikhutTypeGloss QualityOf = "of"
smikhutTypeGloss ContainerOf = "holding"
+291
View File
@@ -0,0 +1,291 @@
{-# LANGUAGE OverloadedStrings #-}
-- | Te'amim — the cantillation mark reader.
--
-- Architecture §3.1:
-- "The marks are RECEIVED from L1 — they are on the text as transmitted.
-- The parse tree is CONSTRUCTED by the reader in the act of reading.
-- This is not pre-computation. This is literacy."
--
-- The te'amim system is hierarchical:
-- - Melakhim (kings/disjunctors) SEPARATE
-- - Meshartim (servants/conjunctors) CONNECT
--
-- The strongest disjunctor (sof-pasuk) ends the verse.
-- The next strongest (etnachta) creates the primary division.
-- Within each half, subordinate disjunctors create further divisions.
-- The result is a binary tree — the verse's own syntactic structure.
--
-- L1 stores the mark names as strings in each word's "teamim" array.
-- This module reads those strings and builds the tree.
module Shoresh.Teamim
( -- * Types
Taam(..)
, TaamRole(..)
, AnnotatedWord(..)
, ParsedVerse(..)
-- * Classification
, taamRole
, taamRank
, taamFromName
-- * Parsing
, readTeamim
, primaryDivision
, boundGroups
) where
import Data.Text (Text)
import qualified Data.Text as T
import Data.List (maximumBy)
import Data.Ord (comparing)
-- | Cantillation marks.
-- The full system has ~30 marks. We enumerate the ones that appear
-- in L1 data (parsed from WLC Unicode codepoints).
data Taam
= SofPasuk -- ^ Verse end (terminal) — strongest disjunctor
| Etnachta -- ^ Primary division — second strongest
| ZakefGadol -- ^ Secondary division (strong)
| ZakefKatan -- ^ Secondary division
| Segol -- ^ Tertiary
| Shalshelet -- ^ Rare, strong disjunctor
| Tipcha -- ^ Tertiary division (near-subordinate to etnachta/zakef)
| Revia -- ^ Mid-level disjunctor
| Zarka -- ^ Pre-segol disjunctor
| Pashta -- ^ Subordinate disjunctor
| Yetiv -- ^ Subordinate disjunctor
| Tevir -- ^ Subordinate disjunctor
| Geresh -- ^ Subordinate disjunctor
| GershayimMark -- ^ Double geresh — subordinate disjunctor
| Pazer -- ^ Subordinate disjunctor
| PazerGadol -- ^ Rare, subordinate disjunctor
| Telisha_Gedola -- ^ Subordinate disjunctor
| Telisha_Qetana -- ^ Conjunctor
| Munach -- ^ Servant — connects to following disjunctor
| Mercha -- ^ Servant — connects to following disjunctor
| MerchaKefula -- ^ Double mercha — servant
| Darga -- ^ Servant
| Kadma -- ^ Servant (azla)
| Mahapakh -- ^ Servant
| Yerach_Ben_Yomo -- ^ Rare servant
| MetegSiluq -- ^ Dual identity: siluq (disjunctor on final word) / meteg
deriving (Eq, Ord, Show)
-- | Role classification: kings separate, servants connect.
data TaamRole
= Melekh -- ^ Disjunctor (separator)
| Mesharet -- ^ Conjunctor (connector)
deriving (Eq, Show)
-- | Classify a taam as melekh (disjunctor) or mesharet (conjunctor).
taamRole :: Taam -> TaamRole
taamRole t = case t of
SofPasuk -> Melekh
Etnachta -> Melekh
ZakefGadol -> Melekh
ZakefKatan -> Melekh
Segol -> Melekh
Shalshelet -> Melekh
Tipcha -> Melekh
Revia -> Melekh
Zarka -> Melekh
Pashta -> Melekh
Yetiv -> Melekh
Tevir -> Melekh
Geresh -> Melekh
GershayimMark -> Melekh
Pazer -> Melekh
PazerGadol -> Melekh
Telisha_Gedola -> Melekh
MetegSiluq -> Melekh -- siluq on final word is a disjunctor
-- Servants
Telisha_Qetana -> Mesharet
Munach -> Mesharet
Mercha -> Mesharet
MerchaKefula -> Mesharet
Darga -> Mesharet
Kadma -> Mesharet
Mahapakh -> Mesharet
Yerach_Ben_Yomo -> Mesharet
-- | Disjunctive rank — higher means stronger separator.
-- This determines where the tree splits.
-- SofPasuk is strongest (it ends the verse).
-- Etnachta is next (primary division).
-- Then the hierarchy descends.
taamRank :: Taam -> Int
taamRank t = case t of
SofPasuk -> 100
Etnachta -> 90
ZakefGadol -> 70
ZakefKatan -> 65
Segol -> 60
Shalshelet -> 58
Revia -> 50
Tipcha -> 45
Zarka -> 40
Pashta -> 35
Yetiv -> 33
Tevir -> 30
Geresh -> 25
GershayimMark -> 25
Pazer -> 20
PazerGadol -> 20
Telisha_Gedola -> 15
MetegSiluq -> 80 -- siluq ranks just below etnachta
-- Servants get rank 0 — they do not create divisions
_ -> 0
-- | Parse a taam name from L1 JSON data.
-- L1 stores these as strings like "tipcha", "etnachta", "munach".
-- Build log correction #1: "meteg_siluq" is in TEAMIM_MAP due to
-- U+05BD dual identity.
taamFromName :: Text -> Maybe Taam
taamFromName name = case T.toLower name of
"sof_pasuk" -> Just SofPasuk
"sof-pasuk" -> Just SofPasuk
"sofpasuk" -> Just SofPasuk
"etnachta" -> Just Etnachta
"etnahta" -> Just Etnachta
"zakef_gadol" -> Just ZakefGadol
"zakef-gadol" -> Just ZakefGadol
"zaqef_gadol" -> Just ZakefGadol -- L1 spelling
"zakef_katan" -> Just ZakefKatan
"zakef-katan" -> Just ZakefKatan
"zaqef_qatan" -> Just ZakefKatan -- L1 spelling
"segol" -> Just Segol
"segol_accent" -> Just Segol -- L1 spelling
"shalshelet" -> Just Shalshelet
"tipcha" -> Just Tipcha
"tipeha" -> Just Tipcha
"revia" -> Just Revia
"revi'i" -> Just Revia
"zarka" -> Just Zarka
"pashta" -> Just Pashta
"yetiv" -> Just Yetiv
"tevir" -> Just Tevir
"geresh" -> Just Geresh
"gershayim" -> Just GershayimMark
"pazer" -> Just Pazer
"pazer_gadol" -> Just PazerGadol
"telisha_gedola" -> Just Telisha_Gedola
"telisha_qetana" -> Just Telisha_Qetana
"munach" -> Just Munach
"mercha" -> Just Mercha
"mercha_kefula" -> Just MerchaKefula
"darga" -> Just Darga
"kadma" -> Just Kadma
"qadma" -> Just Kadma -- L1 spelling
"azla" -> Just Kadma
"mahapakh" -> Just Mahapakh
"yerach_ben_yomo" -> Just Yerach_Ben_Yomo
"meteg_siluq" -> Just MetegSiluq
"siluq" -> Just MetegSiluq
"meteg" -> Just MetegSiluq
"zinor" -> Just Zarka -- L1: zinor is variant name for zarka
"geresh_muqdam" -> Just Geresh -- L1: pre-positioned geresh variant
_ -> Nothing
-- | A word annotated with its cantillation mark and role.
-- This is what we receive from L1 after parsing the raw mark names.
data AnnotatedWord = AnnotatedWord
{ awWord :: !Text -- ^ The Hebrew word text
, awTaam :: !Taam -- ^ The primary cantillation mark
, awRole :: !TaamRole -- ^ King or servant
} deriving (Show)
-- | A parsed verse: binary tree where branches are te'amim divisions.
-- Leaf: a single annotated word.
-- Branch: a disjunctor splitting the verse into two halves.
--
-- This tree is CONSTRUCTED by readTeamim from the raw marks.
-- It is not stored in L1. This is the act of reading.
data ParsedVerse
= Leaf !AnnotatedWord
| Branch !Taam !ParsedVerse !ParsedVerse
deriving (Show)
-- | Read the te'amim — construct the parse tree from annotated words.
--
-- Algorithm: find the strongest disjunctor, split there,
-- recurse on each half. This mirrors how a human reader
-- parses the verse structure from the cantillation marks.
--
-- The marks are received. The tree is built in the act of reading.
readTeamim :: [AnnotatedWord] -> ParsedVerse
readTeamim [] = error "readTeamim: empty word list — a verse cannot be empty"
readTeamim [w] = Leaf w
readTeamim ws =
-- Find the word with the strongest disjunctive mark
let indexed = zip [0..] ws
disjunctors = [ (i, w) | (i, w) <- indexed
, awRole w == Melekh
, i < length ws - 1 -- don't split after the last word
]
in case disjunctors of
[] ->
-- No disjunctors found (all servants) — treat as a chain
-- This shouldn't happen in well-formed verses but we handle it
buildChain ws
_ ->
-- Split at the strongest disjunctor
let (splitIdx, splitWord) = maximumBy (comparing (taamRank . awTaam . snd)) disjunctors
(left, right) = splitAt (splitIdx + 1) ws
splitTaam = awTaam splitWord
in Branch splitTaam (readTeamim left) (readTeamim right)
-- | Build a linear chain when no disjunctors are present.
buildChain :: [AnnotatedWord] -> ParsedVerse
buildChain [] = error "buildChain: empty"
buildChain [w] = Leaf w
buildChain (w:ws) = Branch (awTaam w) (Leaf w) (buildChain ws)
-- | Find the primary division (etnachta) of a parsed verse.
-- Returns the two halves of the verse.
-- The etnachta divides the verse into its two main parts.
primaryDivision :: ParsedVerse -> (ParsedVerse, ParsedVerse)
primaryDivision (Leaf w) = (Leaf w, Leaf w)
primaryDivision (Branch t left right)
| t == Etnachta = (left, right)
| otherwise =
-- The etnachta might be deeper in the tree.
-- In a well-formed parse, it should be at the top level.
-- If not, search left then right.
let (ll, lr) = primaryDivision left
(rl, rr) = primaryDivision right
in if hasEtnachta left
then (ll, Branch t lr right)
else if hasEtnachta right
then (Branch t left rl, rr)
else (left, right) -- no etnachta found — use top-level split
-- | Check if a parsed tree contains an etnachta division.
hasEtnachta :: ParsedVerse -> Bool
hasEtnachta (Leaf _) = False
hasEtnachta (Branch t l r) = t == Etnachta || hasEtnachta l || hasEtnachta r
-- | Identify which words are bound together by meshartim (servant chains).
-- Words connected by servants form semantic units.
-- Returns groups of words that are bound together.
boundGroups :: ParsedVerse -> [[Text]]
boundGroups = go []
where
go acc (Leaf w) = reverse (addToCurrentGroup (awWord w) acc)
go acc (Branch t left right)
| taamRole t == Mesharet =
-- Servants connect — merge groups
let leftGroups = go acc left
in go leftGroups right
| otherwise =
-- Kings separate — start new group for right
let leftGroups = go acc left
-- Close current group, start fresh for right
rightGroups = go ([] : leftGroups) right
in rightGroups
addToCurrentGroup w [] = [[w]]
addToCurrentGroup w (g:gs) = (w:g) : gs
+328
View File
@@ -0,0 +1,328 @@
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Core data types for the Shoresh Engine.
--
-- Architecture §3.1 specifies these types exactly.
-- The 22 Hebrew consonants, the triliteral root, the seven binyanim,
-- the activated root (peulah), semantic domains, and Torah references.
--
-- These types are the atomic units of the symbolic processing system.
-- They are not approximations. Aleph is Aleph.
module Shoresh.Types
( -- * Hebrew Consonants
Consonant(..)
, consonantToChar
, charToConsonant
, consonantName
-- * Triliteral Roots
, Shoresh(..)
, mkShoresh
, shoreshToText
, textToShoresh
-- * Binyanim (Transformation Operators)
, Binyan(..)
, allBinyanim
-- * Activated Roots
, Person(..)
, Number(..)
, Gender(..)
, Aspect(..)
, Peulah(..)
-- * Semantic Domains
, SemanticDomain(..)
-- * Torah References
, Book(..)
, Reference(..)
, bookToText
, textToBook
-- * Root Lexicon Entry
, ShoreshEntry(..)
-- * Input/Output types for pipeline
, Input(..)
, Category(..)
, ProcessedResult(..)
, Evaluation(..)
) where
import Data.Text (Text)
import qualified Data.Text as T
import Data.Map.Strict (Map)
import GHC.Generics (Generic)
import Data.Aeson (FromJSON(..), ToJSON(..), withText)
-- | The 22 Hebrew consonants.
-- These are the building blocks. No more, no less.
data Consonant
= Aleph | Bet | Gimel | Dalet | He | Vav | Zayin
| Chet | Tet | Yod | Kaf | Lamed | Mem | Nun
| Samekh | Ayin | Pe | Tsade | Qof | Resh | Shin | Tav
deriving (Eq, Ord, Show, Enum, Bounded, Generic)
instance ToJSON Consonant
instance FromJSON Consonant
-- | Map a consonant to its Hebrew character.
consonantToChar :: Consonant -> Char
consonantToChar c = case c of
Aleph -> 'א'; Bet -> 'ב'; Gimel -> 'ג'; Dalet -> 'ד'
He -> 'ה'; Vav -> 'ו'; Zayin -> 'ז'; Chet -> 'ח'
Tet -> 'ט'; Yod -> 'י'; Kaf -> 'כ'; Lamed -> 'ל'
Mem -> 'מ'; Nun -> 'נ'; Samekh -> 'ס'; Ayin -> 'ע'
Pe -> 'פ'; Tsade -> 'צ'; Qof -> 'ק'; Resh -> 'ר'
Shin -> 'ש'; Tav -> 'ת'
-- | Map a Hebrew character to a consonant.
-- Handles both regular and final (sofit) forms.
charToConsonant :: Char -> Maybe Consonant
charToConsonant ch = case ch of
'א' -> Just Aleph; 'ב' -> Just Bet; 'ג' -> Just Gimel; 'ד' -> Just Dalet
'ה' -> Just He; 'ו' -> Just Vav; 'ז' -> Just Zayin; 'ח' -> Just Chet
'ט' -> Just Tet; 'י' -> Just Yod; 'כ' -> Just Kaf; 'ך' -> Just Kaf
'ל' -> Just Lamed; 'מ' -> Just Mem; 'ם' -> Just Mem; 'נ' -> Just Nun
'ן' -> Just Nun; 'ס' -> Just Samekh; 'ע' -> Just Ayin; 'פ' -> Just Pe
'ף' -> Just Pe; 'צ' -> Just Tsade; 'ץ' -> Just Tsade; 'ק' -> Just Qof
'ר' -> Just Resh; 'ש' -> Just Shin; 'ת' -> Just Tav
_ -> Nothing
-- | English name of a consonant (for JSON output).
consonantName :: Consonant -> Text
consonantName c = case c of
Aleph -> "aleph"; Bet -> "bet"; Gimel -> "gimel"; Dalet -> "dalet"
He -> "he"; Vav -> "vav"; Zayin -> "zayin"; Chet -> "chet"
Tet -> "tet"; Yod -> "yod"; Kaf -> "kaf"; Lamed -> "lamed"
Mem -> "mem"; Nun -> "nun"; Samekh -> "samekh"; Ayin -> "ayin"
Pe -> "pe"; Tsade -> "tsade"; Qof -> "qof"; Resh -> "resh"
Shin -> "shin"; Tav -> "tav"
-- | A triliteral root — the atomic semantic unit.
-- Most Hebrew roots are three consonants. Some are two (biconsonantal).
-- The spec says triliteral; L1 shows some bicons (e.g. "שמ").
-- We model the common case. Bicons get a silent third.
data Shoresh = Shoresh
{ c1 :: !Consonant
, c2 :: !Consonant
, c3 :: !Consonant
} deriving (Eq, Ord, Show, Generic)
instance ToJSON Shoresh
instance FromJSON Shoresh
-- | Construct a Shoresh from three consonants.
mkShoresh :: Consonant -> Consonant -> Consonant -> Shoresh
mkShoresh = Shoresh
-- | Render a Shoresh as Hebrew text.
shoreshToText :: Shoresh -> Text
shoreshToText (Shoresh a b d) =
T.pack [consonantToChar a, consonantToChar b, consonantToChar d]
-- | Parse a Hebrew text string into a Shoresh.
-- Accepts 2 or 3 consonant strings.
-- For 2-consonant roots, pads with He (ה) as placeholder.
textToShoresh :: Text -> Maybe Shoresh
textToShoresh t =
let chars = T.unpack t
consonants = mapM charToConsonant chars
in case consonants of
Just [a, b, d] -> Just (Shoresh a b d)
Just [a, b] -> Just (Shoresh a b He) -- bicons: pad with He
_ -> Nothing
-- | The seven binyanim — transformation operators on roots.
-- Each binyan transforms the root's meaning systematically.
data Binyan
= Qal -- ^ Simple active
| Nifal -- ^ Passive / reflexive
| Piel -- ^ Intensive active
| Pual -- ^ Intensive passive
| Hifil -- ^ Causative active
| Hofal -- ^ Causative passive
| Hitpael -- ^ Reflexive / reciprocal
deriving (Eq, Ord, Show, Enum, Bounded, Generic)
instance ToJSON Binyan
instance FromJSON Binyan
-- | All seven binyanim as a list.
allBinyanim :: [Binyan]
allBinyanim = [minBound .. maxBound]
-- | Grammatical person.
data Person = First | Second | Third
deriving (Eq, Ord, Show, Enum, Bounded, Generic)
instance ToJSON Person
instance FromJSON Person
-- | Grammatical number.
-- Includes Dual — Hebrew distinguishes singular, dual, and plural.
data Number = Singular | Dual | Plural
deriving (Eq, Ord, Show, Enum, Bounded, Generic)
instance ToJSON Number
instance FromJSON Number
-- | Grammatical gender.
data Gender = Masculine | Feminine
deriving (Eq, Ord, Show, Enum, Bounded, Generic)
instance ToJSON Gender
instance FromJSON Gender
-- | Verbal aspect.
-- Hebrew does not have tenses in the Indo-European sense.
-- Perfect = completed action. Imperfect = incomplete action.
data Aspect = Perfect | Imperfect
deriving (Eq, Ord, Show, Enum, Bounded, Generic)
instance ToJSON Aspect
instance FromJSON Aspect
-- | An activated root — a root transformed by a binyan
-- with full grammatical specification.
data Peulah = Peulah
{ root :: !Shoresh
, binyan :: !Binyan
, person :: !Person
, number :: !Number
, gender :: !Gender
, aspect :: !Aspect
} deriving (Eq, Show, Generic)
instance ToJSON Peulah
instance FromJSON Peulah
-- | Semantic domains — categories of meaning that roots carry.
-- A root belongs to one or more domains.
data SemanticDomain
= Creation
| Separation
| Speech
| Movement
| Knowledge
| Holiness
| Judgment
| Mercy
| Life
| Death
| Covenant
| Transgression
| Blessing
| Curse
| Light
| Darkness
| Water
| Earth
| Time
| Dwelling
| Offering
| Purity
| Authority
| Service
| Warfare
| Agriculture
| Family
| Instruction
deriving (Eq, Ord, Show, Enum, Bounded, Generic)
instance ToJSON SemanticDomain
instance FromJSON SemanticDomain
-- | The five books of Torah.
data Book
= Bereishit
| Shemot
| Vayikra
| Bamidbar
| Devarim
deriving (Eq, Ord, Show, Enum, Bounded, Generic)
instance ToJSON Book where
toJSON = toJSON . bookToText
instance FromJSON Book where
parseJSON = withText "Book" $ \t ->
case textToBook t of
Just b -> pure b
Nothing -> fail $ "Unknown book: " ++ T.unpack t
-- | Book name as it appears in L1 paths.
bookToText :: Book -> Text
bookToText b = case b of
Bereishit -> "bereishit"
Shemot -> "shemot"
Vayikra -> "vayikra"
Bamidbar -> "bamidbar"
Devarim -> "devarim"
-- | Parse a book name from L1.
textToBook :: Text -> Maybe Book
textToBook t = case T.toLower t of
"bereishit" -> Just Bereishit
"genesis" -> Just Bereishit
"shemot" -> Just Shemot
"exodus" -> Just Shemot
"vayikra" -> Just Vayikra
"leviticus" -> Just Vayikra
"bamidbar" -> Just Bamidbar
"numbers" -> Just Bamidbar
"devarim" -> Just Devarim
"deuteronomy" -> Just Devarim
_ -> Nothing
-- | A Torah reference — book, chapter, verse.
data Reference = Reference
{ refBook :: !Book
, refChapter :: !Int
, refVerse :: !Int
} deriving (Eq, Ord, Show, Generic)
instance ToJSON Reference
instance FromJSON Reference
-- | A root entry in the lexicon.
-- This is what the engine knows about a root.
data ShoreshEntry = ShoreshEntry
{ seShoresh :: !Shoresh
, sePrimaryDomain :: !SemanticDomain
, seRelatedRoots :: ![Shoresh]
, seTorahOccurrences :: ![Reference]
, seGlosses :: !(Map Binyan Text)
} deriving (Eq, Show, Generic)
-- | Raw input to the processing pipeline.
data Input = Input
{ inputText :: !Text
, inputSource :: !Text
} deriving (Show, Generic)
-- | A distinguished category after havdalah.
data Category = Category
{ catName :: !Text
, catContent :: !Text
} deriving (Show, Generic)
-- | A processed result ready for evaluation.
data ProcessedResult = ProcessedResult
{ prCategories :: ![Category]
, prRoots :: ![ShoreshEntry]
} deriving (Show, Generic)
-- | The evaluation outcome.
-- Tov: fits the specification.
-- LoTov: does not fit, with reason.
-- Teiku: unresolvable — the question stands.
data Evaluation
= Tov
| LoTov !Text
| Teiku
deriving (Eq, Show, Generic)