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]
class Token(pydantic.main.BaseModel):
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.

id: str = PydanticUndefined

Stable token id, globally unique and sequential across the whole input, e.g. 't0', 't1', ...

text: str = PydanticUndefined

The token's surface text, exactly as it appears in the source.

citation: Optional[str] = None

Citation label of the source unit this token came from (e.g. 'urn:cts:compnov:bible.genesis.masoretic:1.1'), if known.

class CitedText(pydantic.main.BaseModel):
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.

citation: str = PydanticUndefined

Citation label for this unit, e.g. 'urn:cts:compnov:bible.genesis.masoretic:1.1'.

text: str = PydanticUndefined

This unit's raw text, exactly as written (including niqqud and cantillation).

class Sentence(pydantic.main.BaseModel):
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.

tokens: List[Token] = PydanticUndefined

This sentence's tokens, in reading order, using the passage's global token ids.

class VerbalExpression(pydantic.main.BaseModel):
 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:

  1. Every finite verb.
  2. 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).

id: str = PydanticUndefined

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.

syntactic_type: Literal['independent', 'direct quote'] = PydanticUndefined

'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.

semantic_type: Literal['transitive active', 'transitive passive', 'intransitive', 'linking verb'] = PydanticUndefined

The verb's semantic/voice type.

class TokenAnalysis(pydantic.main.BaseModel):
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.

id: str = PydanticUndefined

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.

token: Optional[str] = None

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.

tokentype: Literal['lexical', 'enclitic pronoun', 'proclitic conjunction', 'maqaf', 'cantillation', 'paragraph', 'editorial', 'implied sum'] = PydanticUndefined

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.

lemma: Optional[str] = None

Dictionary headword, for lexical tokens. Omit for cantillation/paragraph/editorial tokens.

verbalunitid: Optional[str] = None

If this token anchors a verbal expression in verbalunits, repeat its own id here; otherwise omit.

relatedtoken1: Optional[str] = None

Token id this token relates to (primary relation). For an INDEPENDENT verb's own 'unit verb' relation, use the special sentinel string 'root' instead of a token id -- 'root' is reserved and must never be assigned as an actual token's id.

relationship1: Optional[Literal['unit verb', 'direct quote', 'subject', 'direct object', 'object marker', 'predicate', 'coordinating conjunction', 'object of preposition', 'article', 'construct', 'adjectival', 'adverbial']] = None

The primary relation type, if any.

relatedtoken2: Optional[str] = None

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).

relationship2: Optional[Literal['unit verb', 'direct quote', 'subject', 'direct object', 'object marker', 'predicate', 'coordinating conjunction', 'object of preposition', 'article', 'construct', 'adjectival', 'adverbial']] = None

The secondary relation type, if any.

RelationLabel = typing.Literal['unit verb', 'direct quote', 'subject', 'direct object', 'object marker', 'predicate', 'coordinating conjunction', 'object of preposition', 'article', 'construct', 'adjectival', 'adverbial']
IMPLIED_TOKENTYPES = frozenset({'implied sum'})
NON_SUBSTANTIVE_TOKENTYPES = frozenset({'maqaf', 'paragraph', 'cantillation', 'editorial'})
def tokengraph_to_mermaid( tokengraph: List[TokenAnalysis], orientation: str = 'BT', color_by_verbal_unit: bool = True, rank_by_depth: bool = True) -> Tuple[str, List[str]]:
 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.

def save_mermaid( tokengraph: List[TokenAnalysis], path: str, orientation: str = 'BT', color_by_verbal_unit: bool = True, rank_by_depth: bool = True) -> List[str]:
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.

def assign_verbal_units( tokengraph: List[TokenAnalysis]) -> Dict[str, Optional[str]]:
 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.

def assign_verbal_unit_colors( tokengraph: List[TokenAnalysis], assignment: Optional[Dict[str, Optional[str]]] = None) -> Tuple[Dict[str, Tuple[str, str, str]], List[str]]:
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).

def compute_subordination_depths( tokengraph: List[TokenAnalysis]) -> Tuple[Dict[str, Optional[int]], List[str]]:
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.

def max_subordination_depth( tokengraph: List[TokenAnalysis], depths: Optional[Dict[str, Optional[int]]] = None) -> Optional[int]:
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.

def find_unanchored_coordinated_verbs(tokengraph: List[TokenAnalysis]) -> List[str]:
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.

def tokengraph_to_text(tokengraph: List[TokenAnalysis]) -> str:
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).

def tokengraph_to_html( tokengraph: List[TokenAnalysis], *, include_cantillation: bool = True) -> str:
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.

def tokengraph_to_depth_html( tokengraph: List[TokenAnalysis], indent_em: float = 2.0, depth: Optional[int] = None) -> Tuple[str, List[str]]:
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

indented by a CSS margin-left of 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

s. A token with no verbal-unit assignment at all (None) likewise never starts a new block; it folds into whichever block is currently open. Leading tokens before the first resolvable verbal-unit token (rare) default to depth 0.

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.

class SyntaxAnalysis(dspy.signatures.signature.Signature):
 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.
passage: str = PydanticUndefined
tokens: List[Token] = PydanticUndefined
verbalunits: List[VerbalExpression] = PydanticUndefined
tokengraph: List[TokenAnalysis] = PydanticUndefined
analyze = predict = Predict(StringSignature(passage, tokens -> reasoning, verbalunits, tokengraph instructions='Analyze the syntax of a passage of Biblical Hebrew according to a\ntwo-part scheme:\n\n(1) a list of verbal expressions. Two constructions count as a verbal\n expression: every finite verb, and every participle.\n\n Classify each verbal expression\'s syntactic type as \'independent\'\n (main/principal -- syntactically independent, its clause coherent\n by itself) or \'direct quote\' (occurring in directly quoted speech\n introduced by a verb of saying). syntax_model.md documents no other\n syntactic_type values in this first draft of the scheme -- in\n particular there is no \'dependent\' category yet for a subordinate\n clause (subordinating conjunctions are listed as "TBA"). A\n participle is classified the same way: \'independent\' unless it\n occurs within quoted speech.\n\n Classify each verbal expression\'s semantic type too (transitive\n active/transitive passive/intransitive/linking verb).\n\n(2) a token-by-token dependency graph. For each token, record up to two\n relations to other tokens (by id), using only these relation\n labels:\n\n - unit verb (independent): every INDEPENDENT verb (or participle\n functioning as one) has relatedtoken1 = the special sentinel\n string \'root\' -- never an actual token id; no real token may be\n assigned the id \'root\' -- and relationship1 = \'unit verb\'.\n Example: in בְּרֵאשִׁית בָּרָא אֱלֹהִים אֵת הַשָּׁמַיִם וְאֵת הָאָרֶץ, the\n independent verb בָּרָא has relatedtoken1 = \'root\', relationship1 =\n \'unit verb\'.\n - direct quote: a verb (or participle) of directly quoted speech\n has relatedtoken1 -> the id of the verb of the governing verbal\n expression (the verb of saying that introduces/frames the\n quotation), relationship1 = \'direct quote\' -- matching its own\n syntactic_type. Example: in וַיֹּאמֶר אֱלֹהִים יְהִי אֹור וַיְהִי־אֹור,\n the verbal unit anchored at יְהִי is direct speech subordinate to\n יֹּאמֶר: יְהִי has relatedtoken1 -> יֹּאמֶר\'s id, relationship1 =\n \'direct quote\'.\n - subject / direct object / predicate: a noun or pronoun serving as\n the subject of a verbal expression has relatedtoken1 -> the id of\n the verb, relationship1 = \'subject\'. One functioning as direct\n object has relatedtoken1 -> the verb\'s id, relationship1 =\n \'direct object\'. One functioning as the predicate complement of a\n LINKING verb (including an elided-\'to be\' implied token -- see\n (3) below) has relatedtoken1 -> that verb\'s id, relationship1 =\n \'predicate\'. Example: in Genesis 1.1 (as above), אֱלֹהִים has\n relatedtoken1 -> בָּרָא\'s id, relationship1 = \'subject\'; שָׁמַיִם and\n אָרֶץ each have relatedtoken1 -> בָּרָא\'s id, relationship1 = \'direct\n object\'.\n - object marker: the direct object marker אֵת itself has\n relatedtoken1 -> the id of the direct object noun/pronoun it\n marks, relationship1 = \'object marker\'. The marked noun keeps its\n OWN separate \'direct object\' relation to the verb -- this is an\n additional entry on the marker token, not a replacement for that\n one. Example: in Genesis 1.1 (as above), the first אֵת has\n relatedtoken1 -> שָׁמַיִם\'s id, the second אֵת has relatedtoken1 ->\n אָרֶץ\'s id, both relationship1 \'object marker\'.\n - coordinating conjunction (single pair): when a coordinating\n conjunction (the proclitic וְ) joins exactly ONE pair of\n adjectives, nouns, prepositional phrases, or verbal expressions,\n it has relatedtoken1 -> the id of the first joined token,\n relatedtoken2 -> the id of the second, with BOTH relationship1\n and relationship2 = \'coordinating conjunction\' (not an overflow\n slot here -- this is the one relation that genuinely uses both\n ends at once). Example: in בְּרֵאשִׁית בָּרָא אֱלֹהִים אֵת הַשָּׁמַיִם\n וְאֵת הָאָרֶץ, the conjunction וְ (prefixed to אֵת before הָאָרֶץ) has\n relatedtoken1 -> הַשָּׁמַיִם\'s id, relatedtoken2 -> הָאָרֶץ\'s id, both\n relationship \'coordinating conjunction\'.\n - coordinating conjunction (repeated, as a chain): the proclitic וְ\n can instead be repeated, prefixed onto EVERY one of a series of\n two or more coordinated items (most often a chain of narrative\n wayyiqtol verbs), not just used once between a pair. Annotate\n this differently from the single-pair case above. Every\n connector\'s own relatedtoken1 -> the id of the item it\n immediately introduces (a real noun, adjective, prepositional\n phrase, or verbal-expression anchor -- NEVER another connector),\n relationship1 = \'coordinating conjunction\', exactly as in the\n single-pair case. relatedtoken2 is what differs: the FIRST\n connector\'s relatedtoken2 -> the id of the NEXT (second)\n connector, while every connector AFTER the first has\n relatedtoken2 -> the id of the PRECEDING connector instead (not\n the following one); relationship2 = \'coordinating conjunction\'\n for all of them, same as relationship1 -- still not an overflow\n slot. Each connected item ALSO keeps its own ordinary relation to\n the rest of the sentence (subject, object of preposition, or\n whatever fits), completely independent of this chain. Example:\n in וַיְבָרֶךְ אֱלֹהִים אֶת־יֹום הַשְּׁבִיעִי וַיְקַדֵּשׁ אֹתֹו, two verbal\n expressions (anchored at בָרֶךְ and קַדֵּשׁ) are coordinated by two\n instances of וְ: the first וְ (prefixed to יְבָרֶךְ) has\n relatedtoken1 -> בָרֶךְ\'s id, relatedtoken2 -> the second וְ\'s id;\n the second וְ (prefixed to יְקַדֵּשׁ) has relatedtoken1 -> קַדֵּשׁ\'s\n id, relatedtoken2 -> the first וְ\'s id. בָרֶךְ and קַדֵּשׁ each ALSO\n have their own relatedtoken1 = \'root\', relationship1 = \'unit\n verb\' entries, unaffected by which connector introduces them --\n being coordinated by וְ does not exempt either verb from its own\n normal \'unit verb\'/\'root\' entry.\n - object of preposition: a noun or pronoun functioning as the\n object of a preposition has relatedtoken1 -> the id of the\n preposition, relationship1 = \'object of preposition\'. Example: in\n the phrase בְּאֶרֶץ, אֶרֶץ has relatedtoken1 -> בְּ\'s id,\n relationship1 = \'object of preposition\'.\n - article: when the article הַ relates to a noun or adjective, it\n has relatedtoken1 -> the id of that noun or adjective,\n relationship1 = \'article\'. Example: in הַשָּׁמַיִם, the article has\n relatedtoken1 -> הַשָּׁמַיִם\'s own lexical-token id, relationship1 =\n \'article\'.\n - construct: when two nouns stand in a construct relation, the\n governed (related) noun has relatedtoken1 -> the id of the\n governing noun, relationship1 = \'construct\'; the governing noun\n is separately recorded according to its own function elsewhere\n in the sentence. Example: in בְּזֵעַת אַפֶּיךָ תֹּאכַל לֶחֶם, אַפֶּי\n (governed by זֵעַת) has relatedtoken1 -> זֵעַת\'s id, relationship1 =\n \'construct\'; זֵעַת itself is recorded as the object of the\n preposition בְּ (relatedtoken1 -> בְּ\'s id, relationship1 = \'object\n of preposition\').\n - adjectival: an adjective has relatedtoken1 -> the id of the noun\n it modifies, relationship1 = \'adjectival\'. Example: in אֲחִיכֶם\n הַקָּטֹן, קָּטֹן (modifying אֲחִי) has relatedtoken1 -> אֲחִי\'s id,\n relationship1 = \'adjectival\'.\n - adverbial: when a prepositional phrase modifies a verb\n adverbially, the PREPOSITION ITSELF (not its object) has\n relatedtoken1 -> the id of the verb, relationship1 = \'adverbial\'.\n The preposition\'s own object is still separately recorded as\n \'object of preposition\', exactly as usual -- this is an\n additional relation on the preposition, on top of its object\'s\n own unaffected relation to it. Example: in בְּרֵאשִׁית בָּרָא אֱלֹהִים\n אֵת הַשָּׁמַיִם וְאֵת הָאָרֶץ, the preposition בְּ (of the adverbial\n phrase בְּרֵאשִׁית) has relatedtoken1 -> בָּרָא\'s id, relationship1 =\n \'adverbial\'; its own object רֵאשִׁית has relatedtoken1 -> בְּ\'s id,\n relationship1 = \'object of preposition\', unchanged.\n\n Only assign relations described above. Leave relatedtoken/\n relationship fields unset for tokens with no relation of these\n kinds -- not every token will have one (syntax_model.md\'s own "TBA"\n section names two remaining constructions -- subordinating\n conjunctions and the relative pronoun אֲשֶׁר -- that have no\n documented relation at all yet; leave a token unrelated rather than\n guessing a label for one of these). Use only the token ids given in\n the input `tokens` list, the sentinel \'root\', or a NEW id you\n create for an implied token (see below), in your output; never\n invent an id for anything else.\n\n(3) implied/elided tokens. `diqduq` recognizes one situation where\n something exists grammatically but has no surface realization in\n the passage at all: an elided present tense of "to be", which\n Biblical Hebrew routinely omits from a nominal (verbless) sentence.\n When this happens, add a NEW entry to `tokengraph` with: a\n brand-new id, not used by any entry in `tokens` or elsewhere in\n your own output (see the naming rule below); tokentype \'implied\n sum\'; and no `token` value (leave it unset/None). Also add a\n matching new entry to `verbalunits`, exactly like any other verbal\n expression, classified \'independent\' (or \'direct quote\', if the\n elided-copula clause is itself directly quoted speech) and\n \'linking verb\'. The subject and predicate each relate to this new\n token exactly as they would to any linking verb (\'subject\' /\n \'predicate\'). Example: in לֹא אֱלֹהִים הֵמָּה ("they are not gods"),\n the subject is הֵמָּה and the predicate noun is אֱלֹהִים; add a new\n implied token (tokentype \'implied sum\', token=None) anchoring an\n \'independent\'/\'linking verb\' verbal expression, with הֵמָּה related\n to it as \'subject\' and אֱלֹהִים as \'predicate\'.\n\n Naming an implied token\'s id: append \'_implied\' to the id of the\n LAST real token in `tokens` that precedes where the elided "to be"\n would have stood (or, if the elided word would come before every\n real token in the sentence, the FIRST real token\'s id instead). If\n more than one implied token is ever needed in the same sentence,\n append \'2\', \'3\', ... after \'_implied\' to keep them unique (e.g.\n \'t5_implied\', \'t5_implied2\'). Place the new `tokengraph` entry at\n the list position where the elided word would have appeared,\n among the tokens of its own clause.' passage = Field(annotation=str required=True json_schema_extra={'desc': 'The Hebrew passage to analyze, exactly as written.', '__dspy_field_type': 'input', 'prefix': 'Passage:'}) tokens = Field(annotation=List[Token] required=True json_schema_extra={'desc': 'Pre-segmented tokens of the passage, in order, with fixed ids. Reference these ids in your output; do not create new ones.', '__dspy_field_type': 'input', 'prefix': 'Tokens:'}) reasoning = Field(annotation=str required=True json_schema_extra={'desc': '${reasoning}', '__dspy_field_type': 'output', 'prefix': 'Reasoning:'}) verbalunits = Field(annotation=List[VerbalExpression] required=True json_schema_extra={'desc': 'One entry per verbal expression (finite verb or participle) in the passage.', '__dspy_field_type': 'output', 'prefix': 'Verbalunits:'}) tokengraph = Field(annotation=List[TokenAnalysis] required=True json_schema_extra={'desc': "One entry per token in `tokens`, in the same order, with its type and any relations -- PLUS one additional entry for each implied/elided token you add (see this signature's docstring), positioned where that token's clause falls in reading order.", '__dspy_field_type': 'output', 'prefix': 'Tokengraph:'}) ))
def analyze_passage( passage: str, citation: str = '') -> Tuple[List[Sentence], list]:
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).

def validate(tokens: List[Token], result) -> List[str]:
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.

class SegmentPassage(dspy.signatures.signature.Signature):
 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 sources again must produce the same ids for the same tokens.

sources: List[CitedText] = PydanticUndefined
sentences: List[Sentence] = PydanticUndefined
def segment_sources(sources: List[CitedText]) -> List[Sentence]:
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.

def analyze_sources( sources: List[CitedText]) -> Tuple[List[Sentence], list]:
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).

def combined_tokengraph(results) -> list:
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.

def serialize_analyses( sentences: List[Sentence], verbalunits: List[VerbalExpression], tokengraph: List[TokenAnalysis]) -> Tuple[str, List[str]]:
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 tokens by 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).

def write_analyses( sentences: List[Sentence], verbalunits: List[VerbalExpression], tokengraph: List[TokenAnalysis], path: str) -> List[str]:
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.

def read_analyses( path: str) -> Tuple[List[TokenAnalysis], List[VerbalExpression], List[Sentence]]:
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().

def split_analysis_by_sentence( tokengraph: List[TokenAnalysis], verbalunits: List[VerbalExpression], sentences: List[Sentence]) -> List[Tuple[List[TokenAnalysis], List[VerbalExpression]]]:
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.

@dataclass
class CtsDataRow:
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

One passage from a #!ctsdata source file: urnbase (the first 4 colon-separated parts of the row's own CTS URN, rejoined with ':', plus a trailing ':') and citation (the URN's 5th part) together reconstruct the full URN as urnbase + citation. text is the passage's own surface text, verbatim.

CtsDataRow(urnbase: str, citation: str, text: str)
urnbase: str
citation: str
text: str
def read_ctsdata(path: str, delimiter: str = '|') -> List[CtsDataRow]:
 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".

def estimate_max_tokens( num_tokens: int, *, safety_margin: float = 1.4, floor: int = 256, ceiling: int = 8192) -> int:
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.

def analyze_with_retry( passage: str, tokens: List[Token], *, max_retries: int = 1, growth_factor: float = 2.0, safety_margin: float = 1.4, floor: int = 256, ceiling: int = 8192, initial_max_tokens: Optional[int] = None):
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.

def get_calibration() -> dict:
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.

def estimate_segmentation_max_tokens( sources: List[CitedText], *, safety_margin: float = 1.4, floor: int = 512, ceiling: int = 8192) -> int:
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.

def segment_with_retry( sources: List[CitedText], *, max_retries: int = 3, growth_factor: float = 2.0, safety_margin: float = 1.4, floor: int = 512, ceiling: int = 8192, initial_max_tokens: Optional[int] = None) -> List[Sentence]:
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.