diqduq
diqduq: a DSPy program analyzing the syntax of Biblical Hebrew passages according to the scheme documented in syntax_model.md. Modeled on arsgrammatica (https://github.com/neelsmith/arsgrammatica), the same author's analyzer for Latin syntax.
1"""diqduq: a DSPy program analyzing the syntax of Biblical Hebrew passages 2according to the scheme documented in syntax_model.md. Modeled on 3arsgrammatica (https://github.com/neelsmith/arsgrammatica), the same 4author's analyzer for Latin syntax. 5""" 6 7from .models import ( 8 Token, 9 CitedText, 10 Sentence, 11 VerbalExpression, 12 TokenAnalysis, 13 RelationLabel, 14 IMPLIED_TOKENTYPES, 15 NON_SUBSTANTIVE_TOKENTYPES, 16) 17from .mermaid import tokengraph_to_mermaid, save_mermaid 18from .verbal_units import ( 19 assign_verbal_units, 20 assign_verbal_unit_colors, 21 compute_subordination_depths, 22 max_subordination_depth, 23 find_unanchored_coordinated_verbs, 24) 25from .rendering import tokengraph_to_text, tokengraph_to_html, tokengraph_to_depth_html 26from .hebrew_syntax_dspy import ( 27 SyntaxAnalysis, 28 analyze, 29 validate, 30 print_analysis, 31) 32from .segmentation_dspy import SegmentPassage, segment_sources 33from .pipeline import analyze_passage, analyze_sources, combined_tokengraph 34from .serialization import serialize_analyses, write_analyses, read_analyses, split_analysis_by_sentence 35from .ctsdata import CtsDataRow, read_ctsdata 36from .token_budget import ( 37 estimate_max_tokens, 38 analyze_with_retry, 39 get_calibration, 40 estimate_segmentation_max_tokens, 41 segment_with_retry, 42) 43 44__all__ = [ 45 "Token", 46 "CitedText", 47 "Sentence", 48 "VerbalExpression", 49 "TokenAnalysis", 50 "RelationLabel", 51 "IMPLIED_TOKENTYPES", 52 "NON_SUBSTANTIVE_TOKENTYPES", 53 "tokengraph_to_mermaid", 54 "save_mermaid", 55 "assign_verbal_units", 56 "assign_verbal_unit_colors", 57 "compute_subordination_depths", 58 "max_subordination_depth", 59 "find_unanchored_coordinated_verbs", 60 "tokengraph_to_text", 61 "tokengraph_to_html", 62 "tokengraph_to_depth_html", 63 "SyntaxAnalysis", 64 "analyze", 65 "analyze_passage", 66 "validate", 67 "print_analysis", 68 "SegmentPassage", 69 "segment_sources", 70 "analyze_sources", 71 "combined_tokengraph", 72 "serialize_analyses", 73 "write_analyses", 74 "read_analyses", 75 "split_analysis_by_sentence", 76 "CtsDataRow", 77 "read_ctsdata", 78 "estimate_max_tokens", 79 "analyze_with_retry", 80 "get_calibration", 81 "estimate_segmentation_max_tokens", 82 "segment_with_retry", 83]
50class Token(BaseModel): 51 """A single pre-segmented token with a stable id. 52 53 `citation` is optional so this model still works for citation-free 54 callers -- e.g. a test fixture built directly from a canned tokengraph, 55 with no CitedText source at all -- as well as for the citation-aware 56 segmentation stage (segmentation_dspy.py), which is the only thing that 57 actually populates it, knowing which CitedText source unit each token 58 came from.""" 59 60 id: str = Field(description="Stable token id, globally unique and sequential across the whole input, e.g. 't0', 't1', ...") 61 text: str = Field(description="The token's surface text, exactly as it appears in the source.") 62 citation: Optional[str] = Field( 63 default=None, 64 description="Citation label of the source unit this token came from (e.g. 'urn:cts:compnov:bible.genesis.masoretic:1.1'), if known.", 65 )
A single pre-segmented token with a stable id.
citation is optional so this model still works for citation-free
callers -- e.g. a test fixture built directly from a canned tokengraph,
with no CitedText source at all -- as well as for the citation-aware
segmentation stage (segmentation_dspy.py), which is the only thing that
actually populates it, knowing which CitedText source unit each token
came from.
39class CitedText(BaseModel): 40 """One citable unit of source text -- e.g. one verse -- paired with its 41 citation label. A sequence of these is segmentation_dspy.py's input: 42 sentence boundaries do NOT need to respect CitedText boundaries (one 43 sentence may span several units), but every resulting token still 44 records which unit it came from via Token.citation.""" 45 46 citation: str = Field(description="Citation label for this unit, e.g. 'urn:cts:compnov:bible.genesis.masoretic:1.1'.") 47 text: str = Field(description="This unit's raw text, exactly as written (including niqqud and cantillation).")
One citable unit of source text -- e.g. one verse -- paired with its citation label. A sequence of these is segmentation_dspy.py's input: sentence boundaries do NOT need to respect CitedText boundaries (one sentence may span several units), but every resulting token still records which unit it came from via Token.citation.
68class Sentence(BaseModel): 69 """One sentence's worth of tokens, in reading order, as produced by the 70 LLM-driven segmentation stage (segmentation_dspy.py). Token ids are 71 global across the whole passage -- numbering continues across sentence 72 boundaries rather than restarting at t0 for each sentence -- so a 73 Sentence is a contiguous slice of the passage's id sequence, not an 74 independently-numbered unit.""" 75 76 tokens: List[Token] = Field( 77 description="This sentence's tokens, in reading order, using the passage's global token ids." 78 )
One sentence's worth of tokens, in reading order, as produced by the LLM-driven segmentation stage (segmentation_dspy.py). Token ids are global across the whole passage -- numbering continues across sentence boundaries rather than restarting at t0 for each sentence -- so a Sentence is a contiguous slice of the passage's id sequence, not an independently-numbered unit.
81class VerbalExpression(BaseModel): 82 """One entry in the table of verbal expressions (syntax_model.md, 83 'Table of verbal expressions'). Two constructions count as a verbal 84 expression, in this first draft of the scheme: 85 86 1. Every finite verb. 87 2. Every participle. 88 89 Each verbal expression is classified along two independent axes: 90 91 - `syntactic_type`: 'independent' (also called "main" or "principal" -- 92 a syntactically independent finite verb, whose clause is coherent by 93 itself) or 'direct quote' (a verbal expression occurring in directly 94 quoted speech -- see RelationLabel's 'direct quote' value for how it 95 relates back to the verb that introduces the quotation). These are 96 the only two values syntax_model.md currently documents; unlike 97 arsgrammatica's mature Latin scheme, there is no 'dependent' category 98 yet (subordinating conjunctions are listed under syntax_model.md's 99 'TBA' section) and no separate category for an "aside" or an 100 "indirect statement" -- Biblical Hebrew narrative marks reported 101 speech directly (see 'direct quote' below) rather than through an 102 accusative-and-infinitive construction the way Latin does. 103 - `semantic_type`: 'transitive active', 'transitive passive', 104 'intransitive', or 'linking verb'. 105 106 A participle's own syntactic_type isn't pinned down by syntax_model.md 107 beyond the two values above -- this codebase's convention (flagged 108 here, matching the style of arsgrammatica's own documented judgment 109 calls, since syntax_model.md itself doesn't say) is to classify a 110 participle 'independent' unless it occurs within quoted speech, in 111 which case 'direct quote' applies exactly as it would to a finite verb. 112 Extend syntax_model.md first if a real passage needs a genuinely 113 different category for a participial clause (e.g. something 114 circumstantial/subordinate, as arsgrammatica's Latin scheme has for a 115 circumstantial participle) -- see notes/USAGE.md's "Extending the scheme". 116 117 `diqduq` also recognizes one construction as understood or implied 118 even though it has no surface realization: an elided present of "to 119 be" (see IMPLIED_TOKENTYPES's 'implied sum' below, and TokenAnalysis's 120 own docstring for the new-token convention this requires).""" 121 122 id: str = Field( 123 description=( 124 "The token id (from the input `tokens` list) of the finite " 125 "verb or participle that anchors this verbal expression. For " 126 "an elided present of 'to be' (see TokenAnalysis's 'implied " 127 "sum' tokentype, IMPLIED_TOKENTYPES), use the new implied " 128 "token's id instead." 129 ) 130 ) 131 syntactic_type: Literal["independent", "direct quote"] = Field( 132 description=( 133 "'independent' (main/principal -- a syntactically independent " 134 "finite verb or participle) or 'direct quote' (occurring in " 135 "directly quoted speech; see RelationLabel's 'direct quote' " 136 "value). syntax_model.md documents no other values yet -- see " 137 "this model's own docstring." 138 ) 139 ) 140 semantic_type: Literal[ 141 "transitive active", "transitive passive", "intransitive", "linking verb" 142 ] = Field(description="The verb's semantic/voice type.")
One entry in the table of verbal expressions (syntax_model.md, 'Table of verbal expressions'). Two constructions count as a verbal expression, in this first draft of the scheme:
- Every finite verb.
- Every participle.
Each verbal expression is classified along two independent axes:
syntactic_type: 'independent' (also called "main" or "principal" -- a syntactically independent finite verb, whose clause is coherent by itself) or 'direct quote' (a verbal expression occurring in directly quoted speech -- see RelationLabel's 'direct quote' value for how it relates back to the verb that introduces the quotation). These are the only two values syntax_model.md currently documents; unlike arsgrammatica's mature Latin scheme, there is no 'dependent' category yet (subordinating conjunctions are listed under syntax_model.md's 'TBA' section) and no separate category for an "aside" or an "indirect statement" -- Biblical Hebrew narrative marks reported speech directly (see 'direct quote' below) rather than through an accusative-and-infinitive construction the way Latin does.semantic_type: 'transitive active', 'transitive passive', 'intransitive', or 'linking verb'.
A participle's own syntactic_type isn't pinned down by syntax_model.md beyond the two values above -- this codebase's convention (flagged here, matching the style of arsgrammatica's own documented judgment calls, since syntax_model.md itself doesn't say) is to classify a participle 'independent' unless it occurs within quoted speech, in which case 'direct quote' applies exactly as it would to a finite verb. Extend syntax_model.md first if a real passage needs a genuinely different category for a participial clause (e.g. something circumstantial/subordinate, as arsgrammatica's Latin scheme has for a circumstantial participle) -- see notes/USAGE.md's "Extending the scheme".
diqduq also recognizes one construction as understood or implied
even though it has no surface realization: an elided present of "to
be" (see IMPLIED_TOKENTYPES's 'implied sum' below, and TokenAnalysis's
own docstring for the new-token convention this requires).
The token id (from the input tokens list) of the finite verb or participle that anchors this verbal expression. For an elided present of 'to be' (see TokenAnalysis's 'implied sum' tokentype, IMPLIED_TOKENTYPES), use the new implied token's id instead.
'independent' (main/principal -- a syntactically independent finite verb or participle) or 'direct quote' (occurring in directly quoted speech; see RelationLabel's 'direct quote' value). syntax_model.md documents no other values yet -- see this model's own docstring.
264class TokenAnalysis(BaseModel): 265 """One entry per token in the dependency graph (syntax_model.md, 266 'Token-level table of dependencies'). Per syntax_model.md's own 267 "TBA" section (some constructions have no documented relation at all 268 yet), not every token will have a relation -- leave the 269 relatedtoken*/relationship* fields unset when none of the documented 270 relations apply. 271 272 Every entry corresponds 1:1 to an entry in the input `tokens` list, 273 EXCEPT for the one IMPLIED_TOKENTYPES value below: syntax_model.md's 274 "understood or implied verbal expressions" section documents an elided 275 present of "to be" ("to be" is often left out of a Hebrew nominal 276 sentence entirely) as a VERBAL EXPRESSION that exists grammatically 277 but has no surface realization at all. For that case, add a NEW entry 278 here -- with a NEW id, not present in `tokens` -- rather than skipping 279 the construction: tokentype 'implied sum', with `token` left unset 280 (None). See hebrew_syntax_dspy.SyntaxAnalysis's docstring for the full 281 rule and the id-naming convention.""" 282 283 id: str = Field( 284 description=( 285 "For an ordinary entry, must match the id of the corresponding " 286 "entry in the input `tokens` list. For an implied token " 287 "(tokentype 'implied sum'), a NEW id not used by any entry in " 288 "`tokens` or elsewhere in this tokengraph -- see " 289 "SyntaxAnalysis's docstring for the naming convention." 290 ) 291 ) 292 token: Optional[str] = Field( 293 default=None, 294 description=( 295 "The token's surface text; should match the `text` of the " 296 "input token with this id. Leave as None ONLY for an implied " 297 "token (tokentype 'implied sum') -- one with no surface " 298 "realization in the passage at all; every other tokentype " 299 "must have real text." 300 ), 301 ) 302 tokentype: Literal[ 303 "lexical", 304 "enclitic pronoun", 305 "proclitic conjunction", 306 "maqaf", 307 "cantillation", 308 "paragraph", 309 "editorial", 310 "implied sum", 311 ] = Field( 312 description=( 313 "Per syntax_model.md's 'Tokenization' section: 'cantillation' " 314 "for any of the te'amim (e.g. sof pasuq, silluq, atnach); " 315 "'paragraph' for a פ (petuhah) or ס (setumah) marking a " 316 "semantic division of the text; 'enclitic pronoun' for a " 317 "pronoun bound as the object of a preposition or verb, or as a " 318 "possessive with a noun; 'proclitic conjunction' specifically " 319 "for the conjunction וְ; 'maqaf' for the joining token ־; " 320 "'lexical' for a continuous alphabetic sequence together with " 321 "its own niqqud/dagesh/mappiq/sin-shin-dot (but never " 322 "cantillation marks, which are their own token type); " 323 "'editorial' for any Unicode punctuation character or other " 324 "editorial mark, such as the masora circle. 'implied sum' " 325 "marks a token with NO surface realization at all (an elided " 326 "present of 'to be' -- see this model's own docstring) -- the " 327 "only tokentype whose `token` field is None and whose `id` is " 328 "not one of the input `tokens`' own ids; it always anchors its " 329 "own entry in `verbalunits`, exactly like a real verb." 330 ) 331 ) 332 333 lemma: Optional[str] = Field(default=None, description="Dictionary headword, for lexical tokens. Omit for cantillation/paragraph/editorial tokens.") 334 verbalunitid: Optional[str] = Field( 335 default=None, 336 description="If this token anchors a verbal expression in `verbalunits`, repeat its own id here; otherwise omit.", 337 ) 338 339 relatedtoken1: Optional[str] = Field( 340 default=None, 341 description=( 342 "Token id this token relates to (primary relation). For an " 343 "INDEPENDENT verb's own 'unit verb' relation, use the special " 344 "sentinel string 'root' instead of a token id -- 'root' is " 345 "reserved and must never be assigned as an actual token's id." 346 ), 347 ) 348 relationship1: Optional[RelationLabel] = Field(default=None, description="The primary relation type, if any.") 349 350 relatedtoken2: Optional[str] = Field(default=None, description="Token id this token relates to (secondary relation -- an overflow slot, EXCEPT for 'coordinating conjunction', which uses both slots for its two sides at once).") 351 relationship2: Optional[RelationLabel] = Field(default=None, description="The secondary relation type, if any.")
One entry per token in the dependency graph (syntax_model.md, 'Token-level table of dependencies'). Per syntax_model.md's own "TBA" section (some constructions have no documented relation at all yet), not every token will have a relation -- leave the relatedtoken*/relationship* fields unset when none of the documented relations apply.
Every entry corresponds 1:1 to an entry in the input tokens list,
EXCEPT for the one IMPLIED_TOKENTYPES value below: syntax_model.md's
"understood or implied verbal expressions" section documents an elided
present of "to be" ("to be" is often left out of a Hebrew nominal
sentence entirely) as a VERBAL EXPRESSION that exists grammatically
but has no surface realization at all. For that case, add a NEW entry
here -- with a NEW id, not present in tokens -- rather than skipping
the construction: tokentype 'implied sum', with token left unset
(None). See hebrew_syntax_dspy.SyntaxAnalysis's docstring for the full
rule and the id-naming convention.
For an ordinary entry, must match the id of the corresponding entry in the input tokens list. For an implied token (tokentype 'implied sum'), a NEW id not used by any entry in tokens or elsewhere in this tokengraph -- see SyntaxAnalysis's docstring for the naming convention.
The token's surface text; should match the text of the input token with this id. Leave as None ONLY for an implied token (tokentype 'implied sum') -- one with no surface realization in the passage at all; every other tokentype must have real text.
Per syntax_model.md's 'Tokenization' section: 'cantillation' for any of the te'amim (e.g. sof pasuq, silluq, atnach); 'paragraph' for a פ (petuhah) or ס (setumah) marking a semantic division of the text; 'enclitic pronoun' for a pronoun bound as the object of a preposition or verb, or as a possessive with a noun; 'proclitic conjunction' specifically for the conjunction וְ; 'maqaf' for the joining token ־; 'lexical' for a continuous alphabetic sequence together with its own niqqud/dagesh/mappiq/sin-shin-dot (but never cantillation marks, which are their own token type); 'editorial' for any Unicode punctuation character or other editorial mark, such as the masora circle. 'implied sum' marks a token with NO surface realization at all (an elided present of 'to be' -- see this model's own docstring) -- the only tokentype whose token field is None and whose id is not one of the input tokens' own ids; it always anchors its own entry in verbalunits, exactly like a real verb.
Dictionary headword, for lexical tokens. Omit for cantillation/paragraph/editorial tokens.
If this token anchors a verbal expression in verbalunits, repeat its own id here; otherwise omit.
70def tokengraph_to_mermaid( 71 tokengraph: List[TokenAnalysis], 72 orientation: str = "BT", 73 color_by_verbal_unit: bool = True, 74 rank_by_depth: bool = True, 75) -> Tuple[str, List[str]]: 76 """Build a Mermaid `graph` diagram from a tokengraph. 77 78 `orientation` is Mermaid's own flowchart orientation code -- `BT` 79 (bottom-to-top, the default here), `TB`, `LR`, or `RL` -- used verbatim 80 in the diagram's opening line (`graph BT`, `graph LR`, etc.). See 81 https://mermaid.js.org/syntax/flowchart.html for what each value looks 82 like. 83 84 `color_by_verbal_unit` (default True) colors every node by the verbal 85 unit it belongs to, per verbal_units.assign_verbal_units(). Pass False 86 to skip coloring and get a plain diagram. 87 88 `rank_by_depth` (default True) makes the diagram's layout respect each 89 verbal expression's own *depth of subordination* (see 90 verbal_units.compute_subordination_depths()). Pass False to skip this 91 and get the diagram's previous, unranked layout. 92 93 Returns (diagram_text, warnings). `warnings` lists any edges that were 94 skipped because they referenced a non-substantive token or an id not 95 present in `tokengraph`, plus, if `color_by_verbal_unit` is True and the 96 passage has more than 8 verbal units, one warning that colors are 97 repeating, plus, if `rank_by_depth` is True, any of 98 compute_subordination_depths()'s own warnings. 99 """ 100 node_ids = {tok.id for tok in tokengraph if tok.tokentype not in NON_SUBSTANTIVE_TOKENTYPES} 101 102 lines = [f"graph {orientation}"] 103 for tok in tokengraph: 104 if tok.id not in node_ids: 105 continue 106 label = ( 107 tok.token 108 if tok.token is not None 109 else _IMPLIED_TOKEN_LABELS.get(tok.tokentype, tok.tokentype) 110 ) 111 open_bracket, close_bracket = ( 112 ("(", ")") if tok.tokentype in IMPLIED_TOKENTYPES else ("[", "]") 113 ) 114 lines.append(f' {tok.id}{open_bracket}"{_escape_label(label)}"{close_bracket}') 115 116 warnings = [] 117 for tok in tokengraph: 118 if tok.id not in node_ids: 119 continue 120 for related_field, label_field in ( 121 ("relatedtoken1", "relationship1"), 122 ("relatedtoken2", "relationship2"), 123 ): 124 related_id = getattr(tok, related_field) 125 label = getattr(tok, label_field) 126 if related_id is None or label is None: 127 continue 128 if related_id == "root": 129 continue 130 if related_id not in node_ids: 131 warnings.append( 132 f"skipped edge {tok.id} -[{label}]-> {related_id}: " 133 f"target is non-substantive or not in tokengraph" 134 ) 135 continue 136 lines.append(f' {tok.id} -->|{_escape_label(label)}| {related_id}') 137 138 if rank_by_depth: 139 depths, depth_warnings = compute_subordination_depths(tokengraph) 140 warnings.extend(depth_warnings) 141 142 depth_groups: dict = {} 143 for tok in tokengraph: 144 if tok.id not in node_ids: 145 continue 146 depth = depths.get(tok.id) 147 if depth is None: 148 continue 149 depth_groups.setdefault(depth, []).append(tok.id) 150 151 rank_lines = [ 152 " " + " ~~~ ".join(ids) 153 for depth in sorted(depth_groups) 154 for ids in (depth_groups[depth],) 155 if len(ids) > 1 156 ] 157 if rank_lines: 158 lines.append("") 159 lines.extend(rank_lines) 160 161 if color_by_verbal_unit: 162 assignment = assign_verbal_units(tokengraph) 163 colors, color_warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment) 164 warnings.extend(color_warnings) 165 166 implied_ids = [ 167 tok.id 168 for tok in tokengraph 169 if tok.id in node_ids and tok.tokentype in IMPLIED_TOKENTYPES 170 ] 171 172 if colors or implied_ids: 173 lines.append("") 174 class_names = {} 175 for i, (unit_id, (fill, stroke, text)) in enumerate(colors.items()): 176 class_name = f"vu{i}" 177 class_names[unit_id] = class_name 178 lines.append( 179 f" classDef {class_name} fill:{fill},stroke:{stroke},color:{text};" 180 ) 181 for unit_id in colors: 182 member_ids = [ 183 tok.id 184 for tok in tokengraph 185 if tok.id in node_ids 186 and assignment.get(tok.id) == unit_id 187 and tok.id not in implied_ids 188 ] 189 if member_ids: 190 lines.append(f" class {','.join(member_ids)} {class_names[unit_id]};") 191 if implied_ids: 192 fill, stroke, text = _IMPLIED_TOKEN_COLOR 193 lines.append( 194 f" classDef implied fill:{fill},stroke:{stroke},color:{text};" 195 ) 196 lines.append(f" class {','.join(implied_ids)} implied;") 197 198 return "\n".join(lines), warnings
Build a Mermaid graph diagram from a tokengraph.
orientation is Mermaid's own flowchart orientation code -- BT
(bottom-to-top, the default here), TB, LR, or RL -- used verbatim
in the diagram's opening line (graph BT, graph LR, etc.). See
https://mermaid.js.org/syntax/flowchart.html for what each value looks
like.
color_by_verbal_unit (default True) colors every node by the verbal
unit it belongs to, per verbal_units.assign_verbal_units(). Pass False
to skip coloring and get a plain diagram.
rank_by_depth (default True) makes the diagram's layout respect each
verbal expression's own depth of subordination (see
verbal_units.compute_subordination_depths()). Pass False to skip this
and get the diagram's previous, unranked layout.
Returns (diagram_text, warnings). warnings lists any edges that were
skipped because they referenced a non-substantive token or an id not
present in tokengraph, plus, if color_by_verbal_unit is True and the
passage has more than 8 verbal units, one warning that colors are
repeating, plus, if rank_by_depth is True, any of
compute_subordination_depths()'s own warnings.
201def save_mermaid( 202 tokengraph: List[TokenAnalysis], 203 path: str, 204 orientation: str = "BT", 205 color_by_verbal_unit: bool = True, 206 rank_by_depth: bool = True, 207) -> List[str]: 208 """Write the diagram to `path` (e.g. 'analysis.mmd') and return any 209 warnings from tokengraph_to_mermaid.""" 210 diagram, warnings = tokengraph_to_mermaid( 211 tokengraph, 212 orientation=orientation, 213 color_by_verbal_unit=color_by_verbal_unit, 214 rank_by_depth=rank_by_depth, 215 ) 216 with open(path, "w", encoding="utf-8") as f: 217 f.write(diagram + "\n") 218 return warnings
Write the diagram to path (e.g. 'analysis.mmd') and return any
warnings from tokengraph_to_mermaid.
73def assign_verbal_units(tokengraph: List[TokenAnalysis]) -> Dict[str, Optional[str]]: 74 """Return {token id: verbal unit id or None}, one entry per token in 75 `tokengraph` (including cantillation/editorial/maqaf and any other 76 unrelated token, so every id is accounted for -- callers that only 77 care about assigned tokens can filter out the None values themselves). 78 79 A verbal unit's own anchor token is assigned to itself (its 80 `verbalunitid`). Every other token is assigned to the verbal unit its 81 relations resolve to via a plain forward chase of relatedtoken1 (then 82 relatedtoken2); a token with no resolvable relation (e.g. a 83 preposition, whose own outward relation syntax_model.md doesn't 84 document yet -- see this module's own docstring) gets None. 85 """ 86 by_id = {tok.id: tok for tok in tokengraph} 87 88 resolved: Dict[str, Optional[str]] = {} 89 in_progress: set = set() 90 91 def resolve(tid: str) -> Optional[str]: 92 if tid in resolved: 93 return resolved[tid] 94 tok = by_id.get(tid) 95 if tok is None: 96 return None 97 98 if tok.verbalunitid is not None: 99 resolved[tid] = tok.verbalunitid 100 return tok.verbalunitid 101 102 if tid in in_progress: 103 # A cycle in the relation graph (malformed LM output) -- bail 104 # out on this token rather than recursing forever. 105 return None 106 in_progress.add(tid) 107 108 result = None 109 for related_field in ("relatedtoken1", "relatedtoken2"): 110 related = getattr(tok, related_field) 111 if related is None or related == "root": 112 continue 113 result = resolve(related) 114 if result is not None: 115 break 116 117 in_progress.discard(tid) 118 resolved[tid] = result 119 return result 120 121 for tid in by_id: 122 resolve(tid) 123 124 return resolved
Return {token id: verbal unit id or None}, one entry per token in
tokengraph (including cantillation/editorial/maqaf and any other
unrelated token, so every id is accounted for -- callers that only
care about assigned tokens can filter out the None values themselves).
A verbal unit's own anchor token is assigned to itself (its
verbalunitid). Every other token is assigned to the verbal unit its
relations resolve to via a plain forward chase of relatedtoken1 (then
relatedtoken2); a token with no resolvable relation (e.g. a
preposition, whose own outward relation syntax_model.md doesn't
document yet -- see this module's own docstring) gets None.
127def assign_verbal_unit_colors( 128 tokengraph: List[TokenAnalysis], 129 assignment: Optional[Dict[str, Optional[str]]] = None, 130) -> Tuple[Dict[str, Tuple[str, str, str]], List[str]]: 131 """Assign each verbal unit found in `tokengraph` a stable (fill, stroke, 132 text) triple from `_VERBAL_UNIT_PALETTE`, using the exact ordering rule 133 `tokengraph_to_mermaid()` uses for its node coloring -- so any other 134 caller wanting "the same colors as the mermaid graph" (rendering.py's 135 `tokengraph_to_html()`) gets an identical mapping without re-deriving 136 the rule itself. 137 138 Order is by first appearance of each verbal unit among tokengraph's 139 *substantive* tokens (tokentype not in models.NON_SUBSTANTIVE_TOKENTYPES 140 -- cantillation, paragraph, editorial, and maqaf never become mermaid 141 nodes at all). 142 143 Pass `assignment` (the result of `assign_verbal_units(tokengraph)`) if 144 the caller already computed it, to avoid re-deriving it here; otherwise 145 it's computed internally. 146 147 Returns `({verbal unit id: (fill, stroke, text)}, warnings)` -- 148 `warnings` holds one entry if there are more distinct verbal units than 149 palette slots (colors repeat past the 8th unit). A verbal unit id 150 absent from the returned dict was never assigned to any substantive 151 token -- callers should treat that the same as "no verbal unit" (no 152 coloring). 153 """ 154 if assignment is None: 155 assignment = assign_verbal_units(tokengraph) 156 157 substantive_ids = {tok.id for tok in tokengraph if tok.tokentype not in NON_SUBSTANTIVE_TOKENTYPES} 158 159 unit_order: List[str] = [] 160 seen_units = set() 161 for tok in tokengraph: 162 if tok.id not in substantive_ids: 163 continue 164 unit_id = assignment.get(tok.id) 165 if unit_id is not None and unit_id not in seen_units: 166 seen_units.add(unit_id) 167 unit_order.append(unit_id) 168 169 warnings: List[str] = [] 170 if len(unit_order) > len(_VERBAL_UNIT_PALETTE): 171 warnings.append( 172 f"{len(unit_order)} verbal units but only {len(_VERBAL_UNIT_PALETTE)} " 173 "distinct colors -- colors repeat and may be ambiguous between units" 174 ) 175 176 colors = { 177 unit_id: _VERBAL_UNIT_PALETTE[i % len(_VERBAL_UNIT_PALETTE)] 178 for i, unit_id in enumerate(unit_order) 179 } 180 return colors, warnings
Assign each verbal unit found in tokengraph a stable (fill, stroke,
text) triple from _VERBAL_UNIT_PALETTE, using the exact ordering rule
tokengraph_to_mermaid() uses for its node coloring -- so any other
caller wanting "the same colors as the mermaid graph" (rendering.py's
tokengraph_to_html()) gets an identical mapping without re-deriving
the rule itself.
Order is by first appearance of each verbal unit among tokengraph's substantive tokens (tokentype not in models.NON_SUBSTANTIVE_TOKENTYPES -- cantillation, paragraph, editorial, and maqaf never become mermaid nodes at all).
Pass assignment (the result of assign_verbal_units(tokengraph)) if
the caller already computed it, to avoid re-deriving it here; otherwise
it's computed internally.
Returns ({verbal unit id: (fill, stroke, text)}, warnings) --
warnings holds one entry if there are more distinct verbal units than
palette slots (colors repeat past the 8th unit). A verbal unit id
absent from the returned dict was never assigned to any substantive
token -- callers should treat that the same as "no verbal unit" (no
coloring).
183def compute_subordination_depths( 184 tokengraph: List[TokenAnalysis], 185) -> Tuple[Dict[str, Optional[int]], List[str]]: 186 """Compute each verbal expression's *depth of subordination*: the 187 number of verbal expressions it is removed from an independent ("root") 188 clause. An independent verb is depth 0; a directly-quoted verb it 189 introduces is depth 1; a verbal expression quoted WITHIN that quote (if 190 the scheme is ever asked to represent one) would be depth 2; and so on. 191 192 A "verbal expression" here is any token that anchors one -- i.e. any 193 token with `verbalunitid` set to its own id (the same convention 194 `assign_verbal_units()` relies on). For each anchor, this function 195 finds its *parent* anchor -- the verbal expression it's subordinate to 196 -- by following the anchor's own relatedtoken1 (falling back to 197 relatedtoken2) until it lands on another anchor: 198 199 - unit verb (independent): relatedtoken1 == 'root' -> no parent, depth 0. 200 - direct quote: relatedtoken1 -> the verb of the clause that introduces 201 the quotation, directly (no intermediate token to hop through, unlike 202 arsgrammatica's Latin scheme, which has no equivalent of a 203 subordinating-conjunction intermediary for this case either). 204 205 Returns `({anchor id: depth or None}, warnings)`. A depth of `None` 206 means the chase from that anchor never reached another anchor (a 207 malformed or genuinely disconnected verbal expression) or a cycle was 208 detected; `warnings` names which anchor(s) and why, rather than 209 raising. 210 """ 211 by_id = {tok.id: tok for tok in tokengraph} 212 anchor_ids = {tok.id for tok in tokengraph if tok.verbalunitid == tok.id} 213 214 warnings: List[str] = [] 215 216 def chase(token_id: str, visited: set) -> Optional[str]: 217 """Follow relatedtoken1 (then relatedtoken2) forward from 218 `token_id`, returning the first anchor id reached, or None if the 219 chain dead-ends or cycles before reaching one. `token_id` itself 220 counts as a hit if it's already an anchor (the direct 'direct 221 quote' case).""" 222 if token_id in visited: 223 return None 224 visited.add(token_id) 225 if token_id in anchor_ids: 226 return token_id 227 tok = by_id.get(token_id) 228 if tok is None: 229 return None 230 for field in ("relatedtoken1", "relatedtoken2"): 231 target = getattr(tok, field) 232 if target is None or target == "root": 233 continue 234 result = chase(target, visited) 235 if result is not None: 236 return result 237 return None 238 239 def parent_of(anchor_id: str) -> Optional[str]: 240 tok = by_id[anchor_id] 241 for field in ("relatedtoken1", "relatedtoken2"): 242 target = getattr(tok, field) 243 if target is None or target == "root": 244 continue 245 result = chase(target, visited=set()) 246 if result is not None and result != anchor_id: 247 return result 248 return None 249 250 depths: Dict[str, Optional[int]] = {} 251 in_progress: set = set() 252 253 def depth_of(anchor_id: str) -> Optional[int]: 254 if anchor_id in depths: 255 return depths[anchor_id] 256 tok = by_id[anchor_id] 257 if tok.relatedtoken1 == "root": 258 depths[anchor_id] = 0 259 return 0 260 261 if anchor_id in in_progress: 262 warnings.append( 263 f"cycle detected resolving the governing verbal expression " 264 f"for {anchor_id!r} -- leaving its depth (and its parent's) " 265 f"unresolved" 266 ) 267 return None 268 in_progress.add(anchor_id) 269 270 parent = parent_of(anchor_id) 271 if parent is None: 272 warnings.append( 273 f"could not find a governing verbal expression for " 274 f"{anchor_id!r} -- leaving its depth unresolved" 275 ) 276 result = None 277 else: 278 parent_depth = depth_of(parent) 279 result = None if parent_depth is None else parent_depth + 1 280 281 in_progress.discard(anchor_id) 282 depths[anchor_id] = result 283 return result 284 285 for anchor_id in anchor_ids: 286 depth_of(anchor_id) 287 288 return depths, warnings
Compute each verbal expression's depth of subordination: the number of verbal expressions it is removed from an independent ("root") clause. An independent verb is depth 0; a directly-quoted verb it introduces is depth 1; a verbal expression quoted WITHIN that quote (if the scheme is ever asked to represent one) would be depth 2; and so on.
A "verbal expression" here is any token that anchors one -- i.e. any
token with verbalunitid set to its own id (the same convention
assign_verbal_units() relies on). For each anchor, this function
finds its parent anchor -- the verbal expression it's subordinate to
-- by following the anchor's own relatedtoken1 (falling back to
relatedtoken2) until it lands on another anchor:
- unit verb (independent): relatedtoken1 == 'root' -> no parent, depth 0.
- direct quote: relatedtoken1 -> the verb of the clause that introduces the quotation, directly (no intermediate token to hop through, unlike arsgrammatica's Latin scheme, which has no equivalent of a subordinating-conjunction intermediary for this case either).
Returns ({anchor id: depth or None}, warnings). A depth of None
means the chase from that anchor never reached another anchor (a
malformed or genuinely disconnected verbal expression) or a cycle was
detected; warnings names which anchor(s) and why, rather than
raising.
291def max_subordination_depth( 292 tokengraph: List[TokenAnalysis], 293 depths: Optional[Dict[str, Optional[int]]] = None, 294) -> Optional[int]: 295 """Return the deepest level of subordination reached anywhere in 296 `tokengraph` -- the highest value `compute_subordination_depths()` 297 assigns to any verbal expression. Root/independent clauses are depth 298 0, so this is also the upper end of the valid `depth` range for 299 `rendering.tokengraph_to_depth_html()`'s own `depth` parameter. 300 301 Pass `depths` (the first element of `compute_subordination_depths()`'s 302 return value) if the caller already computed it, to avoid re-deriving 303 it here. 304 305 Returns `None` if `tokengraph` has no verbal expressions at all, or if 306 every anchor's own depth came back unresolved. Otherwise returns the 307 maximum of every RESOLVED anchor's depth, ignoring unresolved ones 308 rather than letting a single bad anchor blank out the whole result. 309 """ 310 if depths is None: 311 depths, _warnings = compute_subordination_depths(tokengraph) 312 313 resolved = [d for d in depths.values() if d is not None] 314 if not resolved: 315 return None 316 return max(resolved)
Return the deepest level of subordination reached anywhere in
tokengraph -- the highest value compute_subordination_depths()
assigns to any verbal expression. Root/independent clauses are depth
0, so this is also the upper end of the valid depth range for
rendering.tokengraph_to_depth_html()'s own depth parameter.
Pass depths (the first element of compute_subordination_depths()'s
return value) if the caller already computed it, to avoid re-deriving
it here.
Returns None if tokengraph has no verbal expressions at all, or if
every anchor's own depth came back unresolved. Otherwise returns the
maximum of every RESOLVED anchor's depth, ignoring unresolved ones
rather than letting a single bad anchor blank out the whole result.
319def find_unanchored_coordinated_verbs(tokengraph: List[TokenAnalysis]) -> List[str]: 320 """Heuristic sanity check for a specific, plausible live-LM mistake: a 321 coordinating conjunction that pairs two verbal expressions (see 322 hebrew_syntax_dspy.py's docstring) is supposed to leave BOTH conjuncts 323 anchoring their own verbal unit -- each with its own `verbalunitid` 324 (and its own `verbalunits` entry). This is NOT the same kind of check 325 as validate() (referential id integrity) or 326 compute_subordination_depths()'s warnings (a resolvable-but-broken 327 relation graph) -- both of those only catch a problem if the 328 tokengraph is already self-inconsistent. This function catches a 329 tokengraph that's perfectly well-formed and internally consistent, but 330 still probably WRONG, by looking for an asymmetry a correct analysis 331 should never produce. 332 333 The heuristic: find every "coordinating conjunction" token that uses 334 BOTH relatedtoken1 and relatedtoken2 (the two-conjunct, single-pair 335 case -- see that relation's own note about the repeated-connector, 336 series exception, which this deliberately ignores below). For each 337 such pair, if EXACTLY ONE of the two joined tokens is a recognized 338 verbal-unit anchor (`verbalunitid` set to its own id) and the other is 339 not, that asymmetry is flagged: if the conjunction is genuinely pairing 340 two nouns/adjectives/prepositional phrases, NEITHER side would be an 341 anchor; if it's correctly pairing two verbal expressions, BOTH sides 342 would be. 343 344 This pairwise shape doesn't apply to a repeated connector coordinating 345 a series of two or more items via the id-chaining convention (see 346 hebrew_syntax_dspy.py's docstring): there, every connector's own 347 relatedtoken2 points at a NEIGHBORING CONNECTOR, not at a second 348 conjunct, so this heuristic's asymmetry check would misfire. A pair is 349 therefore skipped whenever relatedtoken2 resolves to a token that is 350 itself a coordinating-conjunction connector. 351 352 Returns a list of warning strings (empty if nothing looks suspicious). 353 This is a heuristic, not a guarantee. 354 """ 355 by_id = {tok.id: tok for tok in tokengraph} 356 anchor_ids = {tok.id for tok in tokengraph if tok.verbalunitid == tok.id} 357 358 warnings: List[str] = [] 359 seen_pairs = set() 360 361 for tok in tokengraph: 362 if not ( 363 tok.relatedtoken1 is not None 364 and tok.relatedtoken1 != "root" 365 and tok.relationship1 == "coordinating conjunction" 366 and tok.relatedtoken2 is not None 367 and tok.relatedtoken2 != "root" 368 and tok.relationship2 == "coordinating conjunction" 369 ): 370 continue 371 372 pair = (tok.relatedtoken1, tok.relatedtoken2) 373 if pair in seen_pairs: 374 continue 375 seen_pairs.add(pair) 376 377 first_id, second_id = pair 378 second_tok = by_id.get(second_id) 379 if second_tok is not None and ( 380 second_tok.relationship1 == "coordinating conjunction" 381 or second_tok.relationship2 == "coordinating conjunction" 382 ): 383 # relatedtoken2 points at a FELLOW connector, not at a second 384 # conjunct -- the signature of the repeated-connector/series 385 # pattern (see this function's own docstring), where this 386 # heuristic's pairwise-specific asymmetry check doesn't apply. 387 continue 388 389 first_anchored = first_id in anchor_ids 390 second_anchored = second_id in anchor_ids 391 if first_anchored == second_anchored: 392 continue 393 394 anchored_id, unanchored_id = ( 395 (first_id, second_id) if first_anchored else (second_id, first_id) 396 ) 397 anchored_text = by_id[anchored_id].token if anchored_id in by_id else anchored_id 398 unanchored_text = by_id[unanchored_id].token if unanchored_id in by_id else unanchored_id 399 warnings.append( 400 f"{tok.id} ({tok.token!r}) coordinates {anchored_id} " 401 f"({anchored_text!r}), which anchors its own verbal unit, with " 402 f"{unanchored_id} ({unanchored_text!r}), which does not -- if " 403 "this conjunction is meant to join two verbal expressions " 404 "(rather than a noun/adjective/prepositional-phrase pair), " 405 f"{unanchored_id} is likely missing its own verbalunitid and " 406 "'unit verb'/'root' relation." 407 ) 408 409 return warnings
Heuristic sanity check for a specific, plausible live-LM mistake: a
coordinating conjunction that pairs two verbal expressions (see
hebrew_syntax_dspy.py's docstring) is supposed to leave BOTH conjuncts
anchoring their own verbal unit -- each with its own verbalunitid
(and its own verbalunits entry). This is NOT the same kind of check
as validate() (referential id integrity) or
compute_subordination_depths()'s warnings (a resolvable-but-broken
relation graph) -- both of those only catch a problem if the
tokengraph is already self-inconsistent. This function catches a
tokengraph that's perfectly well-formed and internally consistent, but
still probably WRONG, by looking for an asymmetry a correct analysis
should never produce.
The heuristic: find every "coordinating conjunction" token that uses
BOTH relatedtoken1 and relatedtoken2 (the two-conjunct, single-pair
case -- see that relation's own note about the repeated-connector,
series exception, which this deliberately ignores below). For each
such pair, if EXACTLY ONE of the two joined tokens is a recognized
verbal-unit anchor (verbalunitid set to its own id) and the other is
not, that asymmetry is flagged: if the conjunction is genuinely pairing
two nouns/adjectives/prepositional phrases, NEITHER side would be an
anchor; if it's correctly pairing two verbal expressions, BOTH sides
would be.
This pairwise shape doesn't apply to a repeated connector coordinating a series of two or more items via the id-chaining convention (see hebrew_syntax_dspy.py's docstring): there, every connector's own relatedtoken2 points at a NEIGHBORING CONNECTOR, not at a second conjunct, so this heuristic's asymmetry check would misfire. A pair is therefore skipped whenever relatedtoken2 resolves to a token that is itself a coordinating-conjunction connector.
Returns a list of warning strings (empty if nothing looks suspicious). This is a heuristic, not a guarantee.
147def tokengraph_to_text(tokengraph: List[TokenAnalysis]) -> str: 148 """Join `tokengraph`'s tokens into one continuous plain-text string, 149 per this module's docstring. Tokens are read in list order (the same 150 order tokengraph_to_mermaid() and validate() assume).""" 151 preposition_ids = _preposition_ids(tokengraph) 152 pieces: List[str] = [] 153 previous_class = None 154 155 for tok in tokengraph: 156 if tok.tokentype in IMPLIED_TOKENTYPES: 157 continue 158 cls = _classify(tok, preposition_ids) 159 text = tok.token 160 161 if not pieces: 162 pieces.append(text) 163 elif cls in (_LEFT, _GLUED): 164 pieces.append(text) 165 elif cls == _RIGHT: 166 pieces.append(" " + text) 167 else: # _NORMAL 168 if previous_class in (_RIGHT, _GLUED): 169 pieces.append(text) 170 else: 171 pieces.append(" " + text) 172 173 previous_class = cls 174 175 return "".join(pieces)
Join tokengraph's tokens into one continuous plain-text string,
per this module's docstring. Tokens are read in list order (the same
order tokengraph_to_mermaid() and validate() assume).
178def tokengraph_to_html(tokengraph: List[TokenAnalysis], *, include_cantillation: bool = True) -> str: 179 """Render `tokengraph` as an HTML string: the same continuous text 180 `tokengraph_to_text()` produces -- identical spacing rules -- except 181 every **lexical** token, and every **proclitic conjunction** carrying a 182 'coordinating conjunction' relation (relationship1 or relationship2), 183 has its text wrapped in a `<span style="...">` colored by the verbal 184 unit it belongs to. Colors come from `verbal_units.assign_verbal_units()` 185 / `assign_verbal_unit_colors()` -- the same assignment and the same 186 first-appearance palette ordering `tokengraph_to_mermaid()` uses for its 187 node coloring -- so a passage rendered here and the same passage's 188 Mermaid diagram color each verbal unit identically. 189 190 The coordinating-conjunction carve-out exists because the proclitic וְ 191 is tokentype "proclitic conjunction", not "lexical", but 192 `assign_verbal_units()` still resolves it to one of the units it 193 coordinates (see that module's docstring). Leaving it unwrapped would 194 visually hide that assignment even though it's a real one. 195 196 Every other non-lexical, non-conjunction token -- paragraph, editorial, 197 maqaf, and a non-conjunction enclitic pronoun -- is still emitted as 198 plain (escaped) text even though `assign_verbal_units()` assigns every 199 token to whichever unit its relations resolve to; this function just 200 doesn't turn that assignment into a span for anything else. 201 202 `include_cantillation` (default `True`, matching arsgrammatica's own 203 tokengraph_to_html(), which has no equivalent exclusion) controls 204 whether cantillation tokens (te'amim -- e.g. the verse-final sof pasuq 205 ׃) are rendered at all. Pass `include_cantillation=False` to omit them 206 from the output entirely -- not just leave them uncolored, the way the 207 other non-lexical tokentypes above are -- for a reading view that 208 foregrounds the lexical/relational content without the accent marks 209 interspersed. This is a deliberate Hebrew-specific divergence from 210 arsgrammatica: Latin punctuation is sparse enough to leave visible by 211 default, but cantillation is dense enough (in principle -- most marks 212 stay embedded in a lexical token's own niqqud under this project's 213 fixtures; see gold_examples.py's own note on that simplification) that 214 a caller may want it gone rather than merely unhighlighted. 215 216 An **implied/elided token** (models.py's IMPLIED_TOKENTYPES) is omitted 217 entirely -- same as tokengraph_to_text() -- rather than rendered with 218 any span: it has no surface text, and unlike 219 `tokengraph_to_mermaid()`'s diagram (which DOES show these), inserting 220 placeholder text into the middle of reconstructed prose here would 221 misrepresent what the passage actually says. 222 223 Every token's text is HTML-escaped (`&`, `<`, `>`, and quote characters) 224 before being emitted, spans or not. 225 """ 226 assignment = assign_verbal_units(tokengraph) 227 colors, _warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment) 228 return _tokens_to_html(tokengraph, assignment, colors, include_cantillation=include_cantillation)
Render tokengraph as an HTML string: the same continuous text
tokengraph_to_text() produces -- identical spacing rules -- except
every lexical token, and every proclitic conjunction carrying a
'coordinating conjunction' relation (relationship1 or relationship2),
has its text wrapped in a <span style="..."> colored by the verbal
unit it belongs to. Colors come from verbal_units.assign_verbal_units()
/ assign_verbal_unit_colors() -- the same assignment and the same
first-appearance palette ordering tokengraph_to_mermaid() uses for its
node coloring -- so a passage rendered here and the same passage's
Mermaid diagram color each verbal unit identically.
The coordinating-conjunction carve-out exists because the proclitic וְ
is tokentype "proclitic conjunction", not "lexical", but
assign_verbal_units() still resolves it to one of the units it
coordinates (see that module's docstring). Leaving it unwrapped would
visually hide that assignment even though it's a real one.
Every other non-lexical, non-conjunction token -- paragraph, editorial,
maqaf, and a non-conjunction enclitic pronoun -- is still emitted as
plain (escaped) text even though assign_verbal_units() assigns every
token to whichever unit its relations resolve to; this function just
doesn't turn that assignment into a span for anything else.
include_cantillation (default True, matching arsgrammatica's own
tokengraph_to_html(), which has no equivalent exclusion) controls
whether cantillation tokens (te'amim -- e.g. the verse-final sof pasuq
׃) are rendered at all. Pass include_cantillation=False to omit them
from the output entirely -- not just leave them uncolored, the way the
other non-lexical tokentypes above are -- for a reading view that
foregrounds the lexical/relational content without the accent marks
interspersed. This is a deliberate Hebrew-specific divergence from
arsgrammatica: Latin punctuation is sparse enough to leave visible by
default, but cantillation is dense enough (in principle -- most marks
stay embedded in a lexical token's own niqqud under this project's
fixtures; see gold_examples.py's own note on that simplification) that
a caller may want it gone rather than merely unhighlighted.
An implied/elided token (models.py's IMPLIED_TOKENTYPES) is omitted
entirely -- same as tokengraph_to_text() -- rather than rendered with
any span: it has no surface text, and unlike
tokengraph_to_mermaid()'s diagram (which DOES show these), inserting
placeholder text into the middle of reconstructed prose here would
misrepresent what the passage actually says.
Every token's text is HTML-escaped (&, <, >, and quote characters)
before being emitted, spans or not.
306def tokengraph_to_depth_html( 307 tokengraph: List[TokenAnalysis], 308 indent_em: float = _DEFAULT_DEPTH_INDENT_EM, 309 depth: Optional[int] = None, 310) -> Tuple[str, List[str]]: 311 """Render `tokengraph` as HTML illustrating each verbal expression's 312 *depth of subordination* (see verbal_units.compute_subordination_ 313 depths()): tokens are assembled sequentially exactly as 314 tokengraph_to_html() does -- same spacing, escaping, and verbal-unit 315 color highlighting -- but grouped into consecutive-run "blocks" by 316 which verbal unit each token belongs to (per assign_verbal_units()), 317 each rendered as its own <div> indented by a CSS margin-left of 318 `depth * indent_em` em -- 0 for an independent clause, 1 for a directly 319 quoted clause it introduces, and so on. All layout is CSS -- no table 320 or nested-list structure is used to produce the indentation. 321 322 `depth`, if given, caps how deep the rendering goes: ONLY blocks whose 323 own depth of subordination is <= `depth` are included in the output. 324 `depth=0` shows root/independent clauses only; omit `depth` (or pass 325 `None`, the default) to show every block. Valid values run from 0 up 326 to verbal_units.max_subordination_depth()'s own return value for this 327 `tokengraph`; a negative `depth` raises ValueError. 328 329 Block boundaries follow assign_verbal_units()'s token-to-unit 330 assignment, with one adjustment: a token whose own tokentype is one of 331 `_GLUED_TOKENTYPES` (enclitic pronoun, proclitic conjunction, maqaf, 332 cantillation) never starts a new block, even when its own assignment 333 differs from the block currently open -- these all attach with no 334 space to a neighboring word (see this module's own docstring), and 335 starting a new block there would split a Hebrew word (or a 336 maqaf-joined pair) across two <div>s. A token with no verbal-unit 337 assignment at all (None) likewise never starts a new block; it folds 338 into whichever block is currently open. Leading tokens before the 339 first resolvable verbal-unit token (rare) default to depth 0. 340 341 A verbal expression whose depth couldn't be resolved (see 342 compute_subordination_depths()) renders at depth 0 rather than 343 raising, with a warning explaining why. 344 345 Returns (html, warnings), combining assign_verbal_unit_colors()'s 346 warnings (colors repeating past 8 verbal units) and 347 compute_subordination_depths()'s (an unresolved governing verbal 348 expression) -- computed the same way, and returned in full, regardless 349 of whether `depth` filters some blocks out of the rendered `html` 350 itself. 351 """ 352 if depth is not None and depth < 0: 353 raise ValueError(f"depth must be >= 0 (root clauses only), got {depth!r}") 354 355 assignment = assign_verbal_units(tokengraph) 356 colors, color_warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment) 357 depths, depth_warnings = compute_subordination_depths(tokengraph) 358 warnings = color_warnings + depth_warnings 359 360 blocks = [] 361 for tok in tokengraph: 362 unit_id = assignment.get(tok.id) 363 starts_new_block = ( 364 unit_id is not None 365 and tok.tokentype not in _GLUED_TOKENTYPES 366 and (not blocks or blocks[-1][0] != unit_id) 367 ) 368 if starts_new_block: 369 blocks.append((unit_id, [])) 370 elif not blocks: 371 blocks.append((None, [])) 372 blocks[-1][1].append(tok) 373 374 lines = [] 375 for unit_id, block_tokens in blocks: 376 block_depth = depths.get(unit_id) if unit_id is not None else 0 377 if block_depth is None: 378 block_depth = 0 379 if depth is not None and block_depth > depth: 380 continue 381 block_html = _tokens_to_html(block_tokens, assignment, colors) 382 margin_left = block_depth * indent_em 383 lines.append( 384 f'<div style="margin-left: {margin_left}em; margin-bottom: 0.35em;">' 385 f"{block_html}</div>" 386 ) 387 388 return "\n".join(lines), warnings
Render tokengraph as HTML illustrating each verbal expression's
depth of subordination (see verbal_units.compute_subordination_
depths()): tokens are assembled sequentially exactly as
tokengraph_to_html() does -- same spacing, escaping, and verbal-unit
color highlighting -- but grouped into consecutive-run "blocks" by
which verbal unit each token belongs to (per assign_verbal_units()),
each rendered as its own
depth * indent_em em -- 0 for an independent clause, 1 for a directly
quoted clause it introduces, and so on. All layout is CSS -- no table
or nested-list structure is used to produce the indentation.
depth, if given, caps how deep the rendering goes: ONLY blocks whose
own depth of subordination is <= depth are included in the output.
depth=0 shows root/independent clauses only; omit depth (or pass
None, the default) to show every block. Valid values run from 0 up
to verbal_units.max_subordination_depth()'s own return value for this
tokengraph; a negative depth raises ValueError.
Block boundaries follow assign_verbal_units()'s token-to-unit
assignment, with one adjustment: a token whose own tokentype is one of
_GLUED_TOKENTYPES (enclitic pronoun, proclitic conjunction, maqaf,
cantillation) never starts a new block, even when its own assignment
differs from the block currently open -- these all attach with no
space to a neighboring word (see this module's own docstring), and
starting a new block there would split a Hebrew word (or a
maqaf-joined pair) across two
A verbal expression whose depth couldn't be resolved (see compute_subordination_depths()) renders at depth 0 rather than raising, with a warning explaining why.
Returns (html, warnings), combining assign_verbal_unit_colors()'s
warnings (colors repeating past 8 verbal units) and
compute_subordination_depths()'s (an unresolved governing verbal
expression) -- computed the same way, and returned in full, regardless
of whether depth filters some blocks out of the rendered html
itself.
47class SyntaxAnalysis(dspy.Signature): 48 """Analyze the syntax of a passage of Biblical Hebrew according to a 49 two-part scheme: 50 51 (1) a list of verbal expressions. Two constructions count as a verbal 52 expression: every finite verb, and every participle. 53 54 Classify each verbal expression's syntactic type as 'independent' 55 (main/principal -- syntactically independent, its clause coherent 56 by itself) or 'direct quote' (occurring in directly quoted speech 57 introduced by a verb of saying). syntax_model.md documents no other 58 syntactic_type values in this first draft of the scheme -- in 59 particular there is no 'dependent' category yet for a subordinate 60 clause (subordinating conjunctions are listed as "TBA"). A 61 participle is classified the same way: 'independent' unless it 62 occurs within quoted speech. 63 64 Classify each verbal expression's semantic type too (transitive 65 active/transitive passive/intransitive/linking verb). 66 67 (2) a token-by-token dependency graph. For each token, record up to two 68 relations to other tokens (by id), using only these relation 69 labels: 70 71 - unit verb (independent): every INDEPENDENT verb (or participle 72 functioning as one) has relatedtoken1 = the special sentinel 73 string 'root' -- never an actual token id; no real token may be 74 assigned the id 'root' -- and relationship1 = 'unit verb'. 75 Example: in בְּרֵאשִׁית בָּרָא אֱלֹהִים אֵת הַשָּׁמַיִם וְאֵת הָאָרֶץ, the 76 independent verb בָּרָא has relatedtoken1 = 'root', relationship1 = 77 'unit verb'. 78 - direct quote: a verb (or participle) of directly quoted speech 79 has relatedtoken1 -> the id of the verb of the governing verbal 80 expression (the verb of saying that introduces/frames the 81 quotation), relationship1 = 'direct quote' -- matching its own 82 syntactic_type. Example: in וַיֹּאמֶר אֱלֹהִים יְהִי אֹור וַיְהִי־אֹור, 83 the verbal unit anchored at יְהִי is direct speech subordinate to 84 יֹּאמֶר: יְהִי has relatedtoken1 -> יֹּאמֶר's id, relationship1 = 85 'direct quote'. 86 - subject / direct object / predicate: a noun or pronoun serving as 87 the subject of a verbal expression has relatedtoken1 -> the id of 88 the verb, relationship1 = 'subject'. One functioning as direct 89 object has relatedtoken1 -> the verb's id, relationship1 = 90 'direct object'. One functioning as the predicate complement of a 91 LINKING verb (including an elided-'to be' implied token -- see 92 (3) below) has relatedtoken1 -> that verb's id, relationship1 = 93 'predicate'. Example: in Genesis 1.1 (as above), אֱלֹהִים has 94 relatedtoken1 -> בָּרָא's id, relationship1 = 'subject'; שָׁמַיִם and 95 אָרֶץ each have relatedtoken1 -> בָּרָא's id, relationship1 = 'direct 96 object'. 97 - object marker: the direct object marker אֵת itself has 98 relatedtoken1 -> the id of the direct object noun/pronoun it 99 marks, relationship1 = 'object marker'. The marked noun keeps its 100 OWN separate 'direct object' relation to the verb -- this is an 101 additional entry on the marker token, not a replacement for that 102 one. Example: in Genesis 1.1 (as above), the first אֵת has 103 relatedtoken1 -> שָׁמַיִם's id, the second אֵת has relatedtoken1 -> 104 אָרֶץ's id, both relationship1 'object marker'. 105 - coordinating conjunction (single pair): when a coordinating 106 conjunction (the proclitic וְ) joins exactly ONE pair of 107 adjectives, nouns, prepositional phrases, or verbal expressions, 108 it has relatedtoken1 -> the id of the first joined token, 109 relatedtoken2 -> the id of the second, with BOTH relationship1 110 and relationship2 = 'coordinating conjunction' (not an overflow 111 slot here -- this is the one relation that genuinely uses both 112 ends at once). Example: in בְּרֵאשִׁית בָּרָא אֱלֹהִים אֵת הַשָּׁמַיִם 113 וְאֵת הָאָרֶץ, the conjunction וְ (prefixed to אֵת before הָאָרֶץ) has 114 relatedtoken1 -> הַשָּׁמַיִם's id, relatedtoken2 -> הָאָרֶץ's id, both 115 relationship 'coordinating conjunction'. 116 - coordinating conjunction (repeated, as a chain): the proclitic וְ 117 can instead be repeated, prefixed onto EVERY one of a series of 118 two or more coordinated items (most often a chain of narrative 119 wayyiqtol verbs), not just used once between a pair. Annotate 120 this differently from the single-pair case above. Every 121 connector's own relatedtoken1 -> the id of the item it 122 immediately introduces (a real noun, adjective, prepositional 123 phrase, or verbal-expression anchor -- NEVER another connector), 124 relationship1 = 'coordinating conjunction', exactly as in the 125 single-pair case. relatedtoken2 is what differs: the FIRST 126 connector's relatedtoken2 -> the id of the NEXT (second) 127 connector, while every connector AFTER the first has 128 relatedtoken2 -> the id of the PRECEDING connector instead (not 129 the following one); relationship2 = 'coordinating conjunction' 130 for all of them, same as relationship1 -- still not an overflow 131 slot. Each connected item ALSO keeps its own ordinary relation to 132 the rest of the sentence (subject, object of preposition, or 133 whatever fits), completely independent of this chain. Example: 134 in וַיְבָרֶךְ אֱלֹהִים אֶת־יֹום הַשְּׁבִיעִי וַיְקַדֵּשׁ אֹתֹו, two verbal 135 expressions (anchored at בָרֶךְ and קַדֵּשׁ) are coordinated by two 136 instances of וְ: the first וְ (prefixed to יְבָרֶךְ) has 137 relatedtoken1 -> בָרֶךְ's id, relatedtoken2 -> the second וְ's id; 138 the second וְ (prefixed to יְקַדֵּשׁ) has relatedtoken1 -> קַדֵּשׁ's 139 id, relatedtoken2 -> the first וְ's id. בָרֶךְ and קַדֵּשׁ each ALSO 140 have their own relatedtoken1 = 'root', relationship1 = 'unit 141 verb' entries, unaffected by which connector introduces them -- 142 being coordinated by וְ does not exempt either verb from its own 143 normal 'unit verb'/'root' entry. 144 - object of preposition: a noun or pronoun functioning as the 145 object of a preposition has relatedtoken1 -> the id of the 146 preposition, relationship1 = 'object of preposition'. Example: in 147 the phrase בְּאֶרֶץ, אֶרֶץ has relatedtoken1 -> בְּ's id, 148 relationship1 = 'object of preposition'. 149 - article: when the article הַ relates to a noun or adjective, it 150 has relatedtoken1 -> the id of that noun or adjective, 151 relationship1 = 'article'. Example: in הַשָּׁמַיִם, the article has 152 relatedtoken1 -> הַשָּׁמַיִם's own lexical-token id, relationship1 = 153 'article'. 154 - construct: when two nouns stand in a construct relation, the 155 governed (related) noun has relatedtoken1 -> the id of the 156 governing noun, relationship1 = 'construct'; the governing noun 157 is separately recorded according to its own function elsewhere 158 in the sentence. Example: in בְּזֵעַת אַפֶּיךָ תֹּאכַל לֶחֶם, אַפֶּי 159 (governed by זֵעַת) has relatedtoken1 -> זֵעַת's id, relationship1 = 160 'construct'; זֵעַת itself is recorded as the object of the 161 preposition בְּ (relatedtoken1 -> בְּ's id, relationship1 = 'object 162 of preposition'). 163 - adjectival: an adjective has relatedtoken1 -> the id of the noun 164 it modifies, relationship1 = 'adjectival'. Example: in אֲחִיכֶם 165 הַקָּטֹן, קָּטֹן (modifying אֲחִי) has relatedtoken1 -> אֲחִי's id, 166 relationship1 = 'adjectival'. 167 - adverbial: when a prepositional phrase modifies a verb 168 adverbially, the PREPOSITION ITSELF (not its object) has 169 relatedtoken1 -> the id of the verb, relationship1 = 'adverbial'. 170 The preposition's own object is still separately recorded as 171 'object of preposition', exactly as usual -- this is an 172 additional relation on the preposition, on top of its object's 173 own unaffected relation to it. Example: in בְּרֵאשִׁית בָּרָא אֱלֹהִים 174 אֵת הַשָּׁמַיִם וְאֵת הָאָרֶץ, the preposition בְּ (of the adverbial 175 phrase בְּרֵאשִׁית) has relatedtoken1 -> בָּרָא's id, relationship1 = 176 'adverbial'; its own object רֵאשִׁית has relatedtoken1 -> בְּ's id, 177 relationship1 = 'object of preposition', unchanged. 178 179 Only assign relations described above. Leave relatedtoken/ 180 relationship fields unset for tokens with no relation of these 181 kinds -- not every token will have one (syntax_model.md's own "TBA" 182 section names two remaining constructions -- subordinating 183 conjunctions and the relative pronoun אֲשֶׁר -- that have no 184 documented relation at all yet; leave a token unrelated rather than 185 guessing a label for one of these). Use only the token ids given in 186 the input `tokens` list, the sentinel 'root', or a NEW id you 187 create for an implied token (see below), in your output; never 188 invent an id for anything else. 189 190 (3) implied/elided tokens. `diqduq` recognizes one situation where 191 something exists grammatically but has no surface realization in 192 the passage at all: an elided present tense of "to be", which 193 Biblical Hebrew routinely omits from a nominal (verbless) sentence. 194 When this happens, add a NEW entry to `tokengraph` with: a 195 brand-new id, not used by any entry in `tokens` or elsewhere in 196 your own output (see the naming rule below); tokentype 'implied 197 sum'; and no `token` value (leave it unset/None). Also add a 198 matching new entry to `verbalunits`, exactly like any other verbal 199 expression, classified 'independent' (or 'direct quote', if the 200 elided-copula clause is itself directly quoted speech) and 201 'linking verb'. The subject and predicate each relate to this new 202 token exactly as they would to any linking verb ('subject' / 203 'predicate'). Example: in לֹא אֱלֹהִים הֵמָּה ("they are not gods"), 204 the subject is הֵמָּה and the predicate noun is אֱלֹהִים; add a new 205 implied token (tokentype 'implied sum', token=None) anchoring an 206 'independent'/'linking verb' verbal expression, with הֵמָּה related 207 to it as 'subject' and אֱלֹהִים as 'predicate'. 208 209 Naming an implied token's id: append '_implied' to the id of the 210 LAST real token in `tokens` that precedes where the elided "to be" 211 would have stood (or, if the elided word would come before every 212 real token in the sentence, the FIRST real token's id instead). If 213 more than one implied token is ever needed in the same sentence, 214 append '2', '3', ... after '_implied' to keep them unique (e.g. 215 't5_implied', 't5_implied2'). Place the new `tokengraph` entry at 216 the list position where the elided word would have appeared, 217 among the tokens of its own clause. 218 """ 219 220 passage: str = dspy.InputField(desc="The Hebrew passage to analyze, exactly as written.") 221 tokens: List[Token] = dspy.InputField( 222 desc="Pre-segmented tokens of the passage, in order, with fixed ids. Reference these ids in your output; do not create new ones." 223 ) 224 verbalunits: List[VerbalExpression] = dspy.OutputField( 225 desc="One entry per verbal expression (finite verb or participle) in the passage." 226 ) 227 tokengraph: List[TokenAnalysis] = dspy.OutputField( 228 desc=( 229 "One entry per token in `tokens`, in the same order, with its " 230 "type and any relations -- PLUS one additional entry for each " 231 "implied/elided token you add (see this signature's docstring), " 232 "positioned where that token's clause falls in reading order." 233 ) 234 )
Analyze the syntax of a passage of Biblical Hebrew according to a two-part scheme:
(1) a list of verbal expressions. Two constructions count as a verbal expression: every finite verb, and every participle.
Classify each verbal expression's syntactic type as 'independent'
(main/principal -- syntactically independent, its clause coherent
by itself) or 'direct quote' (occurring in directly quoted speech
introduced by a verb of saying). syntax_model.md documents no other
syntactic_type values in this first draft of the scheme -- in
particular there is no 'dependent' category yet for a subordinate
clause (subordinating conjunctions are listed as "TBA"). A
participle is classified the same way: 'independent' unless it
occurs within quoted speech.
Classify each verbal expression's semantic type too (transitive
active/transitive passive/intransitive/linking verb).
(2) a token-by-token dependency graph. For each token, record up to two relations to other tokens (by id), using only these relation labels:
- unit verb (independent): every INDEPENDENT verb (or participle
functioning as one) has relatedtoken1 = the special sentinel
string 'root' -- never an actual token id; no real token may be
assigned the id 'root' -- and relationship1 = 'unit verb'.
Example: in בְּרֵאשִׁית בָּרָא אֱלֹהִים אֵת הַשָּׁמַיִם וְאֵת הָאָרֶץ, the
independent verb בָּרָא has relatedtoken1 = 'root', relationship1 =
'unit verb'.
- direct quote: a verb (or participle) of directly quoted speech
has relatedtoken1 -> the id of the verb of the governing verbal
expression (the verb of saying that introduces/frames the
quotation), relationship1 = 'direct quote' -- matching its own
syntactic_type. Example: in וַיֹּאמֶר אֱלֹהִים יְהִי אֹור וַיְהִי־אֹור,
the verbal unit anchored at יְהִי is direct speech subordinate to
יֹּאמֶר: יְהִי has relatedtoken1 -> יֹּאמֶר's id, relationship1 =
'direct quote'.
- subject / direct object / predicate: a noun or pronoun serving as
the subject of a verbal expression has relatedtoken1 -> the id of
the verb, relationship1 = 'subject'. One functioning as direct
object has relatedtoken1 -> the verb's id, relationship1 =
'direct object'. One functioning as the predicate complement of a
LINKING verb (including an elided-'to be' implied token -- see
(3) below) has relatedtoken1 -> that verb's id, relationship1 =
'predicate'. Example: in Genesis 1.1 (as above), אֱלֹהִים has
relatedtoken1 -> בָּרָא's id, relationship1 = 'subject'; שָׁמַיִם and
אָרֶץ each have relatedtoken1 -> בָּרָא's id, relationship1 = 'direct
object'.
- object marker: the direct object marker אֵת itself has
relatedtoken1 -> the id of the direct object noun/pronoun it
marks, relationship1 = 'object marker'. The marked noun keeps its
OWN separate 'direct object' relation to the verb -- this is an
additional entry on the marker token, not a replacement for that
one. Example: in Genesis 1.1 (as above), the first אֵת has
relatedtoken1 -> שָׁמַיִם's id, the second אֵת has relatedtoken1 ->
אָרֶץ's id, both relationship1 'object marker'.
- coordinating conjunction (single pair): when a coordinating
conjunction (the proclitic וְ) joins exactly ONE pair of
adjectives, nouns, prepositional phrases, or verbal expressions,
it has relatedtoken1 -> the id of the first joined token,
relatedtoken2 -> the id of the second, with BOTH relationship1
and relationship2 = 'coordinating conjunction' (not an overflow
slot here -- this is the one relation that genuinely uses both
ends at once). Example: in בְּרֵאשִׁית בָּרָא אֱלֹהִים אֵת הַשָּׁמַיִם
וְאֵת הָאָרֶץ, the conjunction וְ (prefixed to אֵת before הָאָרֶץ) has
relatedtoken1 -> הַשָּׁמַיִם's id, relatedtoken2 -> הָאָרֶץ's id, both
relationship 'coordinating conjunction'.
- coordinating conjunction (repeated, as a chain): the proclitic וְ
can instead be repeated, prefixed onto EVERY one of a series of
two or more coordinated items (most often a chain of narrative
wayyiqtol verbs), not just used once between a pair. Annotate
this differently from the single-pair case above. Every
connector's own relatedtoken1 -> the id of the item it
immediately introduces (a real noun, adjective, prepositional
phrase, or verbal-expression anchor -- NEVER another connector),
relationship1 = 'coordinating conjunction', exactly as in the
single-pair case. relatedtoken2 is what differs: the FIRST
connector's relatedtoken2 -> the id of the NEXT (second)
connector, while every connector AFTER the first has
relatedtoken2 -> the id of the PRECEDING connector instead (not
the following one); relationship2 = 'coordinating conjunction'
for all of them, same as relationship1 -- still not an overflow
slot. Each connected item ALSO keeps its own ordinary relation to
the rest of the sentence (subject, object of preposition, or
whatever fits), completely independent of this chain. Example:
in וַיְבָרֶךְ אֱלֹהִים אֶת־יֹום הַשְּׁבִיעִי וַיְקַדֵּשׁ אֹתֹו, two verbal
expressions (anchored at בָרֶךְ and קַדֵּשׁ) are coordinated by two
instances of וְ: the first וְ (prefixed to יְבָרֶךְ) has
relatedtoken1 -> בָרֶךְ's id, relatedtoken2 -> the second וְ's id;
the second וְ (prefixed to יְקַדֵּשׁ) has relatedtoken1 -> קַדֵּשׁ's
id, relatedtoken2 -> the first וְ's id. בָרֶךְ and קַדֵּשׁ each ALSO
have their own relatedtoken1 = 'root', relationship1 = 'unit
verb' entries, unaffected by which connector introduces them --
being coordinated by וְ does not exempt either verb from its own
normal 'unit verb'/'root' entry.
- object of preposition: a noun or pronoun functioning as the
object of a preposition has relatedtoken1 -> the id of the
preposition, relationship1 = 'object of preposition'. Example: in
the phrase בְּאֶרֶץ, אֶרֶץ has relatedtoken1 -> בְּ's id,
relationship1 = 'object of preposition'.
- article: when the article הַ relates to a noun or adjective, it
has relatedtoken1 -> the id of that noun or adjective,
relationship1 = 'article'. Example: in הַשָּׁמַיִם, the article has
relatedtoken1 -> הַשָּׁמַיִם's own lexical-token id, relationship1 =
'article'.
- construct: when two nouns stand in a construct relation, the
governed (related) noun has relatedtoken1 -> the id of the
governing noun, relationship1 = 'construct'; the governing noun
is separately recorded according to its own function elsewhere
in the sentence. Example: in בְּזֵעַת אַפֶּיךָ תֹּאכַל לֶחֶם, אַפֶּי
(governed by זֵעַת) has relatedtoken1 -> זֵעַת's id, relationship1 =
'construct'; זֵעַת itself is recorded as the object of the
preposition בְּ (relatedtoken1 -> בְּ's id, relationship1 = 'object
of preposition').
- adjectival: an adjective has relatedtoken1 -> the id of the noun
it modifies, relationship1 = 'adjectival'. Example: in אֲחִיכֶם
הַקָּטֹן, קָּטֹן (modifying אֲחִי) has relatedtoken1 -> אֲחִי's id,
relationship1 = 'adjectival'.
- adverbial: when a prepositional phrase modifies a verb
adverbially, the PREPOSITION ITSELF (not its object) has
relatedtoken1 -> the id of the verb, relationship1 = 'adverbial'.
The preposition's own object is still separately recorded as
'object of preposition', exactly as usual -- this is an
additional relation on the preposition, on top of its object's
own unaffected relation to it. Example: in בְּרֵאשִׁית בָּרָא אֱלֹהִים
אֵת הַשָּׁמַיִם וְאֵת הָאָרֶץ, the preposition בְּ (of the adverbial
phrase בְּרֵאשִׁית) has relatedtoken1 -> בָּרָא's id, relationship1 =
'adverbial'; its own object רֵאשִׁית has relatedtoken1 -> בְּ's id,
relationship1 = 'object of preposition', unchanged.
Only assign relations described above. Leave relatedtoken/
relationship fields unset for tokens with no relation of these
kinds -- not every token will have one (syntax_model.md's own "TBA"
section names two remaining constructions -- subordinating
conjunctions and the relative pronoun אֲשֶׁר -- that have no
documented relation at all yet; leave a token unrelated rather than
guessing a label for one of these). Use only the token ids given in
the input `tokens` list, the sentinel 'root', or a NEW id you
create for an implied token (see below), in your output; never
invent an id for anything else.
(3) implied/elided tokens. diqduq recognizes one situation where
something exists grammatically but has no surface realization in
the passage at all: an elided present tense of "to be", which
Biblical Hebrew routinely omits from a nominal (verbless) sentence.
When this happens, add a NEW entry to tokengraph with: a
brand-new id, not used by any entry in tokens or elsewhere in
your own output (see the naming rule below); tokentype 'implied
sum'; and no token value (leave it unset/None). Also add a
matching new entry to verbalunits, exactly like any other verbal
expression, classified 'independent' (or 'direct quote', if the
elided-copula clause is itself directly quoted speech) and
'linking verb'. The subject and predicate each relate to this new
token exactly as they would to any linking verb ('subject' /
'predicate'). Example: in לֹא אֱלֹהִים הֵמָּה ("they are not gods"),
the subject is הֵמָּה and the predicate noun is אֱלֹהִים; add a new
implied token (tokentype 'implied sum', token=None) anchoring an
'independent'/'linking verb' verbal expression, with הֵמָּה related
to it as 'subject' and אֱלֹהִים as 'predicate'.
Naming an implied token's id: append '_implied' to the id of the
LAST real token in `tokens` that precedes where the elided "to be"
would have stood (or, if the elided word would come before every
real token in the sentence, the FIRST real token's id instead). If
more than one implied token is ever needed in the same sentence,
append '2', '3', ... after '_implied' to keep them unique (e.g.
't5_implied', 't5_implied2'). Place the new `tokengraph` entry at
the list position where the elided word would have appeared,
among the tokens of its own clause.
80def analyze_passage(passage: str, citation: str = "") -> Tuple[List[Sentence], list]: 81 """Convenience wrapper for the common case of a single string rather 82 than a list of citation-labeled CitedText sources -- kept here so 83 callers (diqduq_main.py) have a one-string entry point rather than 84 needing to build a CitedText list themselves for the ordinary case of 85 one passage from one source. 86 87 Wraps `passage` as one CitedText (using `citation` if given, else an 88 empty string -- fine for callers that don't track citations) and runs 89 it through analyze_sources(). Returns (sentences, results) -- one entry 90 per sentence segmentation finds in `passage`, in order (typically one 91 per verse -- see segmentation_dspy.SegmentPassage's docstring). 92 """ 93 return analyze_sources([CitedText(citation=citation, text=passage)])
Convenience wrapper for the common case of a single string rather than a list of citation-labeled CitedText sources -- kept here so callers (diqduq_main.py) have a one-string entry point rather than needing to build a CitedText list themselves for the ordinary case of one passage from one source.
Wraps passage as one CitedText (using citation if given, else an
empty string -- fine for callers that don't track citations) and runs
it through analyze_sources(). Returns (sentences, results) -- one entry
per sentence segmentation finds in passage, in order (typically one
per verse -- see segmentation_dspy.SegmentPassage's docstring).
244def validate(tokens: List[Token], result) -> List[str]: 245 """Check that every id the LM produced actually exists among `tokens` 246 -- OR is a legitimately new implied token (tokentype in 247 IMPLIED_TOKENTYPES -- currently just 'implied sum'; see 248 SyntaxAnalysis's docstring) -- and that implied tokens themselves are 249 well-formed. Returns a list of human-readable problem descriptions 250 (empty if clean). 251 252 'root' is a special sentinel value for an independent verb's own 253 relatedtoken1 (see SyntaxAnalysis's docstring) -- it is never treated as 254 an unknown id, but syntax_model.md also requires that no actual token 255 ever be assigned the id 'root', so that's checked here too. 256 257 Implied tokens get their own, narrower checks: a tokengraph entry 258 claiming an IMPLIED_TOKENTYPES value must use a genuinely NEW id (not 259 one already in `tokens`) and must leave `token` unset (None). A 260 non-implied entry, conversely, must use one of `tokens`' own ids and 261 must NOT have `token=None`. This check is purely structural -- it does 262 NOT also require an 'implied sum' token to have a matching 263 `verbalunits` entry; that distinction is documented behavior (see 264 SyntaxAnalysis's docstring), not something this function enforces.""" 265 valid_ids = {t.id for t in tokens} 266 problems = [] 267 268 if "root" in valid_ids: 269 problems.append( 270 "token id 'root' is reserved as the sentinel relatedtoken1 " 271 "value for independent verbs and must not be assigned to an " 272 "actual token" 273 ) 274 275 implied_ids = {tok.id for tok in result.tokengraph if tok.tokentype in IMPLIED_TOKENTYPES} 276 known_ids = valid_ids | implied_ids 277 278 for tok in result.tokengraph: 279 if tok.tokentype in IMPLIED_TOKENTYPES: 280 if tok.id in valid_ids: 281 problems.append( 282 f"tokengraph entry {tok.id!r} is tokentype={tok.tokentype!r} but " 283 "reuses an id already in the input `tokens` list -- an " 284 "implied token must use a new id" 285 ) 286 if tok.token is not None: 287 problems.append( 288 f"tokengraph entry {tok.id!r} is tokentype={tok.tokentype!r} but " 289 f"has a non-None token value {tok.token!r} -- an implied " 290 "token's text must be left unset" 291 ) 292 else: 293 if tok.id not in valid_ids: 294 problems.append(f"tokengraph entry has unknown id {tok.id!r}") 295 if tok.token is None: 296 allowed = "/".join(repr(t) for t in sorted(IMPLIED_TOKENTYPES)) 297 problems.append( 298 f"tokengraph entry {tok.id!r} has token=None but " 299 f"tokentype={tok.tokentype!r} -- only {allowed} may " 300 "omit surface text" 301 ) 302 for field in ("relatedtoken1", "relatedtoken2"): 303 val = getattr(tok, field) 304 if val is not None and val != "root" and val not in known_ids: 305 problems.append(f"token {tok.id!r} {field}={val!r} is not a known token id") 306 307 for vu in result.verbalunits: 308 if vu.id not in known_ids: 309 problems.append(f"verbal expression id {vu.id!r} is not a known token id") 310 311 return problems
Check that every id the LM produced actually exists among tokens
-- OR is a legitimately new implied token (tokentype in
IMPLIED_TOKENTYPES -- currently just 'implied sum'; see
SyntaxAnalysis's docstring) -- and that implied tokens themselves are
well-formed. Returns a list of human-readable problem descriptions
(empty if clean).
'root' is a special sentinel value for an independent verb's own relatedtoken1 (see SyntaxAnalysis's docstring) -- it is never treated as an unknown id, but syntax_model.md also requires that no actual token ever be assigned the id 'root', so that's checked here too.
Implied tokens get their own, narrower checks: a tokengraph entry
claiming an IMPLIED_TOKENTYPES value must use a genuinely NEW id (not
one already in tokens) and must leave token unset (None). A
non-implied entry, conversely, must use one of tokens' own ids and
must NOT have token=None. This check is purely structural -- it does
NOT also require an 'implied sum' token to have a matching
verbalunits entry; that distinction is documented behavior (see
SyntaxAnalysis's docstring), not something this function enforces.
314def print_analysis(tokens: List[Token], result): 315 print("Tokens:") 316 for t in tokens: 317 print(f" {t.id:>4} {t.text}") 318 319 print("\nVerbal expressions:") 320 for vu in result.verbalunits: 321 print(f" id={vu.id} syntactic_type={vu.syntactic_type} semantic_type={vu.semantic_type}") 322 323 print("\nToken graph:") 324 for tok in result.tokengraph: 325 rels = [] 326 if tok.relationship1: 327 rels.append(f"{tok.relationship1} -> {tok.relatedtoken1}") 328 if tok.relationship2: 329 rels.append(f"{tok.relationship2} -> {tok.relatedtoken2}") 330 rel_str = "; ".join(rels) if rels else "-" 331 vu_str = f" [verbal unit {tok.verbalunitid}]" if tok.verbalunitid else "" 332 token_str = tok.token if tok.token is not None else f"({tok.tokentype})" 333 print(f" {tok.id:>4} {token_str:<15} type={tok.tokentype:<19} lemma={tok.lemma or '-':<15} {rel_str}{vu_str}")
32class SegmentPassage(dspy.Signature): 33 """Segment a sequence of citation-labeled Biblical Hebrew source units 34 into sentences, and each sentence into tokens, following 35 syntax_model.md's tokenization scheme. 36 37 `sources` is given in reading order; treat its units' text as one 38 continuous passage for sentence-splitting purposes. Every token you 39 produce must carry the `citation` of whichever `sources` unit its 40 surface text came from, even for a sentence that spans more than one 41 unit. 42 43 - A "sentence" here corresponds to one verse-level unit of analysis: 44 split primarily at a *sof pasuq* (the cantillation mark ׃ that ends a 45 Masoretic verse), and also before a paragraph marker (פ/ס) that 46 follows one. A verse may contain more than one independent verbal 47 expression -- e.g. a chain of narrative wayyiqtol clauses joined by 48 repeated וְ (see hebrew_syntax_dspy.SyntaxAnalysis's docstring on 49 coordinating conjunctions), or a framing verb of speech together with 50 its directly quoted content -- all of that still belongs to ONE 51 sentence, analyzed together in a single SyntaxAnalysis call, exactly 52 as syntax_model.md's own worked examples do (e.g. Genesis 1.3, where 53 וַיֹּאמֶר and the direct quote יְהִי אֹור and the notice-of-fulfillment 54 וַיְהִי־אֹור are all one unit). Do not split a sentence at a clause 55 boundary just because a new independent verb or a quotation begins -- 56 only at a *sof pasuq* (or, if a verse genuinely contains no internal 57 sof pasuq at all and none is expected before the passage ends, at the 58 passage's own end). 59 60 - Within each sentence, segment tokens as: *cantillation* (any of the 61 te'amim -- e.g. sof pasuq ׃, silluq, atnach), *paragraph* (a 62 standalone פ or ס marking a semantic division), *enclitic pronoun* 63 (a pronoun bound as the object of a preposition or verb, or as a 64 possessive with a noun), *proclitic conjunction* (specifically the 65 conjunction וְ, including its vocalized forms such as וַ/וּ/וִ before a 66 following consonant/vowel), *maqaf* (the joining hyphen ־), *lexical* 67 (a continuous alphabetic sequence together with its own niqqud, 68 dagesh, mappiq, and sin/shin dot -- but never cantillation marks, 69 which are always their own separate token), or *editorial* (any 70 other Unicode punctuation character or editorial mark, such as the 71 masora circle). 72 73 - A lexical word that is itself prefixed with the article הַ (or its 74 vocalized variants, e.g. הָ before a guttural) or with an inseparable 75 preposition (בְּ/כְּ/לְ) IS split from that prefix: the article or 76 preposition becomes its own *lexical* token immediately before the 77 noun/verb it attaches to, even though nothing separates them in the 78 unpointed surface text. This is required so that relations such as 79 "article" and "object of preposition" (see 80 hebrew_syntax_dspy.SyntaxAnalysis's docstring) have a token of their 81 own to attach to. For example הַשָּׁמַיִם is TWO lexical tokens, הַ 82 then שָׁמַיִם, not one fused token; likewise בְּרֵאשִׁית is TWO 83 lexical tokens, בְּ then רֵאשִׁית. (The proclitic conjunction וְ and 84 the enclitic pronoun suffixes are split the same way, but are tagged 85 with their own tokentypes -- *proclitic conjunction* and *enclitic 86 pronoun* -- rather than *lexical*.) 87 88 - Assign token ids sequentially across the WHOLE input, in reading 89 order: t0, t1, t2, .... Do not restart numbering at each sentence or 90 at each source unit. Every token, across every sentence and every 91 source unit, has a unique id, and running this on the same `sources` 92 again must produce the same ids for the same tokens. 93 """ 94 95 sources: List[CitedText] = dspy.InputField( 96 desc="Citation-labeled source units, in reading order, to segment as one continuous passage." 97 ) 98 sentences: List[Sentence] = dspy.OutputField( 99 desc="The sentences found across all of `sources`, in order. Token ids are global (see instructions); each token's `citation` names the source unit it came from." 100 )
Segment a sequence of citation-labeled Biblical Hebrew source units into sentences, and each sentence into tokens, following syntax_model.md's tokenization scheme.
sources is given in reading order; treat its units' text as one
continuous passage for sentence-splitting purposes. Every token you
produce must carry the citation of whichever sources unit its
surface text came from, even for a sentence that spans more than one
unit.
A "sentence" here corresponds to one verse-level unit of analysis: split primarily at a sof pasuq (the cantillation mark ׃ that ends a Masoretic verse), and also before a paragraph marker (פ/ס) that follows one. A verse may contain more than one independent verbal expression -- e.g. a chain of narrative wayyiqtol clauses joined by repeated וְ (see hebrew_syntax_dspy.SyntaxAnalysis's docstring on coordinating conjunctions), or a framing verb of speech together with its directly quoted content -- all of that still belongs to ONE sentence, analyzed together in a single SyntaxAnalysis call, exactly as syntax_model.md's own worked examples do (e.g. Genesis 1.3, where וַיֹּאמֶר and the direct quote יְהִי אֹור and the notice-of-fulfillment וַיְהִי־אֹור are all one unit). Do not split a sentence at a clause boundary just because a new independent verb or a quotation begins -- only at a sof pasuq (or, if a verse genuinely contains no internal sof pasuq at all and none is expected before the passage ends, at the passage's own end).
Within each sentence, segment tokens as: cantillation (any of the te'amim -- e.g. sof pasuq ׃, silluq, atnach), paragraph (a standalone פ or ס marking a semantic division), enclitic pronoun (a pronoun bound as the object of a preposition or verb, or as a possessive with a noun), proclitic conjunction (specifically the conjunction וְ, including its vocalized forms such as וַ/וּ/וִ before a following consonant/vowel), maqaf (the joining hyphen ־), lexical (a continuous alphabetic sequence together with its own niqqud, dagesh, mappiq, and sin/shin dot -- but never cantillation marks, which are always their own separate token), or editorial (any other Unicode punctuation character or editorial mark, such as the masora circle).
A lexical word that is itself prefixed with the article הַ (or its vocalized variants, e.g. הָ before a guttural) or with an inseparable preposition (בְּ/כְּ/לְ) IS split from that prefix: the article or preposition becomes its own lexical token immediately before the noun/verb it attaches to, even though nothing separates them in the unpointed surface text. This is required so that relations such as "article" and "object of preposition" (see hebrew_syntax_dspy.SyntaxAnalysis's docstring) have a token of their own to attach to. For example הַשָּׁמַיִם is TWO lexical tokens, הַ then שָׁמַיִם, not one fused token; likewise בְּרֵאשִׁית is TWO lexical tokens, בְּ then רֵאשִׁית. (The proclitic conjunction וְ and the enclitic pronoun suffixes are split the same way, but are tagged with their own tokentypes -- proclitic conjunction and enclitic pronoun -- rather than lexical.)
Assign token ids sequentially across the WHOLE input, in reading order: t0, t1, t2, .... Do not restart numbering at each sentence or at each source unit. Every token, across every sentence and every source unit, has a unique id, and running this on the same
sourcesagain must produce the same ids for the same tokens.
106def segment_sources(sources: List[CitedText]) -> List[Sentence]: 107 """Run the segmentation stage and return its sentences.""" 108 result = segment(sources=sources) 109 return result.sentences
Run the segmentation stage and return its sentences.
34def analyze_sources(sources: List[CitedText]) -> Tuple[List[Sentence], list]: 35 """Segment `sources` into citation-aware sentences, run each sentence's 36 tokens through SyntaxAnalysis, and validate each result. 37 38 Returns (sentences, results): results[i] is the SyntaxAnalysis result 39 for sentences[i], same order, one entry per sentence. 40 41 Segmentation itself goes through `token_budget.segment_with_retry()` 42 rather than calling `segmentation_dspy.segment()` directly, and each 43 sentence's SyntaxAnalysis call goes through 44 `token_budget.analyze_with_retry()` rather than calling `analyze()` 45 directly -- both stages get an estimated, appropriately-sized `max_tokens` 46 budget up front, and a retry with a larger one if either still comes 47 back truncated -- see token_budget.py's module docstring for the full 48 design (and for why segmentation needed this covered explicitly, unlike 49 arsgrammatica's own pipeline.py). 50 """ 51 sentences = segment_with_retry(sources) 52 53 results = [] 54 for sentence in sentences: 55 result = analyze_with_retry(passage=_render_sentence_text(sentence), tokens=sentence.tokens) 56 57 problems = validate(sentence.tokens, result) 58 if problems: 59 first_id = sentence.tokens[0].id if sentence.tokens else "?" 60 print(f"Validation warnings (sentence starting at {first_id}):") 61 for p in problems: 62 print(f" - {p}") 63 64 results.append(result) 65 66 return sentences, results
Segment sources into citation-aware sentences, run each sentence's
tokens through SyntaxAnalysis, and validate each result.
Returns (sentences, results): results[i] is the SyntaxAnalysis result for sentences[i], same order, one entry per sentence.
Segmentation itself goes through token_budget.segment_with_retry()
rather than calling segmentation_dspy.segment() directly, and each
sentence's SyntaxAnalysis call goes through
token_budget.analyze_with_retry() rather than calling analyze()
directly -- both stages get an estimated, appropriately-sized max_tokens
budget up front, and a retry with a larger one if either still comes
back truncated -- see token_budget.py's module docstring for the full
design (and for why segmentation needed this covered explicitly, unlike
arsgrammatica's own pipeline.py).
69def combined_tokengraph(results) -> list: 70 """Concatenate every sentence result's tokengraph, in order, into one 71 flat list spanning the whole input -- since token ids are global, 72 tokengraph_to_mermaid() (mermaid.py) needs no changes at all to render 73 this as one diagram for a multi-sentence, multi-citation passage.""" 74 combined = [] 75 for result in results: 76 combined.extend(result.tokengraph) 77 return combined
Concatenate every sentence result's tokengraph, in order, into one flat list spanning the whole input -- since token ids are global, tokengraph_to_mermaid() (mermaid.py) needs no changes at all to render this as one diagram for a multi-sentence, multi-citation passage.
131def serialize_analyses( 132 sentences: List[Sentence], 133 verbalunits: List[VerbalExpression], 134 tokengraph: List[TokenAnalysis], 135) -> Tuple[str, List[str]]: 136 """Build the exact text write_analyses() would write to a file, and 137 return it directly as `(content, warnings)` instead of writing it 138 anywhere. All three lists are flat and span however many 139 sentences/citation sources were analyzed -- the same shape 140 analyze_sources() (for `sentences`) and combined_tokengraph() (for 141 `tokengraph`; `verbalunits` needs the analogous concatenation) already 142 produce. 143 144 `content` is the complete file body, including its trailing newline. 145 `warnings` is a list of warning strings (empty if nothing looks wrong): 146 147 - a tokengraph or verbalunits entry whose id isn't found among any 148 given sentence's tokens (so no citation is known for it) -- EXCEPT 149 for an implied token (tokentype in IMPLIED_TOKENTYPES), which never 150 appears in any sentence's own `tokens` by design; 151 - a sentence whose own tokens don't form a contiguous, matching-order 152 run in `tokengraph`'s given order. 153 154 Raises ValueError for a sentence with no tokens at all, or if any 155 field value contains '|' or a newline (see `_field`). 156 """ 157 warnings: List[str] = [] 158 159 id_to_citation: Dict[str, Optional[str]] = {} 160 for sentence in sentences: 161 for tok in sentence.tokens: 162 id_to_citation[tok.id] = tok.citation 163 164 implied_ids = {tok.id for tok in tokengraph if tok.tokentype in IMPLIED_TOKENTYPES} 165 166 tg_index = {tok.id: i for i, tok in enumerate(tokengraph)} 167 168 lines: List[str] = [] 169 170 lines.append(SENTENCES_LABEL) 171 lines.append(SENTENCES_HEADER) 172 for s_idx, sentence in enumerate(sentences): 173 if not sentence.tokens: 174 raise ValueError( 175 f"sentence at index {s_idx} has no tokens -- cannot derive " 176 "first_token/last_token for an empty sentence" 177 ) 178 first_tok = sentence.tokens[0] 179 last_tok = sentence.tokens[-1] 180 181 first_pos = tg_index.get(first_tok.id) 182 last_pos = tg_index.get(last_tok.id) 183 if first_pos is None or last_pos is None: 184 warnings.append( 185 f"sentence at index {s_idx} (tokens {first_tok.id!r}.." 186 f"{last_tok.id!r}) has a boundary token not present in the " 187 "given tokengraph -- reading this file back may not " 188 "reconstruct this sentence's tokens correctly" 189 ) 190 else: 191 expected_ids = [t.id for t in sentence.tokens] 192 actual_ids = [ 193 tok.id 194 for tok in tokengraph[first_pos : last_pos + 1] 195 if tok.tokentype not in IMPLIED_TOKENTYPES 196 ] 197 if actual_ids != expected_ids: 198 warnings.append( 199 f"sentence at index {s_idx} (tokens {first_tok.id!r}.." 200 f"{last_tok.id!r}) is not a contiguous, matching-order " 201 "run in the given tokengraph -- reading this file back " 202 "may not reconstruct this sentence's tokens correctly" 203 ) 204 205 where = f"#!sentences row for sentence {s_idx}" 206 lines.append( 207 "|".join( 208 [ 209 _field(first_tok.citation, where=where), 210 _field(first_tok.id, where=where), 211 _field(last_tok.citation, where=where), 212 _field(last_tok.id, where=where), 213 ] 214 ) 215 ) 216 217 lines.append("") 218 lines.append(VERBAL_UNITS_LABEL) 219 lines.append(VERBAL_UNITS_HEADER) 220 for vu in verbalunits: 221 if vu.id not in id_to_citation and vu.id not in implied_ids: 222 warnings.append( 223 f"verbal expression {vu.id!r} not found among the given " 224 "sentences' tokens -- writing an empty context for it" 225 ) 226 where = f"#!verbal_units row for {vu.id}" 227 lines.append( 228 "|".join( 229 [ 230 _field(id_to_citation.get(vu.id), where=where), 231 _field(vu.id, where=where), 232 _field(vu.syntactic_type, where=where), 233 _field(vu.semantic_type, where=where), 234 ] 235 ) 236 ) 237 238 lines.append("") 239 lines.append(TOKENS_LABEL) 240 lines.append(TOKENS_HEADER) 241 for tok in tokengraph: 242 if tok.id not in id_to_citation and tok.id not in implied_ids: 243 warnings.append( 244 f"token {tok.id!r} not found among the given sentences' " 245 "tokens -- writing an empty context for it" 246 ) 247 where = f"#!tokens row for {tok.id}" 248 lines.append( 249 "|".join( 250 [ 251 _field(id_to_citation.get(tok.id), where=where), 252 _field(tok.id, where=where), 253 _field(tok.tokentype, where=where), 254 _field(tok.token, where=where), 255 _field(tok.lemma, where=where), 256 _field(tok.verbalunitid, where=where), 257 _field(tok.relatedtoken1, where=where), 258 _field(tok.relationship1, where=where), 259 _field(tok.relatedtoken2, where=where), 260 _field(tok.relationship2, where=where), 261 ] 262 ) 263 ) 264 265 return "\n".join(lines) + "\n", warnings
Build the exact text write_analyses() would write to a file, and
return it directly as (content, warnings) instead of writing it
anywhere. All three lists are flat and span however many
sentences/citation sources were analyzed -- the same shape
analyze_sources() (for sentences) and combined_tokengraph() (for
tokengraph; verbalunits needs the analogous concatenation) already
produce.
content is the complete file body, including its trailing newline.
warnings is a list of warning strings (empty if nothing looks wrong):
- a tokengraph or verbalunits entry whose id isn't found among any
given sentence's tokens (so no citation is known for it) -- EXCEPT
for an implied token (tokentype in IMPLIED_TOKENTYPES), which never
appears in any sentence's own
tokensby design; - a sentence whose own tokens don't form a contiguous, matching-order
run in
tokengraph's given order.
Raises ValueError for a sentence with no tokens at all, or if any
field value contains '|' or a newline (see _field).
268def write_analyses( 269 sentences: List[Sentence], 270 verbalunits: List[VerbalExpression], 271 tokengraph: List[TokenAnalysis], 272 path: str, 273) -> List[str]: 274 """Write `sentences`/`verbalunits`/`tokengraph` to `path` in the format 275 this module's docstring describes -- see serialize_analyses() (which 276 this is a thin wrapper around) for what's actually written and for the 277 full list of warnings this can return. 278 279 Returns a list of warning strings (empty if nothing looks wrong). 280 Raises ValueError for a sentence with no tokens at all, or if any field 281 value contains '|' or a newline -- both raised by serialize_analyses() 282 before this function ever opens `path`. 283 """ 284 content, warnings = serialize_analyses(sentences, verbalunits, tokengraph) 285 with open(path, "w", encoding="utf-8") as f: 286 f.write(content) 287 return warnings
Write sentences/verbalunits/tokengraph to path in the format
this module's docstring describes -- see serialize_analyses() (which
this is a thin wrapper around) for what's actually written and for the
full list of warnings this can return.
Returns a list of warning strings (empty if nothing looks wrong).
Raises ValueError for a sentence with no tokens at all, or if any field
value contains '|' or a newline -- both raised by serialize_analyses()
before this function ever opens path.
290def read_analyses( 291 path: str, 292) -> Tuple[List[TokenAnalysis], List[VerbalExpression], List[Sentence]]: 293 """Read `path` (as written by write_analyses()/serialize_analyses()) and 294 reconstruct `(tokengraph, verbalunits, sentences)` -- in that order. 295 296 Each of the three block labels may appear more than once in `path` -- 297 every instance contributes its own rows, in file order, to that 298 label's combined row list. 299 300 Raises ValueError, naming the offending line and problem, for anything 301 that isn't a faithful, internally-consistent file written by 302 write_analyses(). 303 """ 304 with open(path, "r", encoding="utf-8") as f: 305 raw_lines = f.read().splitlines() 306 307 blocks: Dict[str, List[Tuple[int, str]]] = {label: [] for label in _EXPECTED_HEADERS} 308 seen_labels = set() 309 current_label: Optional[str] = None 310 awaiting_header = False 311 312 for line_no, line in enumerate(raw_lines, start=1): 313 if line.strip() == "": 314 continue 315 316 if line in _EXPECTED_HEADERS: 317 if awaiting_header: 318 raise ValueError( 319 f"line {line_no}: block {current_label!r} has a label " 320 "line but no header line before the next block starts" 321 ) 322 current_label = line 323 seen_labels.add(line) 324 awaiting_header = True 325 continue 326 327 if current_label is None: 328 raise ValueError( 329 f"line {line_no}: data line {line!r} appears before any " 330 "'#!' block label" 331 ) 332 333 if awaiting_header: 334 expected = _EXPECTED_HEADERS[current_label] 335 if line != expected: 336 raise ValueError( 337 f"line {line_no}: expected header {expected!r} for " 338 f"block {current_label!r}, got {line!r}" 339 ) 340 awaiting_header = False 341 continue 342 343 blocks[current_label].append((line_no, line)) 344 345 missing = sorted(set(_EXPECTED_HEADERS) - seen_labels) 346 if missing: 347 raise ValueError(f"file is missing required block(s): {missing}") 348 if awaiting_header: 349 raise ValueError( 350 f"block {current_label!r} has a label line but no header line " 351 "(and no data) -- the file ends too early" 352 ) 353 354 tokengraph: List[TokenAnalysis] = [] 355 id_to_citation: Dict[str, Optional[str]] = {} 356 row_order: List[str] = [] 357 358 for line_no, line in blocks[TOKENS_LABEL]: 359 parts = line.split("|") 360 if len(parts) != 10: 361 raise ValueError( 362 f"line {line_no}: #!tokens row has {len(parts)} columns, " 363 f"expected 10: {line!r}" 364 ) 365 ( 366 context, 367 tok_id, 368 tokentype, 369 text, 370 lemma, 371 verbalunit, 372 related1, 373 relationship1, 374 related2, 375 relationship2, 376 ) = parts 377 if tok_id == "": 378 raise ValueError(f"line {line_no}: #!tokens row has an empty id") 379 if tok_id in id_to_citation: 380 raise ValueError(f"line {line_no}: duplicate token id {tok_id!r} in #!tokens") 381 382 tokengraph.append( 383 TokenAnalysis( 384 id=tok_id, 385 token=_parse_optional(text), 386 tokentype=tokentype, 387 lemma=_parse_optional(lemma), 388 verbalunitid=_parse_optional(verbalunit), 389 relatedtoken1=_parse_optional(related1), 390 relationship1=_parse_optional(relationship1), 391 relatedtoken2=_parse_optional(related2), 392 relationship2=_parse_optional(relationship2), 393 ) 394 ) 395 id_to_citation[tok_id] = _parse_optional(context) 396 row_order.append(tok_id) 397 398 id_position = {tid: i for i, tid in enumerate(row_order)} 399 400 verbalunits: List[VerbalExpression] = [] 401 for line_no, line in blocks[VERBAL_UNITS_LABEL]: 402 parts = line.split("|") 403 if len(parts) != 4: 404 raise ValueError( 405 f"line {line_no}: #!verbal_units row has {len(parts)} " 406 f"columns, expected 4: {line!r}" 407 ) 408 context, vu_id, syntactic_type, semantic_type = parts 409 if vu_id == "": 410 raise ValueError(f"line {line_no}: #!verbal_units row has an empty token id") 411 if vu_id not in id_to_citation: 412 raise ValueError( 413 f"line {line_no}: #!verbal_units references token id " 414 f"{vu_id!r}, which does not appear in the #!tokens block" 415 ) 416 recorded_context = _parse_optional(context) 417 expected_context = id_to_citation[vu_id] 418 if recorded_context != expected_context: 419 raise ValueError( 420 f"line {line_no}: #!verbal_units row's context " 421 f"{recorded_context!r} for token {vu_id!r} does not match " 422 f"the #!tokens block's recorded context {expected_context!r} " 423 "for the same id" 424 ) 425 426 verbalunits.append( 427 VerbalExpression( 428 id=vu_id, 429 syntactic_type=syntactic_type, 430 semantic_type=semantic_type, 431 ) 432 ) 433 434 sentences: List[Sentence] = [] 435 for line_no, line in blocks[SENTENCES_LABEL]: 436 parts = line.split("|") 437 if len(parts) != 4: 438 raise ValueError( 439 f"line {line_no}: #!sentences row has {len(parts)} " 440 f"columns, expected 4: {line!r}" 441 ) 442 context_begin, first_id, context_end, last_id = parts 443 if first_id == "" or last_id == "": 444 raise ValueError( 445 f"line {line_no}: #!sentences row is missing first_token " 446 f"or last_token: {line!r}" 447 ) 448 if first_id not in id_position or last_id not in id_position: 449 raise ValueError( 450 f"line {line_no}: #!sentences references a first_token/" 451 "last_token id not found in the #!tokens block" 452 ) 453 454 start = id_position[first_id] 455 end = id_position[last_id] 456 if start > end: 457 raise ValueError( 458 f"line {line_no}: #!sentences row's first_token " 459 f"{first_id!r} comes after last_token {last_id!r} in the " 460 "#!tokens block's row order" 461 ) 462 463 parsed_begin = _parse_optional(context_begin) 464 parsed_end = _parse_optional(context_end) 465 if parsed_begin != id_to_citation[first_id]: 466 raise ValueError( 467 f"line {line_no}: #!sentences row's context_begin " 468 f"{parsed_begin!r} does not match the #!tokens block's " 469 f"recorded context {id_to_citation[first_id]!r} for token " 470 f"{first_id!r}" 471 ) 472 if parsed_end != id_to_citation[last_id]: 473 raise ValueError( 474 f"line {line_no}: #!sentences row's context_end " 475 f"{parsed_end!r} does not match the #!tokens block's " 476 f"recorded context {id_to_citation[last_id]!r} for token " 477 f"{last_id!r}" 478 ) 479 480 sentence_ids = [ 481 tid 482 for tid in row_order[start : end + 1] 483 if tokengraph[id_position[tid]].tokentype not in IMPLIED_TOKENTYPES 484 ] 485 sentences.append( 486 Sentence( 487 tokens=[ 488 Token( 489 id=tid, 490 text=tokengraph[id_position[tid]].token, 491 citation=id_to_citation[tid], 492 ) 493 for tid in sentence_ids 494 ] 495 ) 496 ) 497 498 return tokengraph, verbalunits, sentences
Read path (as written by write_analyses()/serialize_analyses()) and
reconstruct (tokengraph, verbalunits, sentences) -- in that order.
Each of the three block labels may appear more than once in path --
every instance contributes its own rows, in file order, to that
label's combined row list.
Raises ValueError, naming the offending line and problem, for anything that isn't a faithful, internally-consistent file written by write_analyses().
501def split_analysis_by_sentence( 502 tokengraph: List[TokenAnalysis], 503 verbalunits: List[VerbalExpression], 504 sentences: List[Sentence], 505) -> List[Tuple[List[TokenAnalysis], List[VerbalExpression]]]: 506 """The inverse of what write_analyses()/serialize_analyses() flatten 507 together: given the same `(tokengraph, verbalunits, sentences)` triple 508 read_analyses() returns, split `tokengraph` and `verbalunits` back into 509 one slice per sentence. 510 511 Returns a list the same length and order as `sentences` -- entry i is 512 `(sentence_tokengraph, sentence_verbalunits)` for `sentences[i]`. 513 514 Relies on the same invariant read_analyses() and write_analyses() 515 already depend on: a sentence's own tokens form a contiguous, 516 matching-order run in `tokengraph`. `sentence_tokengraph` is the slice 517 of `tokengraph` between that sentence's first and last token's 518 positions, inclusive -- which also picks up any implied/elided tokens 519 interspersed within that range. `sentence_verbalunits` is every 520 VerbalExpression whose id falls within that same slice. 521 522 One consequence of using [first, last] *real* token positions as the 523 slice boundary: an implied token placed AFTER a sentence's last real 524 token (rather than nested between two real tokens) falls just outside 525 that slice, since there's no further real token of the same sentence 526 to bound it from above. 527 528 Raises ValueError for a sentence with no tokens at all, or whose first 529 or last token id isn't present in `tokengraph`. 530 """ 531 id_position: Dict[str, int] = {tok.id: i for i, tok in enumerate(tokengraph)} 532 533 result: List[Tuple[List[TokenAnalysis], List[VerbalExpression]]] = [] 534 for s_idx, sentence in enumerate(sentences): 535 if not sentence.tokens: 536 raise ValueError(f"sentence at index {s_idx} has no tokens") 537 538 first_id = sentence.tokens[0].id 539 last_id = sentence.tokens[-1].id 540 if first_id not in id_position or last_id not in id_position: 541 raise ValueError( 542 f"sentence at index {s_idx} (tokens {first_id!r}.." 543 f"{last_id!r}) has a boundary token not present in the " 544 "given tokengraph" 545 ) 546 547 start = id_position[first_id] 548 end = id_position[last_id] 549 sentence_tokengraph = tokengraph[start : end + 1] 550 sentence_ids = {tok.id for tok in sentence_tokengraph} 551 sentence_verbalunits = [vu for vu in verbalunits if vu.id in sentence_ids] 552 result.append((sentence_tokengraph, sentence_verbalunits)) 553 554 return result
The inverse of what write_analyses()/serialize_analyses() flatten
together: given the same (tokengraph, verbalunits, sentences) triple
read_analyses() returns, split tokengraph and verbalunits back into
one slice per sentence.
Returns a list the same length and order as sentences -- entry i is
(sentence_tokengraph, sentence_verbalunits) for sentences[i].
Relies on the same invariant read_analyses() and write_analyses()
already depend on: a sentence's own tokens form a contiguous,
matching-order run in tokengraph. sentence_tokengraph is the slice
of tokengraph between that sentence's first and last token's
positions, inclusive -- which also picks up any implied/elided tokens
interspersed within that range. sentence_verbalunits is every
VerbalExpression whose id falls within that same slice.
One consequence of using [first, last] real token positions as the slice boundary: an implied token placed AFTER a sentence's last real token (rather than nested between two real tokens) falls just outside that slice, since there's no further real token of the same sentence to bound it from above.
Raises ValueError for a sentence with no tokens at all, or whose first
or last token id isn't present in tokengraph.
46@dataclass 47class CtsDataRow: 48 """One passage from a `#!ctsdata` source file: `urnbase` (the first 4 49 colon-separated parts of the row's own CTS URN, rejoined with ':', plus 50 a trailing ':') and `citation` (the URN's 5th part) together 51 reconstruct the full URN as `urnbase + citation`. `text` is the 52 passage's own surface text, verbatim.""" 53 54 urnbase: str 55 citation: str 56 text: str
59def read_ctsdata(path: str, delimiter: str = "|") -> List[CtsDataRow]: 60 """Read every `#!ctsdata` block in `path` and return their rows, 61 concatenated in file order, as a list of CtsDataRow -- see this 62 module's docstring for the file shape and what counts as malformed. 63 64 `delimiter` is the column separator used both for the header line 65 ('urn' + delimiter + 'text') and for splitting each data row; '|' by 66 default. Pass a different character if the source file's own text 67 content might contain '|' (there is no escaping mechanism for whichever 68 character is chosen as the delimiter). 69 70 Raises ValueError, naming the offending line, for: a data line 71 appearing before any '#!ctsdata' label; a label line with no header 72 line before the next block or before the file ends; a header line that 73 doesn't match `delimiter`-joined 'urn'/'text' exactly; a data row that 74 isn't exactly 2 columns; a blank urn or text column; or a urn that 75 doesn't split into exactly 5 colon-separated parts. Raises ValueError 76 (not returning an empty list) if the file has no '#!ctsdata' block at 77 all, so a caller can't mistake "wrong file" for "file with zero 78 passages". 79 """ 80 expected_header = delimiter.join(["urn", "text"]) 81 82 with open(path, "r", encoding="utf-8") as f: 83 raw_lines = f.read().splitlines() 84 85 rows: List[CtsDataRow] = [] 86 seen_block = False 87 awaiting_header = False 88 89 for line_no, line in enumerate(raw_lines, start=1): 90 if line.strip() == "": 91 continue 92 93 if line == CTSDATA_LABEL: 94 if awaiting_header: 95 raise ValueError( 96 f"line {line_no}: a {CTSDATA_LABEL!r} block has a label " 97 "line but no header line before the next block starts" 98 ) 99 seen_block = True 100 awaiting_header = True 101 continue 102 103 if not seen_block: 104 raise ValueError( 105 f"line {line_no}: data line {line!r} appears before any " 106 f"{CTSDATA_LABEL!r} block label" 107 ) 108 109 if awaiting_header: 110 if line != expected_header: 111 raise ValueError( 112 f"line {line_no}: expected header {expected_header!r} " 113 f"for a {CTSDATA_LABEL!r} block, got {line!r}" 114 ) 115 awaiting_header = False 116 continue 117 118 parts = line.split(delimiter) 119 if len(parts) != 2: 120 raise ValueError( 121 f"line {line_no}: {CTSDATA_LABEL!r} row has {len(parts)} " 122 f"column(s) (delimiter {delimiter!r}), expected 2: {line!r}" 123 ) 124 urn, text = parts 125 if urn == "": 126 raise ValueError(f"line {line_no}: {CTSDATA_LABEL!r} row has an empty urn column") 127 if text == "": 128 raise ValueError(f"line {line_no}: {CTSDATA_LABEL!r} row has an empty text column") 129 130 urn_parts = urn.split(":") 131 if len(urn_parts) != 5: 132 raise ValueError( 133 f"line {line_no}: urn {urn!r} has {len(urn_parts)} " 134 "colon-separated part(s), expected 5 (e.g. " 135 "'urn:cts:compnov:bible.genesis.masoretic:1.1')" 136 ) 137 citation = urn_parts[4] 138 if citation == "": 139 raise ValueError( 140 f"line {line_no}: urn {urn!r} has an empty final (citation) part" 141 ) 142 urnbase = ":".join(urn_parts[:4]) + ":" 143 144 rows.append(CtsDataRow(urnbase=urnbase, citation=citation, text=text)) 145 146 if not seen_block: 147 raise ValueError(f"file has no {CTSDATA_LABEL!r} block") 148 if awaiting_header: 149 raise ValueError( 150 f"a {CTSDATA_LABEL!r} block has a label line but no header " 151 "line (and no data) -- the file ends too early" 152 ) 153 154 return rows
Read every #!ctsdata block in path and return their rows,
concatenated in file order, as a list of CtsDataRow -- see this
module's docstring for the file shape and what counts as malformed.
delimiter is the column separator used both for the header line
('urn' + delimiter + 'text') and for splitting each data row; '|' by
default. Pass a different character if the source file's own text
content might contain '|' (there is no escaping mechanism for whichever
character is chosen as the delimiter).
Raises ValueError, naming the offending line, for: a data line
appearing before any '#!ctsdata' label; a label line with no header
line before the next block or before the file ends; a header line that
doesn't match delimiter-joined 'urn'/'text' exactly; a data row that
isn't exactly 2 columns; a blank urn or text column; or a urn that
doesn't split into exactly 5 colon-separated parts. Raises ValueError
(not returning an empty list) if the file has no '#!ctsdata' block at
all, so a caller can't mistake "wrong file" for "file with zero
passages".
131def estimate_max_tokens( 132 num_tokens: int, 133 *, 134 safety_margin: float = DEFAULT_SAFETY_MARGIN, 135 floor: int = DEFAULT_FLOOR, 136 ceiling: int = DEFAULT_CEILING, 137) -> int: 138 """Estimate a `max_tokens` budget for a SyntaxAnalysis call over a 139 sentence with `num_tokens` input tokens. 140 141 `raw = intercept + slope * num_tokens` comes from the calibrated (or 142 fallback) linear fit (see _load_calibration()); `safety_margin` 143 multiplies that to leave room for the reasoning field's length being 144 only roughly, not exactly, a function of passage length. The result is 145 clamped to `[floor, ceiling]` -- `floor` guards against a degenerate 146 tiny estimate for a 1-2 token sentence, `ceiling` is a hard cap you 147 should set to your actual model's real max-output-tokens limit. 148 149 Raises ValueError if `num_tokens` is negative. 150 """ 151 if num_tokens < 0: 152 raise ValueError(f"num_tokens must be >= 0, got {num_tokens}") 153 154 calibration = _load_calibration() 155 raw = calibration["intercept"] + calibration["slope"] * num_tokens 156 budget = math.ceil(raw * safety_margin) 157 return max(floor, min(ceiling, budget))
Estimate a max_tokens budget for a SyntaxAnalysis call over a
sentence with num_tokens input tokens.
raw = intercept + slope * num_tokens comes from the calibrated (or
fallback) linear fit (see _load_calibration()); safety_margin
multiplies that to leave room for the reasoning field's length being
only roughly, not exactly, a function of passage length. The result is
clamped to [floor, ceiling] -- floor guards against a degenerate
tiny estimate for a 1-2 token sentence, ceiling is a hard cap you
should set to your actual model's real max-output-tokens limit.
Raises ValueError if num_tokens is negative.
203def analyze_with_retry( 204 passage: str, 205 tokens: List[Token], 206 *, 207 max_retries: int = 1, 208 growth_factor: float = 2.0, 209 safety_margin: float = DEFAULT_SAFETY_MARGIN, 210 floor: int = DEFAULT_FLOOR, 211 ceiling: int = DEFAULT_CEILING, 212 initial_max_tokens: Optional[int] = None, 213): 214 """Call `analyze()`, detecting truncation and retrying with a larger 215 `max_tokens` budget instead of either crashing or silently returning an 216 incomplete result. 217 218 The starting budget is `initial_max_tokens` if given, else 219 `estimate_max_tokens(len(tokens), safety_margin=safety_margin, 220 floor=floor, ceiling=ceiling)`. 221 222 After each attempt, truncation is checked two ways: `_missing_token_ids` 223 against the result (the primary, LM-independent signal -- works 224 whenever a result exists at all, parsed or not, including under 225 DummyLM in tests) and, if the call raised `AdapterParseError` instead 226 of returning a result (the JSON was cut off badly enough to not parse 227 at all), `_finish_reason_was_length()` as a corroborating check before 228 deciding a retry is even worth trying -- a parse failure that ISN'T a 229 length truncation is a real formatting bug a bigger budget won't fix, 230 so it's re-raised immediately rather than retried. 231 232 If truncation is detected and there's still a retry available (fewer 233 than `max_retries` attempts so far, and the budget hasn't already hit 234 `ceiling`), the budget is multiplied by `growth_factor` (capped at 235 `ceiling`) and the call is retried. `max_tokens` is part of DSPy's own 236 LM cache key, so a retry with a different budget always reaches the LM 237 again rather than replaying a cached truncated response. 238 239 Once retries are exhausted: if the last attempt raised, that exception 240 propagates. If the last attempt returned a still-incomplete result, 241 it's returned anyway -- with a `UserWarning` naming the missing token 242 ids -- rather than raising, matching this codebase's convention of 243 surfacing analysis problems as warnings rather than treating an 244 imperfect LM result as fatal. 245 """ 246 budget = initial_max_tokens if initial_max_tokens is not None else estimate_max_tokens( 247 len(tokens), safety_margin=safety_margin, floor=floor, ceiling=ceiling 248 ) 249 250 attempt = 0 251 while True: 252 old_budget = budget 253 try: 254 result = analyze(passage=passage, tokens=tokens, config={"max_tokens": budget}) 255 except AdapterParseError: 256 if attempt < max_retries and budget < ceiling and _finish_reason_was_length(): 257 attempt += 1 258 budget = min(ceiling, math.ceil(budget * growth_factor)) 259 warnings.warn( 260 f"SyntaxAnalysis call truncated at max_tokens={old_budget} before it " 261 f"could be parsed at all; retrying with max_tokens={budget} " 262 f"(attempt {attempt}/{max_retries}).", 263 stacklevel=2, 264 ) 265 continue 266 raise 267 268 missing = _missing_token_ids(tokens, result) 269 truncated = bool(missing) or _finish_reason_was_length() 270 if truncated and attempt < max_retries and budget < ceiling: 271 attempt += 1 272 budget = min(ceiling, math.ceil(budget * growth_factor)) 273 warnings.warn( 274 f"SyntaxAnalysis call at max_tokens={old_budget} returned a tokengraph " 275 f"missing {len(missing)} input token id(s) ({sorted(missing)}); retrying " 276 f"with a larger max_tokens={budget} (attempt {attempt}/{max_retries}).", 277 stacklevel=2, 278 ) 279 continue 280 281 if truncated: 282 missing_desc = sorted(missing) if missing else "(finish_reason indicated truncation, but no ids are directly missing)" 283 warnings.warn( 284 f"SyntaxAnalysis call still looks truncated after {attempt} retry(ies) " 285 f"(max_tokens={old_budget}) -- returning it anyway. Missing input token " 286 f"id(s): {missing_desc}.", 287 stacklevel=2, 288 ) 289 290 return result
Call analyze(), detecting truncation and retrying with a larger
max_tokens budget instead of either crashing or silently returning an
incomplete result.
The starting budget is initial_max_tokens if given, else
estimate_max_tokens(len(tokens), safety_margin=safety_margin,
floor=floor, ceiling=ceiling).
After each attempt, truncation is checked two ways: _missing_token_ids
against the result (the primary, LM-independent signal -- works
whenever a result exists at all, parsed or not, including under
DummyLM in tests) and, if the call raised AdapterParseError instead
of returning a result (the JSON was cut off badly enough to not parse
at all), _finish_reason_was_length() as a corroborating check before
deciding a retry is even worth trying -- a parse failure that ISN'T a
length truncation is a real formatting bug a bigger budget won't fix,
so it's re-raised immediately rather than retried.
If truncation is detected and there's still a retry available (fewer
than max_retries attempts so far, and the budget hasn't already hit
ceiling), the budget is multiplied by growth_factor (capped at
ceiling) and the call is retried. max_tokens is part of DSPy's own
LM cache key, so a retry with a different budget always reaches the LM
again rather than replaying a cached truncated response.
Once retries are exhausted: if the last attempt raised, that exception
propagates. If the last attempt returned a still-incomplete result,
it's returned anyway -- with a UserWarning naming the missing token
ids -- rather than raising, matching this codebase's convention of
surfacing analysis problems as warnings rather than treating an
imperfect LM result as fatal.
123def get_calibration() -> dict: 124 """Public introspection: what (intercept, slope) is estimate_max_tokens() 125 currently using, and did it come from a real calibration fit or from 126 this module's untuned fallback? See _load_calibration()'s docstring for 127 the shape returned.""" 128 return _load_calibration()
Public introspection: what (intercept, slope) is estimate_max_tokens() currently using, and did it come from a real calibration fit or from this module's untuned fallback? See _load_calibration()'s docstring for the shape returned.
330def estimate_segmentation_max_tokens( 331 sources: List[CitedText], 332 *, 333 safety_margin: float = DEFAULT_SEGMENTATION_SAFETY_MARGIN, 334 floor: int = DEFAULT_SEGMENTATION_FLOOR, 335 ceiling: int = DEFAULT_SEGMENTATION_CEILING, 336) -> int: 337 """Estimate a `max_tokens` budget for a SegmentPassage call over 338 `sources`, from their combined input character count (see this 339 section's own module-level comment for why character count, and why 340 this is an uncalibrated proxy rather than a real fit). 341 342 `raw = _SEGMENTATION_FALLBACK_INTERCEPT + 343 _SEGMENTATION_FALLBACK_CHARS_PER_COMPLETION_TOKEN * num_chars`; 344 `safety_margin` multiplies that, and the result is clamped to 345 `[floor, ceiling]` -- same shape as estimate_max_tokens(), see its 346 docstring for what each parameter guards against. 347 """ 348 num_chars = sum(len(source.text) for source in sources) 349 raw = _SEGMENTATION_FALLBACK_INTERCEPT + _SEGMENTATION_FALLBACK_CHARS_PER_COMPLETION_TOKEN * num_chars 350 budget = math.ceil(raw * safety_margin) 351 return max(floor, min(ceiling, budget))
Estimate a max_tokens budget for a SegmentPassage call over
sources, from their combined input character count (see this
section's own module-level comment for why character count, and why
this is an uncalibrated proxy rather than a real fit).
raw = _SEGMENTATION_FALLBACK_INTERCEPT +
_SEGMENTATION_FALLBACK_CHARS_PER_COMPLETION_TOKEN * num_chars;
safety_margin multiplies that, and the result is clamped to
[floor, ceiling] -- same shape as estimate_max_tokens(), see its
docstring for what each parameter guards against.
381def segment_with_retry( 382 sources: List[CitedText], 383 *, 384 max_retries: int = 3, 385 growth_factor: float = 2.0, 386 safety_margin: float = DEFAULT_SEGMENTATION_SAFETY_MARGIN, 387 floor: int = DEFAULT_SEGMENTATION_FLOOR, 388 ceiling: int = DEFAULT_SEGMENTATION_CEILING, 389 initial_max_tokens: Optional[int] = None, 390) -> List[Sentence]: 391 """Call `segmentation_dspy.segment()`, detecting truncation and 392 retrying with a larger `max_tokens` budget instead of either crashing 393 or silently returning an incomplete result -- the segmentation-stage 394 counterpart to `analyze_with_retry()` above (see this module's own 395 docstring for why segmentation needed this at all). 396 397 `max_retries` defaults higher here than `analyze_with_retry()`'s `1` 398 (three doublings from a truncated initial estimate reaches roughly 399 8x that estimate before giving up) specifically because 400 estimate_segmentation_max_tokens()'s budget is an uncalibrated 401 character-count proxy, not a real fit the way estimate_max_tokens()'s 402 is once utilities/calibrate_max_tokens.py has been run -- a rough guess deserves 403 more retry headroom to self-correct, cheaply, rather than giving up 404 after one doubling the way a properly-calibrated estimate can afford 405 to. 406 407 The starting budget is `initial_max_tokens` if given, else 408 `estimate_segmentation_max_tokens(sources, safety_margin=safety_margin, 409 floor=floor, ceiling=ceiling)`. 410 411 After each attempt, truncation is checked two ways: 412 `_segmentation_undercoverage()` against the result (the primary, 413 LM-independent signal -- works whenever a result exists at all, parsed 414 or not) and, if the call raised `AdapterParseError` instead of 415 returning a result, `_finish_reason_was_length()` as a corroborating 416 check before deciding a retry is worth trying -- same split as 417 `analyze_with_retry()`'s own two-signal design; see its docstring for 418 why a non-length parse failure is re-raised immediately rather than 419 retried. 420 421 If truncation is detected and a retry is still available (fewer than 422 `max_retries` attempts so far, and the budget hasn't already hit 423 `ceiling`), the budget is multiplied by `growth_factor` (capped at 424 `ceiling`) and segmentation is retried. Once retries are exhausted: a 425 raised exception propagates; an incomplete result is returned anyway, 426 with a `UserWarning`, rather than treated as fatal -- matching 427 `analyze_with_retry()`'s own warn-don't-raise convention. 428 """ 429 budget = initial_max_tokens if initial_max_tokens is not None else estimate_segmentation_max_tokens( 430 sources, safety_margin=safety_margin, floor=floor, ceiling=ceiling 431 ) 432 433 attempt = 0 434 while True: 435 old_budget = budget 436 try: 437 result = segment(sources=sources, config={"max_tokens": budget}) 438 except AdapterParseError: 439 if attempt < max_retries and budget < ceiling and _finish_reason_was_length(): 440 attempt += 1 441 budget = min(ceiling, math.ceil(budget * growth_factor)) 442 warnings.warn( 443 f"SegmentPassage call truncated at max_tokens={old_budget} before it " 444 f"could be parsed at all; retrying with max_tokens={budget} " 445 f"(attempt {attempt}/{max_retries}).", 446 stacklevel=2, 447 ) 448 continue 449 raise 450 451 sentences = result.sentences 452 truncated = _segmentation_undercoverage(sources, sentences) or _finish_reason_was_length() 453 if truncated and attempt < max_retries and budget < ceiling: 454 attempt += 1 455 budget = min(ceiling, math.ceil(budget * growth_factor)) 456 warnings.warn( 457 f"SegmentPassage call at max_tokens={old_budget} returned a result that " 458 f"looks incomplete; retrying with a larger max_tokens={budget} " 459 f"(attempt {attempt}/{max_retries}).", 460 stacklevel=2, 461 ) 462 continue 463 464 if truncated: 465 warnings.warn( 466 f"SegmentPassage call still looks truncated after {attempt} retry(ies) " 467 f"(max_tokens={old_budget}) -- returning it anyway. If this keeps " 468 "happening, call diqduq.segment_with_retry() directly with a larger " 469 "initial_max_tokens/ceiling instead of going through analyze_sources()/" 470 "analyze_passage()'s defaults.", 471 stacklevel=2, 472 ) 473 474 return sentences
Call segmentation_dspy.segment(), detecting truncation and
retrying with a larger max_tokens budget instead of either crashing
or silently returning an incomplete result -- the segmentation-stage
counterpart to analyze_with_retry() above (see this module's own
docstring for why segmentation needed this at all).
max_retries defaults higher here than analyze_with_retry()'s 1
(three doublings from a truncated initial estimate reaches roughly
8x that estimate before giving up) specifically because
estimate_segmentation_max_tokens()'s budget is an uncalibrated
character-count proxy, not a real fit the way estimate_max_tokens()'s
is once utilities/calibrate_max_tokens.py has been run -- a rough guess deserves
more retry headroom to self-correct, cheaply, rather than giving up
after one doubling the way a properly-calibrated estimate can afford
to.
The starting budget is initial_max_tokens if given, else
estimate_segmentation_max_tokens(sources, safety_margin=safety_margin,
floor=floor, ceiling=ceiling).
After each attempt, truncation is checked two ways:
_segmentation_undercoverage() against the result (the primary,
LM-independent signal -- works whenever a result exists at all, parsed
or not) and, if the call raised AdapterParseError instead of
returning a result, _finish_reason_was_length() as a corroborating
check before deciding a retry is worth trying -- same split as
analyze_with_retry()'s own two-signal design; see its docstring for
why a non-length parse failure is re-raised immediately rather than
retried.
If truncation is detected and a retry is still available (fewer than
max_retries attempts so far, and the budget hasn't already hit
ceiling), the budget is multiplied by growth_factor (capped at
ceiling) and segmentation is retried. Once retries are exhausted: a
raised exception propagates; an incomplete result is returned anyway,
with a UserWarning, rather than treated as fatal -- matching
analyze_with_retry()'s own warn-don't-raise convention.