grammatike

grammatike: a DSPy program analyzing the syntax of Ancient Greek passages according to the scheme documented in syntax_model.md.

Greek analogue of arsgrammatica (https://github.com/neelsmith/arsgrammatica), the same author's parallel package for analyzing Latin syntax.

 1"""grammatike: a DSPy program analyzing the syntax of Ancient Greek passages
 2according to the scheme documented in syntax_model.md.
 3
 4Greek analogue of `arsgrammatica` (https://github.com/neelsmith/arsgrammatica),
 5the same author's parallel package for analyzing Latin syntax.
 6"""
 7
 8from .models import (
 9    Token,
10    CitedText,
11    Sentence,
12    VerbalExpression,
13    TokenAnalysis,
14    RelationLabel,
15    IMPLIED_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 .dot import tokengraph_to_dot, save_dot, compute_graph_depths, max_graph_depth
26from .rendering import tokengraph_to_text, tokengraph_to_html, tokengraph_to_depth_html
27from .greek_syntax_dspy import (
28    SyntaxAnalysis,
29    analyze,
30    validate,
31    print_analysis,
32)
33from .segmentation import segment_sources
34from .pipeline import analyze_sources, combined_tokengraph, analyze_passage
35from .serialization import (
36    serialize_analyses,
37    write_analyses,
38    read_analyses,
39    read_llm_notes,
40    split_analysis_by_sentence,
41)
42from .ctsdata import CtsDataRow, read_ctsdata
43from .token_budget import (
44    estimate_max_tokens,
45    analyze_with_retry,
46    get_calibration,
47    DEFAULT_CEILING,
48)
49from .gepa_metric import syntax_metric
50
51__all__ = [
52    "Token",
53    "CitedText",
54    "Sentence",
55    "VerbalExpression",
56    "TokenAnalysis",
57    "RelationLabel",
58    "IMPLIED_TOKENTYPES",
59    "tokengraph_to_mermaid",
60    "save_mermaid",
61    "assign_verbal_units",
62    "assign_verbal_unit_colors",
63    "compute_subordination_depths",
64    "max_subordination_depth",
65    "find_unanchored_coordinated_verbs",
66    "tokengraph_to_dot",
67    "save_dot",
68    "compute_graph_depths",
69    "max_graph_depth",
70    "tokengraph_to_text",
71    "tokengraph_to_html",
72    "tokengraph_to_depth_html",
73    "SyntaxAnalysis",
74    "analyze",
75    "validate",
76    "print_analysis",
77    "segment_sources",
78    "analyze_sources",
79    "combined_tokengraph",
80    "analyze_passage",
81    "serialize_analyses",
82    "write_analyses",
83    "read_analyses",
84    "read_llm_notes",
85    "split_analysis_by_sentence",
86    "CtsDataRow",
87    "read_ctsdata",
88    "estimate_max_tokens",
89    "analyze_with_retry",
90    "get_calibration",
91    "DEFAULT_CEILING",
92    "syntax_metric",
93]
class Token(pydantic.main.BaseModel):
36class Token(BaseModel):
37    """A single pre-segmented token with a stable id.
38
39    `citation` is optional so this model still works for citation-free
40    callers -- e.g. a test fixture built directly from a canned tokengraph,
41    with no CitedText source at all -- as well as for the citation-aware
42    segmentation stage (segmentation.py), which is the only thing that
43    actually populates it, knowing which CitedText source unit each token
44    came from."""
45
46    id: str = Field(description="Stable token id, globally unique and sequential across the whole input, e.g. 't0', 't1', ...")
47    text: str = Field(description="The token's surface text, exactly as it appears in the source.")
48    citation: Optional[str] = Field(
49        default=None,
50        description="Citation label of the source unit this token came from (e.g. 'Lysias 1.1'), if known.",
51    )

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.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. 'Lysias 1.1'), if known.

class CitedText(pydantic.main.BaseModel):
24class CitedText(BaseModel):
25    """One citable unit of source text -- e.g. one line of poetry, one
26    section of prose -- paired with its citation label. A sequence of
27    these is segmentation.py's input: sentence boundaries do NOT need
28    to respect CitedText boundaries (one sentence may span several units),
29    but every resulting token still records which unit it came from via
30    Token.citation."""
31
32    citation: str = Field(description="Citation label for this unit, e.g. 'Lysias 1.1'.")
33    text: str = Field(description="This unit's raw text, exactly as written.")

One citable unit of source text -- e.g. one line of poetry, one section of prose -- paired with its citation label. A sequence of these is segmentation.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. 'Lysias 1.1'.

text: str = PydanticUndefined

This unit's raw text, exactly as written.

class Sentence(pydantic.main.BaseModel):
54class Sentence(BaseModel):
55    """One sentence's worth of tokens, in reading order, as produced by the
56    deterministic segmentation stage (segmentation.py). Token ids are
57    global across the whole passage -- numbering continues across sentence
58    boundaries rather than restarting at t0 for each sentence -- so a
59    Sentence is a contiguous slice of the passage's id sequence, not an
60    independently-numbered unit."""
61
62    tokens: List[Token] = Field(
63        description="This sentence's tokens, in reading order, using the passage's global token ids."
64    )

One sentence's worth of tokens, in reading order, as produced by the deterministic segmentation stage (segmentation.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):
 67class VerbalExpression(BaseModel):
 68    """One entry in the table of verbal expressions (syntax_model.md, 'Basic
 69    model' / verbal-expression sections). Three constructions count as a
 70    verbal expression: finite verbs, infinitives (only when part of indirect
 71    speech), and participles -- but only some participles: an *attributive*
 72    participle (e.g. ὁ ἀνὴρ ὁ ὑβρίζων εἰς σέ, "the man who is insulting
 73    you") and a *circumstantial* participle (e.g. χρόνου μεταξὺ
 74    διαγενομένου, "when some time had passed") each constitute a verbal
 75    expression of their own, as does a participle expressing indirect
 76    speech after a verb of perception or thinking (εἶδε τὴν βασίλειαν
 77    φεύγουσαν, "he saw the queen fleeing"). A *supplementary* participle
 78    (e.g. ὁ ἀνὴρ ἐχθρὸς ὢν ἡμῖν τυγχάνει, where ὤν supplements τυγχάνει) is
 79    NOT a verbal expression at all -- it does not get its own entry here.
 80
 81    Each construction has its own set of allowed `syntactic_type` values,
 82    given explicitly by syntax_model.md rather than left to convention:
 83    a finite verb is 'independent', 'dependent', 'direct quote' (occurring
 84    in directly quoted speech, e.g. πειρᾷς in '"ἵνα σύ γε" ἔφη "πειρᾷς
 85    ἐνταῦθα τὴν παιδίσκην"'), or 'aside' (a verbal expression that
 86    interrupts the surrounding syntax, e.g. δεῖ in 'δεῖ γὰρ καὶ ταῦθ᾽
 87    ὑμῖν διηγήσασθαι'); an infinitive or participle anchoring an indirect
 88    statement is always 'indirect statement'; an attributive participle is
 89    'attributive'; a circumstantial participle is 'circumstantial'."""
 90
 91    id: str = Field(
 92        description=(
 93            "The token id (from the input `tokens` list) of the finite verb, "
 94            "infinitive, or participle that anchors this verbal expression. "
 95            "For a multi-word compound form with a conjugated form of εἰμί "
 96            "(e.g. the perfect passive/middle system, ὁ νόμος γεγραμμένος "
 97            "ἐστίν), use the id of the conjugated form of εἰμί, not the "
 98            "participle. For an implied/elided verbal expression (see "
 99            "TokenAnalysis's 'implied eimi'/'implied repetition' tokentypes, "
100            "IMPLIED_TOKENTYPES), use the new implied token's id instead -- "
101            "an implied token always anchors its own verbal expression."
102        )
103    )
104    syntactic_type: Literal[
105        "independent",
106        "dependent",
107        "direct quote",
108        "aside",
109        "indirect statement",
110        "attributive",
111        "circumstantial",
112    ] = Field(
113        description=(
114            "For a finite verb: 'independent' (main/principal), 'dependent' "
115            "(subordinate/secondary, introduced by a subordinating "
116            "conjunction or relative/interrogative pronoun), 'direct quote' "
117            "(occurring in directly quoted speech), or 'aside' (interrupts "
118            "the surrounding syntax). For an infinitive, or a participle "
119            "after a verb of perception/thinking, anchoring an indirect "
120            "statement: 'indirect statement'. For an attributive "
121            "participle: 'attributive'. For a circumstantial participle "
122            "(including a genitive absolute): 'circumstantial'."
123        )
124    )
125    semantic_type: Literal[
126        "transitive active", "transitive passive", "intransitive", "linking verb"
127    ] = Field(description="The verb's semantic/voice type.")

One entry in the table of verbal expressions (syntax_model.md, 'Basic model' / verbal-expression sections). Three constructions count as a verbal expression: finite verbs, infinitives (only when part of indirect speech), and participles -- but only some participles: an attributive participle (e.g. ὁ ἀνὴρ ὁ ὑβρίζων εἰς σέ, "the man who is insulting you") and a circumstantial participle (e.g. χρόνου μεταξὺ διαγενομένου, "when some time had passed") each constitute a verbal expression of their own, as does a participle expressing indirect speech after a verb of perception or thinking (εἶδε τὴν βασίλειαν φεύγουσαν, "he saw the queen fleeing"). A supplementary participle (e.g. ὁ ἀνὴρ ἐχθρὸς ὢν ἡμῖν τυγχάνει, where ὤν supplements τυγχάνει) is NOT a verbal expression at all -- it does not get its own entry here.

Each construction has its own set of allowed syntactic_type values, given explicitly by syntax_model.md rather than left to convention: a finite verb is 'independent', 'dependent', 'direct quote' (occurring in directly quoted speech, e.g. πειρᾷς in '"ἵνα σύ γε" ἔφη "πειρᾷς ἐνταῦθα τὴν παιδίσκην"'), or 'aside' (a verbal expression that interrupts the surrounding syntax, e.g. δεῖ in 'δεῖ γὰρ καὶ ταῦθ᾽ ὑμῖν διηγήσασθαι'); an infinitive or participle anchoring an indirect statement is always 'indirect statement'; an attributive participle is 'attributive'; a circumstantial participle is 'circumstantial'.

id: str = PydanticUndefined

The token id (from the input tokens list) of the finite verb, infinitive, or participle that anchors this verbal expression. For a multi-word compound form with a conjugated form of εἰμί (e.g. the perfect passive/middle system, ὁ νόμος γεγραμμένος ἐστίν), use the id of the conjugated form of εἰμί, not the participle. For an implied/elided verbal expression (see TokenAnalysis's 'implied eimi'/'implied repetition' tokentypes, IMPLIED_TOKENTYPES), use the new implied token's id instead -- an implied token always anchors its own verbal expression.

syntactic_type: Literal['independent', 'dependent', 'direct quote', 'aside', 'indirect statement', 'attributive', 'circumstantial'] = PydanticUndefined

For a finite verb: 'independent' (main/principal), 'dependent' (subordinate/secondary, introduced by a subordinating conjunction or relative/interrogative pronoun), 'direct quote' (occurring in directly quoted speech), or 'aside' (interrupts the surrounding syntax). For an infinitive, or a participle after a verb of perception/thinking, anchoring an indirect statement: 'indirect statement'. For an attributive participle: 'attributive'. For a circumstantial participle (including a genitive absolute): 'circumstantial'.

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

The verb's semantic/voice type.

class TokenAnalysis(pydantic.main.BaseModel):
262class TokenAnalysis(BaseModel):
263    """One entry per token in the dependency graph (syntax_model.md,
264    'Token-level table of dependencies'). Not every token will have a
265    relation -- leave the relatedtoken*/relationship* fields unset when none
266    of the documented relations apply (e.g. an independent verb has no
267    relation2, and a punctuation token typically has none at all).
268
269    Most entries correspond 1:1 to an entry in the input `tokens` list. The
270    exceptions are the two IMPLIED_TOKENTYPES values below: syntax_model.md's
271    'understood or implied verbal expressions' section documents two
272    DIFFERENT situations where a verbal expression exists grammatically but
273    has no surface realization at all in the passage, and this codebase
274    distinguishes them with two distinct tokentype values rather than one
275    generic 'implied':
276
277    - 'implied eimi': an elided form of εἰμί ("to be") in a predicate
278      expression. Example: ταύτην τὴν ὕβριν ἅπαντες ἄνθρωποι δεινοτάτην
279      ἡγοῦνται has an implied infinitive of εἰμί governing the predicate
280      ταύτην τὴν ὕβριν ... δεινοτάτην in indirect statement after
281      ἡγοῦνται.
282    - 'implied repetition': a verb elided from a later verbal expression in
283      a coordinated series because it repeats the verb of an earlier one.
284      Example: ἐγὼ μὲν ἄνω διῃτώμην, αἱ δὲ γυναῖκες κάτω elides a second
285      διῃτώμην after γυναῖκες.
286
287    For either, add a NEW entry here -- with a NEW id, not present in
288    `tokens` -- rather than skipping the construction entirely; see
289    greek_syntax_dspy.SyntaxAnalysis's docstring for the full rules and the
290    id-naming convention."""
291
292    id: str = Field(
293        description=(
294            "For an ordinary entry, must match the id of the corresponding "
295            "entry in the input `tokens` list. For an implied token "
296            "(tokentype in IMPLIED_TOKENTYPES -- 'implied eimi' or "
297            "'implied repetition'), a NEW id not used by any entry in "
298            "`tokens` or elsewhere in this tokengraph -- see "
299            "SyntaxAnalysis's docstring for the naming convention."
300        )
301    )
302    token: Optional[str] = Field(
303        default=None,
304        description=(
305            "The token's surface text; should match the `text` of the input "
306            "token with this id. Leave as None ONLY for an implied token "
307            "(tokentype 'implied eimi' or 'implied repetition') -- one with "
308            "no surface realization in the passage at all; every other "
309            "tokentype must have real text."
310        ),
311    )
312    tokentype: Literal[
313        "lexical", "enclitic", "punctuation", "numeral",
314        "implied eimi", "implied repetition",
315    ] = Field(
316        description=(
317            "'numeral' is a number written NUMERICALLY (e.g. in Milesian "
318            "notation) rather than spelled out as a word; a number spelled "
319            "out as an ordinary word (e.g. δύω for 'two') is 'lexical' "
320            "instead, even though it's semantically a number -- e.g. in "
321            "'Ἀτρεΐδα δὲ μάλιστα δύω', δύω is 'lexical', not 'numeral'. "
322            "'enclitic' tokenization must consider context -- syntax_model.md's "
323            "tokenization section documents this. "
324            "'implied eimi' and 'implied repetition' each mark a token with "
325            "NO surface realization at all (see this model's own docstring "
326            "for the distinction) -- the only two tokentypes whose `token` "
327            "field is None and whose `id` is not one of the input `tokens`' "
328            "own ids."
329        )
330    )
331
332    lemma: Optional[str] = Field(default=None, description="Dictionary headword, for lexical tokens. Omit for punctuation.")
333    verbalunitid: Optional[str] = Field(
334        default=None,
335        description="If this token anchors a verbal expression in `verbalunits`, repeat its own id here; otherwise omit.",
336    )
337
338    relatedtoken1: Optional[str] = Field(
339        default=None,
340        description=(
341            "Token id this token relates to (primary relation). For an "
342            "INDEPENDENT verb's own 'unit verb' relation, use the special "
343            "sentinel string 'root' instead of a token id -- 'root' is "
344            "reserved and must never be assigned as an actual token's id."
345        ),
346    )
347    relationship1: Optional[RelationLabel] = Field(default=None, description="The primary relation type, if any.")
348
349    relatedtoken2: Optional[str] = Field(default=None, description="Token id this token relates to (secondary relation, used when relation1 is already occupied -- e.g. a relative pronoun's function inside its own clause).")
350    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'). Not every token will have a relation -- leave the relatedtoken*/relationship* fields unset when none of the documented relations apply (e.g. an independent verb has no relation2, and a punctuation token typically has none at all).

Most entries correspond 1:1 to an entry in the input tokens list. The exceptions are the two IMPLIED_TOKENTYPES values below: syntax_model.md's 'understood or implied verbal expressions' section documents two DIFFERENT situations where a verbal expression exists grammatically but has no surface realization at all in the passage, and this codebase distinguishes them with two distinct tokentype values rather than one generic 'implied':

  • 'implied eimi': an elided form of εἰμί ("to be") in a predicate expression. Example: ταύτην τὴν ὕβριν ἅπαντες ἄνθρωποι δεινοτάτην ἡγοῦνται has an implied infinitive of εἰμί governing the predicate ταύτην τὴν ὕβριν ... δεινοτάτην in indirect statement after ἡγοῦνται.
  • 'implied repetition': a verb elided from a later verbal expression in a coordinated series because it repeats the verb of an earlier one. Example: ἐγὼ μὲν ἄνω διῃτώμην, αἱ δὲ γυναῖκες κάτω elides a second διῃτώμην after γυναῖκες.

For either, add a NEW entry here -- with a NEW id, not present in tokens -- rather than skipping the construction entirely; see greek_syntax_dspy.SyntaxAnalysis's docstring for the full rules 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 in IMPLIED_TOKENTYPES -- 'implied eimi' or 'implied repetition'), 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 eimi' or 'implied repetition') -- one with no surface realization in the passage at all; every other tokentype must have real text.

tokentype: Literal['lexical', 'enclitic', 'punctuation', 'numeral', 'implied eimi', 'implied repetition'] = PydanticUndefined

'numeral' is a number written NUMERICALLY (e.g. in Milesian notation) rather than spelled out as a word; a number spelled out as an ordinary word (e.g. δύω for 'two') is 'lexical' instead, even though it's semantically a number -- e.g. in 'Ἀτρεΐδα δὲ μάλιστα δύω', δύω is 'lexical', not 'numeral'. 'enclitic' tokenization must consider context -- syntax_model.md's tokenization section documents this. 'implied eimi' and 'implied repetition' each mark a token with NO surface realization at all (see this model's own docstring for the distinction) -- the only two tokentypes whose token field is None and whose id is not one of the input tokens' own ids.

lemma: Optional[str] = None

Dictionary headword, for lexical tokens. Omit for punctuation.

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', 'aside', 'indirect statement', 'auxiliary', 'agent', 'object of preposition', 'circumstantial participle', 'genitive absolute', 'attributive participle', 'sentence connector', 'connecting word', 'subordinating conjunction', 'relative pronoun', 'subject', 'direct object', 'predicate', 'complementary infinitive', 'article', 'attributive', 'demonstrative', 'adverbial', 'genitive', 'dative', 'accusative', 'vocative', 'apposition', 'modal particle', 'exclamation']] = None

The primary relation type, if any.

relatedtoken2: Optional[str] = None

Token id this token relates to (secondary relation, used when relation1 is already occupied -- e.g. a relative pronoun's function inside its own clause).

relationship2: Optional[Literal['unit verb', 'direct quote', 'aside', 'indirect statement', 'auxiliary', 'agent', 'object of preposition', 'circumstantial participle', 'genitive absolute', 'attributive participle', 'sentence connector', 'connecting word', 'subordinating conjunction', 'relative pronoun', 'subject', 'direct object', 'predicate', 'complementary infinitive', 'article', 'attributive', 'demonstrative', 'adverbial', 'genitive', 'dative', 'accusative', 'vocative', 'apposition', 'modal particle', 'exclamation']] = None

The secondary relation type, if any.

RelationLabel = typing.Literal['unit verb', 'direct quote', 'aside', 'indirect statement', 'auxiliary', 'agent', 'object of preposition', 'circumstantial participle', 'genitive absolute', 'attributive participle', 'sentence connector', 'connecting word', 'subordinating conjunction', 'relative pronoun', 'subject', 'direct object', 'predicate', 'complementary infinitive', 'article', 'attributive', 'demonstrative', 'adverbial', 'genitive', 'dative', 'accusative', 'vocative', 'apposition', 'modal particle', 'exclamation']
IMPLIED_TOKENTYPES = frozenset({'implied repetition', 'implied eimi'})
def tokengraph_to_mermaid( tokengraph: List[TokenAnalysis], orientation: str = 'BT', color_by_verbal_unit: bool = True) -> Tuple[str, List[str]]:
114def tokengraph_to_mermaid(
115    tokengraph: List[TokenAnalysis],
116    orientation: str = "BT",
117    color_by_verbal_unit: bool = True,
118) -> Tuple[str, List[str]]:
119    """Build a Mermaid `graph` diagram from a tokengraph.
120
121    `orientation` is Mermaid's own flowchart orientation code -- `BT`
122    (bottom-to-top, the default here), `TB`, `LR`, or `RL` -- used verbatim
123    in the diagram's opening line (`graph BT`, `graph LR`, etc.). See
124    https://mermaid.js.org/syntax/flowchart.html for what each value looks
125    like; this function doesn't validate it, so a typo just becomes invalid
126    Mermaid syntax in the output rather than an error here.
127
128    `color_by_verbal_unit` (default True) colors every node by the verbal
129    unit it belongs to, per verbal_units.assign_verbal_units() -- so each
130    clause is visually distinguishable. Verbal units are assigned colors
131    from `_VERBAL_UNIT_PALETTE` in the order their tokens first appear in
132    `tokengraph`; a token assigned to no verbal unit is left with Mermaid's
133    default node styling. The one exception is an implied/elided token
134    (models.py's IMPLIED_TOKENTYPES) -- it always gets its own dedicated
135    `implied` class, colored with `verbal_units._IMPLIED_TOKEN_COLOR`,
136    instead of whatever `_VERBAL_UNIT_PALETTE` color its own verbal unit
137    would otherwise get (see this module's own docstring for why). Pass
138    False to skip coloring and get a plain diagram, as before this
139    parameter existed.
140
141    Returns (diagram_text, warnings). `warnings` lists any edges that were
142    skipped because they referenced a punctuation token or an id not present
143    in `tokengraph` -- worth checking, since it usually means the id came
144    from a validation problem upstream (see greek_syntax_dspy.validate) --
145    plus, if `color_by_verbal_unit` is True and the passage has more than 8
146    verbal units, one warning that colors are repeating rather than staying
147    distinct (the palette has 8 slots; see _VERBAL_UNIT_PALETTE).
148    """
149    node_ids = {tok.id for tok in tokengraph if tok.tokentype != "punctuation"}
150
151    lines = [f"graph {orientation}"]
152    for tok in tokengraph:
153        if tok.id not in node_ids:
154            continue
155        # An implied/elided token (see models.py's IMPLIED_TOKENTYPES) has
156        # no surface text at all -- tok.token is None -- so it needs a
157        # placeholder label rather than crashing _escape_label() on None.
158        # _IMPLIED_TOKEN_LABELS supplies that ("elided eimi" for "implied
159        # eimi", "implied repetition" verbatim); the node's color (below)
160        # is what actually marks it as an implied token, not the label
161        # text. token_label() is the shared helper dot.py's
162        # tokengraph_to_dot() also uses, so both renderers agree on this.
163        label = token_label(tok)
164        lines.append(f'    {tok.id}["{_escape_label(label)}"]')
165
166    warnings = []
167    for tok in tokengraph:
168        if tok.id not in node_ids:
169            continue
170        for related_field, label_field in (
171            ("relatedtoken1", "relationship1"),
172            ("relatedtoken2", "relationship2"),
173        ):
174            related_id = getattr(tok, related_field)
175            label = getattr(tok, label_field)
176            if related_id is None or label is None:
177                continue
178            if related_id == "root":
179                # An independent verb's own unit-verb relation, per
180                # syntax_model.md -- intentionally not a real node, so not
181                # a warning-worthy gap. Just draw no edge for it.
182                continue
183            if related_id not in node_ids:
184                warnings.append(
185                    f"skipped edge {tok.id} -[{label}]-> {related_id}: "
186                    f"target is punctuation or not in tokengraph"
187                )
188                continue
189            lines.append(f'    {tok.id} -->|{_escape_label(label)}| {related_id}')
190
191    if color_by_verbal_unit:
192        assignment = assign_verbal_units(tokengraph)
193        colors, color_warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment)
194        warnings.extend(color_warnings)
195
196        # Implied tokens (models.py's IMPLIED_TOKENTYPES) always get a
197        # dedicated "caution" amber (_IMPLIED_TOKEN_COLOR) instead of
198        # whatever color their own verbal unit would otherwise get --
199        # regardless of which unit they anchor -- so they're excluded from
200        # every per-unit `member_ids` group below and given their own
201        # classDef/class pair instead. See rendering.py's
202        # tokengraph_to_html() docstring for the matching HTML behavior.
203        implied_ids = [
204            tok.id
205            for tok in tokengraph
206            if tok.id in node_ids and tok.tokentype in IMPLIED_TOKENTYPES
207        ]
208
209        # A token whose relationship1 is specifically "sentence connector"
210        # (e.g. γάρ tying this sentence back to the previous one -- see
211        # rendering.py's identical carve-out, and models.py's RelationLabel
212        # docstring for the distinction from the more general "connecting
213        # word") always gets its own dedicated `sentenceconnector` class --
214        # a neon-yellow fill with a strong black border -- instead of
215        # whatever color its own verbal unit would otherwise get,
216        # regardless of which unit it's assigned to. Excluded from
217        # implied_ids (in the never-really-expected case a token is somehow
218        # both) so the two classes never compete for the same node.
219        connector_ids = [
220            tok.id
221            for tok in tokengraph
222            if tok.id in node_ids
223            and tok.id not in implied_ids
224            and tok.relationship1 == "sentence connector"
225        ]
226
227        if colors or implied_ids or connector_ids:
228            lines.append("")
229            class_names = {}
230            for i, (unit_id, (fill, stroke, text)) in enumerate(colors.items()):
231                class_name = f"vu{i}"
232                class_names[unit_id] = class_name
233                lines.append(
234                    f"    classDef {class_name} fill:{fill},stroke:{stroke},color:{text};"
235                )
236            for unit_id in colors:
237                member_ids = [
238                    tok.id
239                    for tok in tokengraph
240                    if tok.id in node_ids
241                    and assignment.get(tok.id) == unit_id
242                    and tok.id not in implied_ids
243                    and tok.id not in connector_ids
244                ]
245                if member_ids:
246                    lines.append(f"    class {','.join(member_ids)} {class_names[unit_id]};")
247            if implied_ids:
248                fill, stroke, text = _IMPLIED_TOKEN_COLOR
249                lines.append(
250                    f"    classDef implied fill:{fill},stroke:{stroke},color:{text};"
251                )
252                lines.append(f"    class {','.join(implied_ids)} implied;")
253            if connector_ids:
254                lines.append(
255                    "    classDef sentenceconnector fill:#ffff00,stroke:#000000,stroke-width:4px,color:#000000;"
256                )
257                lines.append(f"    class {','.join(connector_ids)} sentenceconnector;")
258
259    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; this function doesn't validate it, so a typo just becomes invalid Mermaid syntax in the output rather than an error here.

color_by_verbal_unit (default True) colors every node by the verbal unit it belongs to, per verbal_units.assign_verbal_units() -- so each clause is visually distinguishable. Verbal units are assigned colors from _VERBAL_UNIT_PALETTE in the order their tokens first appear in tokengraph; a token assigned to no verbal unit is left with Mermaid's default node styling. The one exception is an implied/elided token (models.py's IMPLIED_TOKENTYPES) -- it always gets its own dedicated implied class, colored with verbal_units._IMPLIED_TOKEN_COLOR, instead of whatever _VERBAL_UNIT_PALETTE color its own verbal unit would otherwise get (see this module's own docstring for why). Pass False to skip coloring and get a plain diagram, as before this parameter existed.

Returns (diagram_text, warnings). warnings lists any edges that were skipped because they referenced a punctuation token or an id not present in tokengraph -- worth checking, since it usually means the id came from a validation problem upstream (see greek_syntax_dspy.validate) -- plus, if color_by_verbal_unit is True and the passage has more than 8 verbal units, one warning that colors are repeating rather than staying distinct (the palette has 8 slots; see _VERBAL_UNIT_PALETTE).

def save_mermaid( tokengraph: List[TokenAnalysis], path: str, orientation: str = 'BT', color_by_verbal_unit: bool = True) -> List[str]:
262def save_mermaid(
263    tokengraph: List[TokenAnalysis],
264    path: str,
265    orientation: str = "BT",
266    color_by_verbal_unit: bool = True,
267) -> List[str]:
268    """Write the diagram to `path` (e.g. 'analysis.mmd') and return any
269    warnings from tokengraph_to_mermaid."""
270    diagram, warnings = tokengraph_to_mermaid(
271        tokengraph, orientation=orientation, color_by_verbal_unit=color_by_verbal_unit
272    )
273    with open(path, "w", encoding="utf-8") as f:
274        f.write(diagram + "\n")
275    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]]:
161def assign_verbal_units(tokengraph: List[TokenAnalysis]) -> Dict[str, Optional[str]]:
162    """Return {token id: verbal unit id or None}, one entry per token in
163    `tokengraph` (including punctuation and unrelated tokens, so every id
164    is accounted for -- callers that only care about assigned tokens can
165    filter out the None values themselves).
166
167    A verbal unit's own anchor token is assigned to itself (its
168    `verbalunitid`). Every other token is assigned to the verbal unit its
169    relations resolve to, per this module's docstring; a token with no
170    resolvable relation (e.g. a bare accusative of time, an enclitic left
171    unrelated) gets None.
172
173    A true genitive-absolute noun (its own outgoing relation is "genitive
174    absolute", not some normal clause role) is redirected to the verbal
175    unit of the circumstantial participle it agrees with, rather than to
176    the verb its own relatedtoken1 points at -- see this module's
177    docstring for the full "προϊόντος δὲ τοῦ χρόνου ... ἧκον" example.
178    Anything that in turn chains through that noun (an adjective, an
179    appositive) follows it into the participle's unit too, since this
180    redirect happens once, at the noun itself, and every other resolution
181    is unchanged.
182
183    Attributive participles need NO analogous redirect: the noun/pronoun
184    they agree with always keeps its ordinary syntactic role (there is no
185    "attributive absolute" construction), so it resolves correctly via the
186    plain fallback chase below without any special-casing -- see this
187    module's docstring for why.
188    """
189    by_id = {tok.id: tok for tok in tokengraph}
190
191    # Reverse index: for every token that some OTHER token points at via a
192    # "unit verb" relation, record who points at it. Per syntax_model.md,
193    # a "unit verb" target is always either the literal sentinel 'root'
194    # (from an independent verb -- never a real token) or a subordinating
195    # conjunction/relative (or interrogative) pronoun's id (from a
196    # dependent verb) -- so a hit here always means "this token introduces
197    # the pointing verb's clause."
198    introduces_clause_for: Dict[str, str] = {}
199    # Reverse index: for every token that some OTHER token points at via a
200    # "circumstantial participle" relation, record who points at it (the
201    # participle -- real or implied -- that agrees with it). Used below to
202    # redirect a TRUE genitive-absolute noun to that participle's own
203    # verbal unit instead of the verb it otherwise points at; a noun a
204    # circumstantial participle agrees with that fits normally into the
205    # clause (its own outgoing relation isn't "genitive absolute") is left
206    # alone and keeps resolving normally, so this index is consulted but
207    # not always used.
208    #
209    # There is deliberately NO analogous reverse index for "attributive
210    # participle": the noun/pronoun an attributive participle agrees with
211    # never needs redirecting (see this module's docstring), so building
212    # one here would just be dead code.
213    circumstantial_participle_for: Dict[str, str] = {}
214    for tok in tokengraph:
215        for related_field, label_field in (
216            ("relatedtoken1", "relationship1"),
217            ("relatedtoken2", "relationship2"),
218        ):
219            related = getattr(tok, related_field)
220            label = getattr(tok, label_field)
221            if related is None or related == "root":
222                continue
223            if label == _UNIT_VERB:
224                introduces_clause_for[related] = tok.id
225            elif label == _CIRCUMSTANTIAL_PARTICIPLE:
226                circumstantial_participle_for[related] = tok.id
227
228    resolved: Dict[str, Optional[str]] = {}
229    in_progress: set = set()
230
231    def resolve(tid: str) -> Optional[str]:
232        if tid in resolved:
233            return resolved[tid]
234        tok = by_id.get(tid)
235        if tok is None:
236            return None
237
238        if tok.verbalunitid is not None:
239            resolved[tid] = tok.verbalunitid
240            return tok.verbalunitid
241
242        if tid in in_progress:
243            # A cycle in the relation graph (malformed LM output) -- bail
244            # out on this token rather than recursing forever.
245            return None
246        in_progress.add(tid)
247
248        result = None
249
250        clause_verb_id = introduces_clause_for.get(tid)
251        if clause_verb_id is not None:
252            result = resolve(clause_verb_id)
253
254        if result is None:
255            participle_id = circumstantial_participle_for.get(tid)
256            is_genitive_absolute = (
257                tok.relationship1 == _GENITIVE_ABSOLUTE
258                or tok.relationship2 == _GENITIVE_ABSOLUTE
259            )
260            if participle_id is not None and is_genitive_absolute:
261                result = resolve(participle_id)
262
263        if result is None:
264            for related_field in ("relatedtoken1", "relatedtoken2"):
265                related = getattr(tok, related_field)
266                if related is None or related == "root":
267                    continue
268                result = resolve(related)
269                if result is not None:
270                    break
271
272        in_progress.discard(tid)
273        resolved[tid] = result
274        return result
275
276    for tid in by_id:
277        resolve(tid)
278
279    return resolved

Return {token id: verbal unit id or None}, one entry per token in tokengraph (including punctuation and unrelated tokens, 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, per this module's docstring; a token with no resolvable relation (e.g. a bare accusative of time, an enclitic left unrelated) gets None.

A true genitive-absolute noun (its own outgoing relation is "genitive absolute", not some normal clause role) is redirected to the verbal unit of the circumstantial participle it agrees with, rather than to the verb its own relatedtoken1 points at -- see this module's docstring for the full "προϊόντος δὲ τοῦ χρόνου ... ἧκον" example. Anything that in turn chains through that noun (an adjective, an appositive) follows it into the participle's unit too, since this redirect happens once, at the noun itself, and every other resolution is unchanged.

Attributive participles need NO analogous redirect: the noun/pronoun they agree with always keeps its ordinary syntactic role (there is no "attributive absolute" construction), so it resolves correctly via the plain fallback chase below without any special-casing -- see this module's docstring for why.

def assign_verbal_unit_colors( tokengraph: List[TokenAnalysis], assignment: Optional[Dict[str, Optional[str]]] = None) -> Tuple[Dict[str, Tuple[str, str, str]], List[str]]:
282def assign_verbal_unit_colors(
283    tokengraph: List[TokenAnalysis],
284    assignment: Optional[Dict[str, Optional[str]]] = None,
285) -> Tuple[Dict[str, Tuple[str, str, str]], List[str]]:
286    """Assign each verbal unit found in `tokengraph` a stable (fill, stroke,
287    text) triple from `_VERBAL_UNIT_PALETTE`, using the exact ordering rule
288    `tokengraph_to_mermaid()` uses for its node coloring -- so any other
289    caller wanting "the same colors as the mermaid graph" (currently
290    rendering.py's `tokengraph_to_html()`) gets an identical mapping without
291    re-deriving the rule itself.
292
293    Order is by first appearance of each verbal unit among tokengraph's
294    *non-punctuation* tokens, since those are the only tokens that become
295    mermaid nodes at all -- a verbal unit whose earliest token happens to be
296    punctuation (it can't be: punctuation tokens aren't assigned to a
297    verbal unit's anchor, but could in principle inherit one from a
298    relation) still gets ordered by its first non-punctuation member.
299
300    Pass `assignment` (the result of `assign_verbal_units(tokengraph)`) if
301    the caller already computed it, to avoid re-deriving it here; otherwise
302    it's computed internally.
303
304    Returns `({verbal unit id: (fill, stroke, text)}, warnings)` --
305    `warnings` holds one entry, with the same wording
306    `tokengraph_to_mermaid()` uses, if there are more distinct verbal units
307    than palette slots (colors repeat past the 8th unit). A verbal unit id
308    absent from the returned dict was never assigned to any non-punctuation
309    token -- callers should treat that the same as "no verbal unit" (no
310    coloring), same as `tokengraph_to_mermaid()` does.
311    """
312    if assignment is None:
313        assignment = assign_verbal_units(tokengraph)
314
315    non_punctuation_ids = {tok.id for tok in tokengraph if tok.tokentype != "punctuation"}
316
317    unit_order: List[str] = []
318    seen_units = set()
319    for tok in tokengraph:
320        if tok.id not in non_punctuation_ids:
321            continue
322        unit_id = assignment.get(tok.id)
323        if unit_id is not None and unit_id not in seen_units:
324            seen_units.add(unit_id)
325            unit_order.append(unit_id)
326
327    warnings: List[str] = []
328    if len(unit_order) > len(_VERBAL_UNIT_PALETTE):
329        warnings.append(
330            f"{len(unit_order)} verbal units but only {len(_VERBAL_UNIT_PALETTE)} "
331            "distinct colors -- colors repeat and may be ambiguous between units"
332        )
333
334    colors = {
335        unit_id: _VERBAL_UNIT_PALETTE[i % len(_VERBAL_UNIT_PALETTE)]
336        for i, unit_id in enumerate(unit_order)
337    }
338    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" (currently 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 non-punctuation tokens, since those are the only tokens that become mermaid nodes at all -- a verbal unit whose earliest token happens to be punctuation (it can't be: punctuation tokens aren't assigned to a verbal unit's anchor, but could in principle inherit one from a relation) still gets ordered by its first non-punctuation member.

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, with the same wording tokengraph_to_mermaid() uses, 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 non-punctuation token -- callers should treat that the same as "no verbal unit" (no coloring), same as tokengraph_to_mermaid() does.

def compute_subordination_depths( tokengraph: List[TokenAnalysis]) -> Tuple[Dict[str, Optional[int]], List[str]]:
341def compute_subordination_depths(
342    tokengraph: List[TokenAnalysis],
343) -> Tuple[Dict[str, Optional[int]], List[str]]:
344    """Compute each verbal expression's *depth of subordination*: the
345    number of verbal expressions it is removed from an independent ("root")
346    clause. An independent verb is depth 0; a verb it introduces (a
347    dependent clause, a direct quote, an aside) is depth 1; a verbal
348    expression THAT verb in turn introduces (e.g. an indirect statement
349    inside a dependent clause) is depth 2; and so on.
350
351    A "verbal expression" here is any token that anchors one -- i.e. any
352    token with `verbalunitid` set to its own id (the same convention
353    `assign_verbal_units()` relies on). For each anchor, this function
354    finds its *parent* anchor -- the verbal expression it's subordinate to
355    -- by following the anchor's own relatedtoken1 (falling back to
356    relatedtoken2), through as many intermediate non-anchor tokens as
357    necessary, until it lands on another anchor. This one chase handles
358    every documented case uniformly, without needing to special-case by
359    relationship label, because they all eventually resolve to another
360    anchor via forward pointers already in the graph:
361
362    - unit verb (independent): relatedtoken1 == 'root' -> no parent, depth 0.
363    - unit verb (dependent): relatedtoken1 -> a subordinating conjunction or
364      relative/interrogative pronoun (not itself an anchor) -> ITS
365      relatedtoken1 -> the superior verb (a conjunction) or an antecedent
366      noun (a relative pronoun), the latter requiring one more hop through
367      the noun's own relation to reach the verb it depends on. Example:
368      "ἐπειδὴ δὲ ἦν πρὸς ἡμέραν, ἧκεν ἐκείνη" -- ἦν's relatedtoken1 is
369      ἐπειδὴ, whose own relatedtoken1 is ἧκεν.
370    - direct quote / aside / indirect statement: relatedtoken1 -> the verb
371      of the clause it interrupts, is framed by, or (for an indirect-
372      statement infinitive or participle) governs it, directly (no
373      intermediate token). Examples: πειρᾷς -> ἔφη (direct quote); δεῖ ->
374      ἔστι (aside); ἀποσβεσθῆναι -> ἔφασκε (indirect statement, an
375      infinitive); φεύγουσαν -> εἶδε (indirect statement, a participle
376      after a verb of perception).
377    - circumstantial participle: relatedtoken1 -> the noun/pronoun it
378      agrees with (not itself an anchor) -> that noun's own relation,
379      either its normal role in the surrounding clause (one more hop to a
380      verb, e.g. παραλείπων/λέγων -> ἐγώ -> ἐπιδείξω via "subject") or, for
381      a true genitive absolute, 'genitive absolute' pointing directly at
382      the main verb (e.g. προϊόντος -> χρόνου -> ἧκον).
383    - attributive participle: structurally identical to the circumstantial
384      case above, just with a different relationship label and no
385      "absolute" variant -- relatedtoken1 -> the noun/pronoun it agrees
386      with (not itself an anchor) -> that noun's own ordinary relation,
387      one more hop to the governing verb. Example: "ὁ γὰρ ἀνὴρ ὁ ὑβρίζων
388      εἰς σὲ ... τυγχάνει" -- ὑβρίζων's relatedtoken1 is ἀνήρ, whose own
389      relatedtoken1 (relationship1 "subject") is τυγχάνει. This is the one
390      construction with no Latin precedent at all (Latin's scheme never
391      treats an attributive participle as a verbal expression), but it
392      needs no new code here: the same generic hop-through-the-noun chase
393      that already handles circumstantial participles handles it too.
394
395    Returns `({anchor id: depth or None}, warnings)`. A depth of `None`
396    means the chase from that anchor never reached another anchor (a
397    malformed or genuinely disconnected verbal expression) or a cycle was
398    detected; `warnings` names which anchor(s) and why, mirroring
399    `tokengraph_to_mermaid()`'s warnings-list convention rather than
400    raising.
401    """
402    by_id = {tok.id: tok for tok in tokengraph}
403    anchor_ids = {tok.id for tok in tokengraph if tok.verbalunitid == tok.id}
404
405    warnings: List[str] = []
406
407    def chase(token_id: str, visited: set) -> Optional[str]:
408        """Follow relatedtoken1 (then relatedtoken2) forward from
409        `token_id`, returning the first anchor id reached, or None if the
410        chain dead-ends or cycles before reaching one. `token_id` itself
411        counts as a hit if it's already an anchor (the direct-link cases:
412        direct quote, aside, indirect statement)."""
413        if token_id in visited:
414            return None
415        visited.add(token_id)
416        if token_id in anchor_ids:
417            return token_id
418        tok = by_id.get(token_id)
419        if tok is None:
420            return None
421        for field in ("relatedtoken1", "relatedtoken2"):
422            target = getattr(tok, field)
423            if target is None or target == "root":
424                continue
425            result = chase(target, visited)
426            if result is not None:
427                return result
428        return None
429
430    def parent_of(anchor_id: str) -> Optional[str]:
431        tok = by_id[anchor_id]
432        for field in ("relatedtoken1", "relatedtoken2"):
433            target = getattr(tok, field)
434            if target is None or target == "root":
435                continue
436            result = chase(target, visited=set())
437            if result is not None and result != anchor_id:
438                return result
439        return None
440
441    depths: Dict[str, Optional[int]] = {}
442    in_progress: set = set()
443
444    def depth_of(anchor_id: str) -> Optional[int]:
445        if anchor_id in depths:
446            return depths[anchor_id]
447        tok = by_id[anchor_id]
448        if tok.relatedtoken1 == "root":
449            depths[anchor_id] = 0
450            return 0
451
452        if anchor_id in in_progress:
453            warnings.append(
454                f"cycle detected resolving the governing verbal expression "
455                f"for {anchor_id!r} -- leaving its depth (and its parent's) "
456                f"unresolved"
457            )
458            return None
459        in_progress.add(anchor_id)
460
461        parent = parent_of(anchor_id)
462        if parent is None:
463            warnings.append(
464                f"could not find a governing verbal expression for "
465                f"{anchor_id!r} -- leaving its depth unresolved"
466            )
467            result = None
468        else:
469            parent_depth = depth_of(parent)
470            result = None if parent_depth is None else parent_depth + 1
471
472        in_progress.discard(anchor_id)
473        depths[anchor_id] = result
474        return result
475
476    for anchor_id in anchor_ids:
477        depth_of(anchor_id)
478
479    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 verb it introduces (a dependent clause, a direct quote, an aside) is depth 1; a verbal expression THAT verb in turn introduces (e.g. an indirect statement inside a dependent clause) is 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), through as many intermediate non-anchor tokens as necessary, until it lands on another anchor. This one chase handles every documented case uniformly, without needing to special-case by relationship label, because they all eventually resolve to another anchor via forward pointers already in the graph:

  • unit verb (independent): relatedtoken1 == 'root' -> no parent, depth 0.
  • unit verb (dependent): relatedtoken1 -> a subordinating conjunction or relative/interrogative pronoun (not itself an anchor) -> ITS relatedtoken1 -> the superior verb (a conjunction) or an antecedent noun (a relative pronoun), the latter requiring one more hop through the noun's own relation to reach the verb it depends on. Example: "ἐπειδὴ δὲ ἦν πρὸς ἡμέραν, ἧκεν ἐκείνη" -- ἦν's relatedtoken1 is ἐπειδὴ, whose own relatedtoken1 is ἧκεν.
  • direct quote / aside / indirect statement: relatedtoken1 -> the verb of the clause it interrupts, is framed by, or (for an indirect- statement infinitive or participle) governs it, directly (no intermediate token). Examples: πειρᾷς -> ἔφη (direct quote); δεῖ -> ἔστι (aside); ἀποσβεσθῆναι -> ἔφασκε (indirect statement, an infinitive); φεύγουσαν -> εἶδε (indirect statement, a participle after a verb of perception).
  • circumstantial participle: relatedtoken1 -> the noun/pronoun it agrees with (not itself an anchor) -> that noun's own relation, either its normal role in the surrounding clause (one more hop to a verb, e.g. παραλείπων/λέγων -> ἐγώ -> ἐπιδείξω via "subject") or, for a true genitive absolute, 'genitive absolute' pointing directly at the main verb (e.g. προϊόντος -> χρόνου -> ἧκον).
  • attributive participle: structurally identical to the circumstantial case above, just with a different relationship label and no "absolute" variant -- relatedtoken1 -> the noun/pronoun it agrees with (not itself an anchor) -> that noun's own ordinary relation, one more hop to the governing verb. Example: "ὁ γὰρ ἀνὴρ ὁ ὑβρίζων εἰς σὲ ... τυγχάνει" -- ὑβρίζων's relatedtoken1 is ἀνήρ, whose own relatedtoken1 (relationship1 "subject") is τυγχάνει. This is the one construction with no Latin precedent at all (Latin's scheme never treats an attributive participle as a verbal expression), but it needs no new code here: the same generic hop-through-the-noun chase that already handles circumstantial participles handles it too.

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, mirroring tokengraph_to_mermaid()'s warnings-list convention rather than raising.

def max_subordination_depth( tokengraph: List[TokenAnalysis], depths: Optional[Dict[str, Optional[int]]] = None) -> Optional[int]:
482def max_subordination_depth(
483    tokengraph: List[TokenAnalysis],
484    depths: Optional[Dict[str, Optional[int]]] = None,
485) -> Optional[int]:
486    """Return the deepest level of subordination reached anywhere in
487    `tokengraph` -- the highest value `compute_subordination_depths()`
488    assigns to any verbal expression. Root/independent clauses are depth
489    0, so this is also the upper end of the valid `depth` range for
490    `rendering.tokengraph_to_depth_html()`'s own `depth` parameter (whose
491    valid range is 0, root clauses only, through this function's return
492    value, everything).
493
494    Pass `depths` (the first element of `compute_subordination_depths()`'s
495    return value) if the caller already computed it, to avoid re-deriving
496    it here; otherwise it's computed internally (any resolution warnings
497    are silently dropped in that case -- call
498    `compute_subordination_depths()` directly first if the caller also
499    needs those).
500
501    Returns `None` if `tokengraph` has no verbal expressions at all (an
502    empty passage, or one with none of the five constructions
503    syntax_model.md counts as one), or if every anchor's own depth came
504    back unresolved (see `compute_subordination_depths()`'s own
505    warnings for why an anchor might be unresolved -- a relation cycle, or
506    a governing verbal expression that couldn't be found). Otherwise
507    returns the maximum of every RESOLVED anchor's depth, ignoring
508    unresolved ones rather than letting a single bad anchor blank out the
509    whole result.
510    """
511    if depths is None:
512        depths, _warnings = compute_subordination_depths(tokengraph)
513
514    resolved = [d for d in depths.values() if d is not None]
515    if not resolved:
516        return None
517    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 (whose valid range is 0, root clauses only, through this function's return value, everything).

Pass depths (the first element of compute_subordination_depths()'s return value) if the caller already computed it, to avoid re-deriving it here; otherwise it's computed internally (any resolution warnings are silently dropped in that case -- call compute_subordination_depths() directly first if the caller also needs those).

Returns None if tokengraph has no verbal expressions at all (an empty passage, or one with none of the five constructions syntax_model.md counts as one), or if every anchor's own depth came back unresolved (see compute_subordination_depths()'s own warnings for why an anchor might be unresolved -- a relation cycle, or a governing verbal expression that couldn't be found). 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]:
520def find_unanchored_coordinated_verbs(tokengraph: List[TokenAnalysis]) -> List[str]:
521    """Heuristic sanity check, adapted from arsgrammatica's original, for a
522    class of live-LM mistake: a "connecting word" correctly marks a series
523    of coordinate CLAUSES, but one of the later clauses' own verb never
524    gets flagged as its own verbal-unit anchor (no `verbalunitid` set) --
525    e.g. an explicit verb the LM forgot to add to `verbalunits`, or a
526    missing 'implied repetition' token for an elided one.
527
528    This check USED to need a coarse, imprecise proxy (does some OTHER
529    verbal expression merely exist at the same subordination depth as the
530    series' first member?), because an earlier version of syntax_model.md's
531    "connecting word" relation recorded only ONE id anywhere in the graph
532    -- "the id of the first item" in the series -- never the id of the
533    item the connecting word itself was attached to, so there was nothing
534    to name the second (or later) member directly and compare against.
535    (Latin's own "coordinating conjunction", by contrast, always named
536    both conjuncts, one on relatedtoken1 and one on relatedtoken2 of the
537    same token -- see gold_examples.py's
538    coordinating_conjunction_dedit_et_dixit_esse fixture for the original,
539    Latin-side observation this function is adapted from.)
540
541    syntax_model.md's current "connecting word" rules (its "other uses of
542    connecting words" section) removed that limitation: every connecting
543    word now names ITS OWN adjacent item on relation1 (never generically
544    "the first item"), and relation2 completes the pair or series, in one
545    of three shapes -- see SyntaxAnalysis's own docstring for the full
546    account with worked examples:
547      - a single connecting word joining a pair: relation1 = the first
548        item's id, relation2 = the second item's id, both directly on the
549        SAME token.
550      - a paired correlative (τε...καί, a repeated καὶ...καί, or a
551        within-sentence μέν...δέ pair): each connector's own relation1 =
552        its own adjacent item, relation2 = the id of the OTHER connector.
553      - a 3+-member series (e.g. οὔτε...οὔτε...οὔτε): each connector's own
554        relation1 = its own adjacent item again; relation2 chains the
555        connectors together instead of naming an item -- the first
556        connector's relation2 points forward to the second connector, and
557        every later connector's relation2 points backward to the one
558        immediately before it.
559
560    This function exploits that added precision. It first groups every
561    "connecting word" token into its own coordinate chain: a lone token by
562    itself for the single-pair shape, or several tokens linked together
563    (by relation2 pointing from one connecting word to another) for a
564    correlative pair or series. For each chain, it collects every member's
565    own coordinated ITEM -- relation1 always, plus relation2 whenever that
566    ISN'T itself another connecting word in the same chain (i.e. the
567    direct second-item pointer the single-pair shape uses). If ANY item in
568    a chain turns out to be a recognized verbal-unit anchor, the whole
569    chain is coordinating CLAUSES (not nouns/adjectives/adverbs), so every
570    OTHER item in that chain is expected to be an anchor too -- one that
571    isn't gets named directly in a warning, the same precision Latin's
572    original dual-linked check had. A lone connecting word whose relation1
573    already points at an anchor but that has no relation2 at all (and no
574    correlative partner pointing back at it either) is flagged separately:
575    it looks like one half of a pair whose other half was never recorded.
576
577    "sentence connector" is deliberately excluded from this check, exactly
578    as before: syntax_model.md defines it as pointing at "the verb of THIS
579    sentence" -- the sentence it introduces, not a cross-clause pairing
580    with whatever precedes it (a sentence connector never records a
581    cross-sentence link) -- so it never asserts "there are two coordinate
582    members" the way "connecting word" does, and there is nothing here for
583    it to be checked against.
584
585    Returns a list of warning strings (empty if nothing looks suspicious),
586    the same "degrade visibly, don't raise" convention every other
587    warnings-returning function in this codebase uses. This is a
588    heuristic, not a guarantee: a clean result here isn't a substitute for
589    validate() or a human read of the analysis, and a flagged result
590    deserves a look rather than an automatic "fix."
591    """
592    by_id = {tok.id: tok for tok in tokengraph}
593    anchor_ids = {tok.id for tok in tokengraph if tok.verbalunitid == tok.id}
594    connecting_word_ids = {
595        tok.id for tok in tokengraph if tok.relationship1 == _CONNECTING_WORD
596    }
597
598    # Union-find over connecting-word tokens: two are in the same
599    # coordinate chain if either's relatedtoken2 names the other -- the
600    # correlative-pair/series-chaining shape described above. A lone
601    # connecting word (the single-pair shape) ends up its own singleton
602    # chain.
603    parent: Dict[str, str] = {cid: cid for cid in connecting_word_ids}
604
605    def find(x: str) -> str:
606        while parent[x] != x:
607            parent[x] = parent[parent[x]]
608            x = parent[x]
609        return x
610
611    def union(a: str, b: str) -> None:
612        ra, rb = find(a), find(b)
613        if ra != rb:
614            parent[ra] = rb
615
616    for cid in connecting_word_ids:
617        partner = by_id[cid].relatedtoken2
618        if partner in connecting_word_ids:
619            union(cid, partner)
620
621    chains: Dict[str, List[str]] = {}
622    for cid in connecting_word_ids:
623        chains.setdefault(find(cid), []).append(cid)
624
625    warnings: List[str] = []
626    for members in sorted(chains.values(), key=lambda m: sorted(m)):
627        items: List[Tuple[str, str]] = []  # (connecting word id, item id)
628        for cid in sorted(members):
629            tok = by_id[cid]
630            for target in (tok.relatedtoken1, tok.relatedtoken2):
631                if target is None or target == "root" or target in connecting_word_ids:
632                    continue
633                items.append((cid, target))
634
635        if not any(target in anchor_ids for _cid, target in items):
636            continue  # not a clause-coordinating chain -- nouns/adjectives/etc.
637
638        for cid, target in items:
639            if target in anchor_ids:
640                continue
641            item_tok = by_id.get(target)
642            text = item_tok.token if item_tok is not None else target
643            connector_tok = by_id[cid]
644            warnings.append(
645                f"{cid} ({connector_tok.token!r}) is a 'connecting word' "
646                "coordinating clauses (another member of its chain points "
647                "at a recognized verbal-unit anchor), but its own item "
648                f"{target!r} ({text!r}) is not itself anchored -- it may "
649                "be missing its own verbalunitid (an explicit verb that "
650                "wasn't flagged, or a missing 'implied repetition' token "
651                "if the verb was elided)."
652            )
653
654        if len(members) == 1:
655            only_cid = members[0]
656            tok = by_id[only_cid]
657            if tok.relatedtoken1 in anchor_ids and tok.relatedtoken2 is None:
658                warnings.append(
659                    f"{only_cid} ({tok.token!r}) is a 'connecting word' "
660                    "whose relation1 points at a recognized verbal-unit "
661                    "anchor, but it has no relation2 and no correlative "
662                    "partner -- it looks like one half of a coordinated "
663                    "pair whose other member was never recorded."
664                )
665
666    return warnings

Heuristic sanity check, adapted from arsgrammatica's original, for a class of live-LM mistake: a "connecting word" correctly marks a series of coordinate CLAUSES, but one of the later clauses' own verb never gets flagged as its own verbal-unit anchor (no verbalunitid set) -- e.g. an explicit verb the LM forgot to add to verbalunits, or a missing 'implied repetition' token for an elided one.

This check USED to need a coarse, imprecise proxy (does some OTHER verbal expression merely exist at the same subordination depth as the series' first member?), because an earlier version of syntax_model.md's "connecting word" relation recorded only ONE id anywhere in the graph -- "the id of the first item" in the series -- never the id of the item the connecting word itself was attached to, so there was nothing to name the second (or later) member directly and compare against. (Latin's own "coordinating conjunction", by contrast, always named both conjuncts, one on relatedtoken1 and one on relatedtoken2 of the same token -- see gold_examples.py's coordinating_conjunction_dedit_et_dixit_esse fixture for the original, Latin-side observation this function is adapted from.)

syntax_model.md's current "connecting word" rules (its "other uses of connecting words" section) removed that limitation: every connecting word now names ITS OWN adjacent item on relation1 (never generically "the first item"), and relation2 completes the pair or series, in one of three shapes -- see SyntaxAnalysis's own docstring for the full account with worked examples:

  • a single connecting word joining a pair: relation1 = the first item's id, relation2 = the second item's id, both directly on the SAME token.
  • a paired correlative (τε...καί, a repeated καὶ...καί, or a within-sentence μέν...δέ pair): each connector's own relation1 = its own adjacent item, relation2 = the id of the OTHER connector.
  • a 3+-member series (e.g. οὔτε...οὔτε...οὔτε): each connector's own relation1 = its own adjacent item again; relation2 chains the connectors together instead of naming an item -- the first connector's relation2 points forward to the second connector, and every later connector's relation2 points backward to the one immediately before it.

This function exploits that added precision. It first groups every "connecting word" token into its own coordinate chain: a lone token by itself for the single-pair shape, or several tokens linked together (by relation2 pointing from one connecting word to another) for a correlative pair or series. For each chain, it collects every member's own coordinated ITEM -- relation1 always, plus relation2 whenever that ISN'T itself another connecting word in the same chain (i.e. the direct second-item pointer the single-pair shape uses). If ANY item in a chain turns out to be a recognized verbal-unit anchor, the whole chain is coordinating CLAUSES (not nouns/adjectives/adverbs), so every OTHER item in that chain is expected to be an anchor too -- one that isn't gets named directly in a warning, the same precision Latin's original dual-linked check had. A lone connecting word whose relation1 already points at an anchor but that has no relation2 at all (and no correlative partner pointing back at it either) is flagged separately: it looks like one half of a pair whose other half was never recorded.

"sentence connector" is deliberately excluded from this check, exactly as before: syntax_model.md defines it as pointing at "the verb of THIS sentence" -- the sentence it introduces, not a cross-clause pairing with whatever precedes it (a sentence connector never records a cross-sentence link) -- so it never asserts "there are two coordinate members" the way "connecting word" does, and there is nothing here for it to be checked against.

Returns a list of warning strings (empty if nothing looks suspicious), the same "degrade visibly, don't raise" convention every other warnings-returning function in this codebase uses. This is a heuristic, not a guarantee: a clean result here isn't a substitute for validate() or a human read of the analysis, and a flagged result deserves a look rather than an automatic "fix."

def tokengraph_to_dot( tokengraph: List[TokenAnalysis], orientation: str = 'BT', color_by_verbal_unit: bool = True, rank_by_depth: bool = True, depth: Optional[int] = None) -> Tuple[str, List[str]]:
276def tokengraph_to_dot(
277    tokengraph: List[TokenAnalysis],
278    orientation: str = "BT",
279    color_by_verbal_unit: bool = True,
280    rank_by_depth: bool = True,
281    depth: Optional[int] = None,
282) -> Tuple[str, List[str]]:
283    """Build a Graphviz DOT `digraph` from a tokengraph -- the same diagram
284    tokengraph_to_mermaid() draws (same nodes, same edges, same coloring),
285    as DOT source instead of Mermaid source. See this module's own
286    docstring for why this exists alongside tokengraph_to_mermaid(), the
287    two adaptations from arsgrammatica's own dot.py, and what actually
288    rendering the result requires.
289
290    `orientation` maps directly onto DOT's `rankdir` graph attribute -- `BT`
291    (bottom-to-top, the default here, matching tokengraph_to_mermaid()'s
292    own default), `TB`, `LR`, or `RL`. Not validated here, same as
293    tokengraph_to_mermaid()'s `orientation` -- a typo just becomes an
294    attribute value Graphviz itself will reject.
295
296    `color_by_verbal_unit` (default True) colors every node by the verbal
297    unit it belongs to, per verbal_units.assign_verbal_units() -- the exact
298    same colors (and the same >8-verbal-units warning) as
299    tokengraph_to_mermaid(), just written as `fillcolor`/`color`/
300    `fontcolor` attributes directly on each node line instead of Mermaid's
301    separate `classDef`/`class` statements (DOT has no equivalent of a
302    named, reusable class -- inline per-node attributes are the idiomatic
303    way to do this). An implied/elided token (IMPLIED_TOKENTYPES) always
304    gets its own dedicated amber (verbal_units._IMPLIED_TOKEN_COLOR)
305    instead of whatever color its own verbal unit would otherwise get, and
306    a token whose relationship1 is "sentence connector" always gets its own
307    dedicated neon-yellow/strong-border color (_SENTENCE_CONNECTOR_COLOR),
308    both exactly mirroring tokengraph_to_mermaid()'s own precedence (see
309    that function's docstring). Pass False for a plain, uncolored diagram.
310
311    `rank_by_depth` (default True) is the reason this module exists
312    alongside tokengraph_to_mermaid() -- see the module docstring. Every
313    verbal-unit anchor node (any token with `verbalunitid` set to its own
314    id, implied tokens included) at the same depth in
315    verbal_units.compute_subordination_depths() -- grammatike's own
316    substitute for arsgrammatica's compute_aat_depths(), see "Two
317    adaptations" in this module's own docstring -- gets listed together in
318    one `{rank=same; id1; id2; ...}` subgraph statement, which *forces*
319    Graphviz's layout engine to place them on the same rank -- not a nudge,
320    a hard constraint. A depth with only one anchor gets no `rank=same`
321    statement (nothing to align it WITH); an anchor whose subordination
322    depth came back unresolved (None -- a relation cycle, or no governing
323    verbal expression found; see compute_subordination_depths()'s own
324    docstring) is excluded from every grouping, and its own warning about
325    why is folded into this function's returned warnings. Pass False to
326    skip this and let Graphviz's own layout heuristics place every node.
327
328    `depth`, if given, caps the diagram to nodes at or within that many
329    edges of a root/independent verbal-unit anchor -- compute_graph_depths()
330    above, a plain GRAPH distance along the same relatedtoken1/
331    relatedtoken2 edges drawn as `->` lines below, NOT
332    verbal_units.compute_subordination_depths() (`rank_by_depth` above, and
333    the CLAUSE-level notion behind tokengraph_to_depth_html()'s own
334    indented-HTML `depth` slider -- a whole clause's subject, object, and
335    other ordinary dependents share ONE subordination depth with their
336    verb, but each is its own hop of GRAPH depth). `depth=0` shows ONLY
337    root anchors -- an independent verb with no dependents at all;
338    `depth=1` adds every token one edge away from a root anchor (its
339    subject, object, adverbials, ...); and so on. A token farther than
340    `depth` is dropped entirely: omitted as a node, exactly as if it had
341    never been in `tokengraph`. Omit `depth` (or pass `None`, the default)
342    to show every node, same as before this parameter existed. A `depth` at
343    or beyond max_graph_depth()'s own return value for this `tokengraph`
344    shows everything too; a negative `depth` raises ValueError.
345
346    Dropping a node can leave a KEPT node's edge pointing at a now-excluded
347    one. Such an edge is skipped, with the same combined warning already
348    used for an edge targeting punctuation or a genuinely absent id (see
349    Returns below) -- `depth` filtering degrades visibly rather than
350    emitting a dangling `->` line Graphviz would reject.
351
352    Returns `(dot_source, warnings)` -- same shape and same warnings as
353    tokengraph_to_mermaid(): an edge skipped because it targets a
354    punctuation token, a token excluded by the `depth` cutoff, or an id not
355    present in `tokengraph` (except the 'root' sentinel, skipped silently,
356    same as there); if `color_by_verbal_unit` is True and the passage has
357    more than 8 verbal units, one warning that colors are repeating.
358    `depth` filtering itself never adds a warning (compute_graph_depths()
359    has no unresolved state -- an unrelated or cyclic token just defaults
360    to depth 0), but `rank_by_depth` CAN add one -- see above, and the "Two
361    adaptations" section of this module's own docstring for why this
362    differs from arsgrammatica's own version.
363    """
364    if depth is not None and depth < 0:
365        raise ValueError(f"depth must be >= 0 (root nodes only), got {depth!r}")
366
367    node_ids = {tok.id for tok in tokengraph if tok.tokentype != "punctuation"}
368
369    warnings: List[str] = []
370    if depth is not None:
371        graph_depths = compute_graph_depths(tokengraph)
372        depth_excluded_ids = {tok_id for tok_id, d in graph_depths.items() if d > depth}
373        node_ids -= depth_excluded_ids
374
375    colors_by_unit: Dict[str, Tuple[str, str, str]] = {}
376    assignment: Dict[str, Optional[str]] = {}
377    implied_ids: set = set()
378    connector_ids: set = set()
379    if color_by_verbal_unit:
380        assignment = assign_verbal_units(tokengraph)
381        colors_by_unit, color_warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment)
382        warnings.extend(color_warnings)
383        implied_ids = {
384            tok.id
385            for tok in tokengraph
386            if tok.id in node_ids and tok.tokentype in IMPLIED_TOKENTYPES
387        }
388        # See this module's own docstring, "Two adaptations" (2) --
389        # grammatike-specific, no arsgrammatica counterpart. Excluded from
390        # implied_ids (in the never-really-expected case a token is somehow
391        # both) so the two colors never compete for the same node, matching
392        # mermaid.py's own connector_ids precedent exactly.
393        connector_ids = {
394            tok.id
395            for tok in tokengraph
396            if tok.id in node_ids
397            and tok.id not in implied_ids
398            and tok.relationship1 == "sentence connector"
399        }
400
401    lines = ["digraph tokengraph {", f"    rankdir={orientation};", "    node [shape=box];", ""]
402    for tok in tokengraph:
403        if tok.id not in node_ids:
404            continue
405        color = None
406        strong_border = False
407        if color_by_verbal_unit:
408            if tok.id in implied_ids:
409                color = _IMPLIED_TOKEN_COLOR
410            elif tok.id in connector_ids:
411                color = _SENTENCE_CONNECTOR_COLOR
412                strong_border = True
413            else:
414                unit_id = assignment.get(tok.id)
415                color = colors_by_unit.get(unit_id) if unit_id is not None else None
416        lines.append(f"    {tok.id} [{_node_attrs(tok, color, strong_border)}];")
417
418    lines.append("")
419    for tok in tokengraph:
420        if tok.id not in node_ids:
421            continue
422        for related_field, label_field in (
423            ("relatedtoken1", "relationship1"),
424            ("relatedtoken2", "relationship2"),
425        ):
426            related_id = getattr(tok, related_field)
427            label = getattr(tok, label_field)
428            if related_id is None or label is None:
429                continue
430            if related_id == "root":
431                # An independent verb's own unit-verb relation, per
432                # syntax_model.md -- intentionally not a real node, so not
433                # a warning-worthy gap. Just draw no edge for it.
434                continue
435            if related_id not in node_ids:
436                warnings.append(
437                    f"skipped edge {tok.id} -[{label}]-> {related_id}: "
438                    f"target is punctuation, excluded by the depth cutoff, "
439                    f"or not in tokengraph"
440                )
441                continue
442            lines.append(f'    {tok.id} -> {related_id} [label="{_escape_label(label)}"];')
443
444    if rank_by_depth:
445        sub_depths, depth_warnings = compute_subordination_depths(tokengraph)
446        warnings.extend(depth_warnings)
447
448        # Same grouping tokengraph_to_mermaid() would build for a `~~~`
449        # chain if it had one -- see this module's own docstring, "Two
450        # adaptations" (1), for why sub_depths.get() being None here means
451        # "unresolved" (a cycle, or no governing verbal expression found),
452        # unlike arsgrammatica's own compute_aat_depths(), which has no such
453        # state. Named sub_depths (not `depths`, and this loop's own
454        # variable not `depth`) to avoid shadowing the `depth` PARAMETER
455        # above -- a different depth notion entirely, see this function's
456        # own docstring.
457        depth_groups: dict = {}
458        for tok in tokengraph:
459            if tok.id not in node_ids:
460                continue
461            anchor_depth = sub_depths.get(tok.id)
462            if anchor_depth is None:
463                continue
464            depth_groups.setdefault(anchor_depth, []).append(tok.id)
465
466        rank_lines = [
467            "    {rank=same; " + "; ".join(ids) + ";}"
468            for anchor_depth in sorted(depth_groups)
469            for ids in (depth_groups[anchor_depth],)
470            if len(ids) > 1
471        ]
472        if rank_lines:
473            lines.append("")
474            lines.extend(rank_lines)
475
476    lines.append("}")
477    return "\n".join(lines), warnings

Build a Graphviz DOT digraph from a tokengraph -- the same diagram tokengraph_to_mermaid() draws (same nodes, same edges, same coloring), as DOT source instead of Mermaid source. See this module's own docstring for why this exists alongside tokengraph_to_mermaid(), the two adaptations from arsgrammatica's own dot.py, and what actually rendering the result requires.

orientation maps directly onto DOT's rankdir graph attribute -- BT (bottom-to-top, the default here, matching tokengraph_to_mermaid()'s own default), TB, LR, or RL. Not validated here, same as tokengraph_to_mermaid()'s orientation -- a typo just becomes an attribute value Graphviz itself will reject.

color_by_verbal_unit (default True) colors every node by the verbal unit it belongs to, per verbal_units.assign_verbal_units() -- the exact same colors (and the same >8-verbal-units warning) as tokengraph_to_mermaid(), just written as fillcolor/color/ fontcolor attributes directly on each node line instead of Mermaid's separate classDef/class statements (DOT has no equivalent of a named, reusable class -- inline per-node attributes are the idiomatic way to do this). An implied/elided token (IMPLIED_TOKENTYPES) always gets its own dedicated amber (verbal_units._IMPLIED_TOKEN_COLOR) instead of whatever color its own verbal unit would otherwise get, and a token whose relationship1 is "sentence connector" always gets its own dedicated neon-yellow/strong-border color (_SENTENCE_CONNECTOR_COLOR), both exactly mirroring tokengraph_to_mermaid()'s own precedence (see that function's docstring). Pass False for a plain, uncolored diagram.

rank_by_depth (default True) is the reason this module exists alongside tokengraph_to_mermaid() -- see the module docstring. Every verbal-unit anchor node (any token with verbalunitid set to its own id, implied tokens included) at the same depth in verbal_units.compute_subordination_depths() -- grammatike's own substitute for arsgrammatica's compute_aat_depths(), see "Two adaptations" in this module's own docstring -- gets listed together in one {rank=same; id1; id2; ...} subgraph statement, which forces Graphviz's layout engine to place them on the same rank -- not a nudge, a hard constraint. A depth with only one anchor gets no rank=same statement (nothing to align it WITH); an anchor whose subordination depth came back unresolved (None -- a relation cycle, or no governing verbal expression found; see compute_subordination_depths()'s own docstring) is excluded from every grouping, and its own warning about why is folded into this function's returned warnings. Pass False to skip this and let Graphviz's own layout heuristics place every node.

depth, if given, caps the diagram to nodes at or within that many edges of a root/independent verbal-unit anchor -- compute_graph_depths() above, a plain GRAPH distance along the same relatedtoken1/ relatedtoken2 edges drawn as -> lines below, NOT verbal_units.compute_subordination_depths() (rank_by_depth above, and the CLAUSE-level notion behind tokengraph_to_depth_html()'s own indented-HTML depth slider -- a whole clause's subject, object, and other ordinary dependents share ONE subordination depth with their verb, but each is its own hop of GRAPH depth). depth=0 shows ONLY root anchors -- an independent verb with no dependents at all; depth=1 adds every token one edge away from a root anchor (its subject, object, adverbials, ...); and so on. A token farther than depth is dropped entirely: omitted as a node, exactly as if it had never been in tokengraph. Omit depth (or pass None, the default) to show every node, same as before this parameter existed. A depth at or beyond max_graph_depth()'s own return value for this tokengraph shows everything too; a negative depth raises ValueError.

Dropping a node can leave a KEPT node's edge pointing at a now-excluded one. Such an edge is skipped, with the same combined warning already used for an edge targeting punctuation or a genuinely absent id (see Returns below) -- depth filtering degrades visibly rather than emitting a dangling -> line Graphviz would reject.

Returns (dot_source, warnings) -- same shape and same warnings as tokengraph_to_mermaid(): an edge skipped because it targets a punctuation token, a token excluded by the depth cutoff, or an id not present in tokengraph (except the 'root' sentinel, skipped silently, same as there); if color_by_verbal_unit is True and the passage has more than 8 verbal units, one warning that colors are repeating. depth filtering itself never adds a warning (compute_graph_depths() has no unresolved state -- an unrelated or cyclic token just defaults to depth 0), but rank_by_depth CAN add one -- see above, and the "Two adaptations" section of this module's own docstring for why this differs from arsgrammatica's own version.

def save_dot( tokengraph: List[TokenAnalysis], path: str, orientation: str = 'BT', color_by_verbal_unit: bool = True, rank_by_depth: bool = True, depth: Optional[int] = None) -> List[str]:
480def save_dot(
481    tokengraph: List[TokenAnalysis],
482    path: str,
483    orientation: str = "BT",
484    color_by_verbal_unit: bool = True,
485    rank_by_depth: bool = True,
486    depth: Optional[int] = None,
487) -> List[str]:
488    """Write the diagram to `path` (e.g. 'analysis.dot') and return any
489    warnings from tokengraph_to_dot()."""
490    diagram, warnings = tokengraph_to_dot(
491        tokengraph,
492        orientation=orientation,
493        color_by_verbal_unit=color_by_verbal_unit,
494        rank_by_depth=rank_by_depth,
495        depth=depth,
496    )
497    with open(path, "w", encoding="utf-8") as f:
498        f.write(diagram + "\n")
499    return warnings

Write the diagram to path (e.g. 'analysis.dot') and return any warnings from tokengraph_to_dot().

def compute_graph_depths(tokengraph: List[TokenAnalysis]) -> Dict[str, int]:
178def compute_graph_depths(tokengraph: List[TokenAnalysis]) -> Dict[str, int]:
179    """Each non-punctuation token's *graph depth*: the number of edges
180    separating it from the nearest root/independent verbal-unit anchor,
181    following the exact same relatedtoken1/relatedtoken2 edges
182    tokengraph_to_dot() itself draws as `->` lines (dependent -> governor)
183    -- the depth notion behind tokengraph_to_dot()'s own `depth` parameter.
184    See this module's own docstring for how this differs from
185    verbal_units.compute_subordination_depths() (rendering.
186    tokengraph_to_depth_html()'s clause-level notion, also what
187    `rank_by_depth` uses here -- see "Two adaptations" above).
188
189    A root anchor (relatedtoken1 == 'root') is depth 0. Every other token's
190    depth is one more than its PARENT's -- relatedtoken1, falling back to
191    relatedtoken2 only when relatedtoken1 itself doesn't resolve to a
192    usable parent (None, or an id not in `tokengraph`) -- the SAME
193    "relatedtoken1, fall back to relatedtoken2" preference
194    verbal_units.compute_subordination_depths() already uses to chase a
195    verbal expression's own governor. This matters for a token that plays
196    two roles at once, most notably a relative pronoun: e.g. syntax_model.md's
197    ὅν pointing at its antecedent ἀνήρ via relatedtoken1 ('relative
198    pronoun') AND at the dependent verb it's ALSO the direct object of via
199    relatedtoken2 ('direct object') -- that second edge points forward,
200    toward a token that in turn points back at the pronoun itself (its own
201    'unit verb' relation), a genuine two-way link the data model allows.
202    Taking the shallower of BOTH edges (rather than preferring
203    relatedtoken1) would let that forward edge "cheat" the pronoun's own
204    depth down to whatever the dependent verb's -- itself only computable
205    FROM the pronoun -- happens to resolve to first, collapsing what should
206    be a deeper chain. Only ever falling back to relatedtoken2, never
207    averaging or taking a minimum over both, avoids that: relatedtoken1
208    alone already resolves to the antecedent here, so relatedtoken2 is
209    simply never consulted for depth (it's still drawn as its own edge
210    below, same as always -- this only affects which relation DEPTH
211    follows).
212
213    A token whose relatedtoken1 AND any fallback relatedtoken2 both fail to
214    resolve (neither set, or pointing at ids not in `tokengraph`), or which
215    is caught in a relation cycle even after preferring relatedtoken1,
216    defaults to depth 0 -- the same "can't determine, default to root
217    level" fallback verbal_units.compute_subordination_depths() and
218    rendering.tokengraph_to_depth_html() both use for their own unresolved
219    cases, rather than raising.
220
221    Returns `{token id: depth}`, one entry per non-punctuation token in
222    `tokengraph` (punctuation is never part of the diagram, so never
223    included here either).
224    """
225    by_id = {tok.id: tok for tok in tokengraph}
226    depths: Dict[str, int] = {}
227    in_progress: set = set()
228
229    def depth_of(tok_id: str) -> int:
230        if tok_id in depths:
231            return depths[tok_id]
232        tok = by_id[tok_id]
233        if tok.relatedtoken1 == "root":
234            depths[tok_id] = 0
235            return 0
236
237        if tok_id in in_progress:
238            # A relation cycle -- fall back to 0 rather than recursing
239            # forever; NOT cached, so a non-cyclic call further up the
240            # stack still computes (and caches) this token's real depth if
241            # some other path reaches it.
242            return 0
243        in_progress.add(tok_id)
244
245        parent_id = None
246        if tok.relatedtoken1 is not None and tok.relatedtoken1 != "root" and tok.relatedtoken1 in by_id:
247            parent_id = tok.relatedtoken1
248        elif tok.relatedtoken2 is not None and tok.relatedtoken2 in by_id:
249            parent_id = tok.relatedtoken2
250
251        result = 1 + depth_of(parent_id) if parent_id is not None else 0
252
253        in_progress.discard(tok_id)
254        depths[tok_id] = result
255        return result
256
257    for tok in tokengraph:
258        if tok.tokentype == "punctuation":
259            continue
260        depth_of(tok.id)
261
262    return depths

Each non-punctuation token's graph depth: the number of edges separating it from the nearest root/independent verbal-unit anchor, following the exact same relatedtoken1/relatedtoken2 edges tokengraph_to_dot() itself draws as -> lines (dependent -> governor) -- the depth notion behind tokengraph_to_dot()'s own depth parameter. See this module's own docstring for how this differs from verbal_units.compute_subordination_depths() (rendering. tokengraph_to_depth_html()'s clause-level notion, also what rank_by_depth uses here -- see "Two adaptations" above).

A root anchor (relatedtoken1 == 'root') is depth 0. Every other token's depth is one more than its PARENT's -- relatedtoken1, falling back to relatedtoken2 only when relatedtoken1 itself doesn't resolve to a usable parent (None, or an id not in tokengraph) -- the SAME "relatedtoken1, fall back to relatedtoken2" preference verbal_units.compute_subordination_depths() already uses to chase a verbal expression's own governor. This matters for a token that plays two roles at once, most notably a relative pronoun: e.g. syntax_model.md's ὅν pointing at its antecedent ἀνήρ via relatedtoken1 ('relative pronoun') AND at the dependent verb it's ALSO the direct object of via relatedtoken2 ('direct object') -- that second edge points forward, toward a token that in turn points back at the pronoun itself (its own 'unit verb' relation), a genuine two-way link the data model allows. Taking the shallower of BOTH edges (rather than preferring relatedtoken1) would let that forward edge "cheat" the pronoun's own depth down to whatever the dependent verb's -- itself only computable FROM the pronoun -- happens to resolve to first, collapsing what should be a deeper chain. Only ever falling back to relatedtoken2, never averaging or taking a minimum over both, avoids that: relatedtoken1 alone already resolves to the antecedent here, so relatedtoken2 is simply never consulted for depth (it's still drawn as its own edge below, same as always -- this only affects which relation DEPTH follows).

A token whose relatedtoken1 AND any fallback relatedtoken2 both fail to resolve (neither set, or pointing at ids not in tokengraph), or which is caught in a relation cycle even after preferring relatedtoken1, defaults to depth 0 -- the same "can't determine, default to root level" fallback verbal_units.compute_subordination_depths() and rendering.tokengraph_to_depth_html() both use for their own unresolved cases, rather than raising.

Returns {token id: depth}, one entry per non-punctuation token in tokengraph (punctuation is never part of the diagram, so never included here either).

def max_graph_depth(tokengraph: List[TokenAnalysis]) -> Optional[int]:
265def max_graph_depth(tokengraph: List[TokenAnalysis]) -> Optional[int]:
266    """The highest value compute_graph_depths() assigns to any token in
267    `tokengraph` -- the upper end of the meaningful range for
268    tokengraph_to_dot()'s own `depth` parameter, the same role
269    verbal_units.max_subordination_depth() plays for
270    tokengraph_to_depth_html()'s unrelated depth notion. Returns None for
271    an empty tokengraph, or one with only punctuation."""
272    depths = compute_graph_depths(tokengraph)
273    return max(depths.values()) if depths else None

The highest value compute_graph_depths() assigns to any token in tokengraph -- the upper end of the meaningful range for tokengraph_to_dot()'s own depth parameter, the same role verbal_units.max_subordination_depth() plays for tokengraph_to_depth_html()'s unrelated depth notion. Returns None for an empty tokengraph, or one with only punctuation.

def tokengraph_to_text(tokengraph: List[TokenAnalysis]) -> str:
179def tokengraph_to_text(tokengraph: List[TokenAnalysis]) -> str:
180    """Join `tokengraph`'s tokens into one continuous plain-text string,
181    per this module's docstring. Tokens are read in list order (the same
182    order tokengraph_to_mermaid() and validate() assume)."""
183    quote_counts: Dict[str, int] = {}
184    pieces: List[str] = []
185    previous_class = None
186
187    for tok in tokengraph:
188        if tok.tokentype in IMPLIED_TOKENTYPES:
189            # An implied/elided token (models.py's IMPLIED_TOKENTYPES) has
190            # no surface realization at all -- skip it entirely, exactly as
191            # if it weren't in the list, rather than trying to render
192            # `None`. previous_class is deliberately left untouched, so the
193            # next real token's spacing is decided as if this one weren't
194            # here.
195            continue
196        cls = _classify(tok, quote_counts)
197        text = tok.token
198
199        if not pieces:
200            # Nothing precedes the first token -- never prepend a space,
201            # regardless of this token's own classification.
202            pieces.append(text)
203        elif cls in (_LEFT, _ENCLITIC):
204            pieces.append(text)
205        elif cls == _RIGHT:
206            pieces.append(" " + text)
207        else:  # _NORMAL
208            if previous_class == _RIGHT:
209                pieces.append(text)
210            else:
211                pieces.append(" " + text)
212
213        previous_class = cls
214
215    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]) -> str:
218def tokengraph_to_html(tokengraph: List[TokenAnalysis]) -> str:
219    """Render `tokengraph` as an HTML string: the same continuous text
220    `tokengraph_to_text()` produces -- identical spacing rules, and the same
221    punctuation/enclitic/quote-pair handling -- except every **lexical**
222    token, every **numeral** token, and every **connecting word** (any
223    token with relationship1 or relationship2 in `_CONNECTING_RELATIONS`
224    -- "connecting word" or "sentence connector" -- lexical or not), has
225    its text wrapped in a `<span style="...">` colored by the verbal unit
226    it belongs to. Colors come from `verbal_units.assign_verbal_units()` /
227    `assign_verbal_unit_colors()` -- the same assignment and the same
228    first-appearance palette ordering `tokengraph_to_mermaid()` uses for its
229    node coloring -- so a passage rendered here and the same passage's
230    Mermaid diagram color each verbal unit identically.
231
232    The connecting-word carve-out exists because a connecting particle like
233    "μέν", "δέ", or "γάρ" might be tokenized as tokentype "enclitic" rather
234    than "lexical" -- see `_CONNECTING_RELATIONS`'s own comment for the
235    open question there -- but `assign_verbal_units()` still resolves it to
236    one of the verbal units it coordinates or connects (see that module's
237    docstring): e.g. in "ἐγὼ μὲν ἄνω διῃτώμην, αἱ δὲ γυναῖκες κάτω", μέν and
238    δέ each resolve to the verbal unit of the clause they introduce, same
239    as ἐγὼ and γυναῖκες do. Leaving such a token unwrapped would visually
240    hide that assignment even though it's a real one, unlike the other
241    non-lexical, non-numeral tokentypes below. A subordinating conjunction
242    (e.g. "ἐπειδή", "ὡς") or a relative pronoun (e.g. "ὅν") doesn't need
243    this carve-out: both are always tokentype "lexical" (full words, never
244    enclitic), so they're already wrapped.
245
246    The numeral carve-out is for a related reason: syntax_model.md's
247    tokenization section restricts `tokentype`="numeral" to a number
248    written NUMERICALLY (e.g. in Milesian notation) -- a number spelled out
249    as an ordinary word (e.g. "δύω") is "lexical" instead -- but a numeral
250    is otherwise an ordinary participant in the clause, able to carry
251    whatever ordinary noun/case relation the RelationLabel set documents
252    (e.g. "attributive", "accusative"), the same relation a spelled-out
253    number would use in the same position. `assign_verbal_units()` resolves
254    that relation exactly like any other, so a numeral belonging to a
255    verbal unit is wrapped the same way a lexical token would be -- unlike
256    punctuation or a non-connecting enclitic, neither of which carries that
257    kind of ordinary syntactic relation under the current scheme.
258
259    Every other non-lexical, non-numeral token -- punctuation and a
260    non-connecting enclitic (e.g. the intensifying "γε" in "ἵνα σύ γε" ἔφη
261    "πειρᾷς...") -- is still emitted as plain (escaped) text even though
262    `assign_verbal_units()` assigns every token, including punctuation, to
263    whichever unit its relations resolve to; this function just doesn't
264    turn that assignment into a span for anything else. A lexical, numeral,
265    or connecting-word token belonging to no verbal unit (assignment is
266    `None`, e.g. a bare vocative left otherwise unrelated) is left
267    unwrapped too, as is one whose unit happens to have no non-punctuation
268    member at all and so never got a color slot from
269    `assign_verbal_unit_colors()` (should not occur in practice for a
270    lexical or numeral token, since it's always a non-punctuation member of
271    its own unit, but handled defensively rather than assumed).
272
273    A token whose relationship1 is specifically "sentence connector" (e.g.
274    γάρ tying this sentence back to the previous one) is a special case of
275    the connecting-word carve-out above: instead of the ordinary verbal-unit
276    color, it always gets `_SENTENCE_CONNECTOR_STYLE`'s neon-yellow
277    background and strong black border, regardless of which unit it's
278    assigned to -- the same override tokengraph_to_mermaid() applies to its
279    own `sentenceconnector` node class.
280
281    An **implied/elided token** (models.py's IMPLIED_TOKENTYPES: "implied
282    eimi", "implied repetition") is omitted entirely -- same as
283    tokengraph_to_text() -- rather than rendered with any span: it has no
284    surface text (`tok.token` is always `None`), and unlike
285    `tokengraph_to_mermaid()`'s diagram (which DOES show these, as their
286    own specially-colored, specially-labeled node -- see that module's own
287    docstring), inserting placeholder text into the middle of reconstructed
288    prose here would misrepresent what the passage actually says. The
289    Mermaid diagram is the one place an implied token's presence is worth
290    seeing at all.
291
292    Every token's text is HTML-escaped (`&`, `<`, `>`, and quote characters)
293    before being emitted, spans or not -- real Greek text can contain a
294    literal `"` or `'` (see the quote-pair handling below, and this
295    module's TODO on elision apostrophes), which would otherwise be
296    indistinguishable from markup to anything that re-parses this output.
297
298    The span's inline style sets both `background-color` (the verbal unit's
299    palette `fill`, the same value used as a Mermaid node's `fill`) and
300    `color` (the palette's `text` value, currently black for every slot) --
301    the latter so the token reads correctly regardless of whatever text
302    color the surrounding page has set, matching the explicit black
303    `color:` every Mermaid node in that unit also gets.
304    """
305    assignment = assign_verbal_units(tokengraph)
306    colors, _warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment)
307    return _tokens_to_html(tokengraph, assignment, colors)

Render tokengraph as an HTML string: the same continuous text tokengraph_to_text() produces -- identical spacing rules, and the same punctuation/enclitic/quote-pair handling -- except every lexical token, every numeral token, and every connecting word (any token with relationship1 or relationship2 in _CONNECTING_RELATIONS -- "connecting word" or "sentence connector" -- lexical or not), 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 connecting-word carve-out exists because a connecting particle like "μέν", "δέ", or "γάρ" might be tokenized as tokentype "enclitic" rather than "lexical" -- see _CONNECTING_RELATIONS's own comment for the open question there -- but assign_verbal_units() still resolves it to one of the verbal units it coordinates or connects (see that module's docstring): e.g. in "ἐγὼ μὲν ἄνω διῃτώμην, αἱ δὲ γυναῖκες κάτω", μέν and δέ each resolve to the verbal unit of the clause they introduce, same as ἐγὼ and γυναῖκες do. Leaving such a token unwrapped would visually hide that assignment even though it's a real one, unlike the other non-lexical, non-numeral tokentypes below. A subordinating conjunction (e.g. "ἐπειδή", "ὡς") or a relative pronoun (e.g. "ὅν") doesn't need this carve-out: both are always tokentype "lexical" (full words, never enclitic), so they're already wrapped.

The numeral carve-out is for a related reason: syntax_model.md's tokenization section restricts tokentype="numeral" to a number written NUMERICALLY (e.g. in Milesian notation) -- a number spelled out as an ordinary word (e.g. "δύω") is "lexical" instead -- but a numeral is otherwise an ordinary participant in the clause, able to carry whatever ordinary noun/case relation the RelationLabel set documents (e.g. "attributive", "accusative"), the same relation a spelled-out number would use in the same position. assign_verbal_units() resolves that relation exactly like any other, so a numeral belonging to a verbal unit is wrapped the same way a lexical token would be -- unlike punctuation or a non-connecting enclitic, neither of which carries that kind of ordinary syntactic relation under the current scheme.

Every other non-lexical, non-numeral token -- punctuation and a non-connecting enclitic (e.g. the intensifying "γε" in "ἵνα σύ γε" ἔφη "πειρᾷς...") -- is still emitted as plain (escaped) text even though assign_verbal_units() assigns every token, including punctuation, to whichever unit its relations resolve to; this function just doesn't turn that assignment into a span for anything else. A lexical, numeral, or connecting-word token belonging to no verbal unit (assignment is None, e.g. a bare vocative left otherwise unrelated) is left unwrapped too, as is one whose unit happens to have no non-punctuation member at all and so never got a color slot from assign_verbal_unit_colors() (should not occur in practice for a lexical or numeral token, since it's always a non-punctuation member of its own unit, but handled defensively rather than assumed).

A token whose relationship1 is specifically "sentence connector" (e.g. γάρ tying this sentence back to the previous one) is a special case of the connecting-word carve-out above: instead of the ordinary verbal-unit color, it always gets _SENTENCE_CONNECTOR_STYLE's neon-yellow background and strong black border, regardless of which unit it's assigned to -- the same override tokengraph_to_mermaid() applies to its own sentenceconnector node class.

An implied/elided token (models.py's IMPLIED_TOKENTYPES: "implied eimi", "implied repetition") is omitted entirely -- same as tokengraph_to_text() -- rather than rendered with any span: it has no surface text (tok.token is always None), and unlike tokengraph_to_mermaid()'s diagram (which DOES show these, as their own specially-colored, specially-labeled node -- see that module's own docstring), inserting placeholder text into the middle of reconstructed prose here would misrepresent what the passage actually says. The Mermaid diagram is the one place an implied token's presence is worth seeing at all.

Every token's text is HTML-escaped (&, <, >, and quote characters) before being emitted, spans or not -- real Greek text can contain a literal " or ' (see the quote-pair handling below, and this module's TODO on elision apostrophes), which would otherwise be indistinguishable from markup to anything that re-parses this output.

The span's inline style sets both background-color (the verbal unit's palette fill, the same value used as a Mermaid node's fill) and color (the palette's text value, currently black for every slot) -- the latter so the token reads correctly regardless of whatever text color the surrounding page has set, matching the explicit black color: every Mermaid node in that unit also gets.

def tokengraph_to_depth_html( tokengraph: List[TokenAnalysis], indent_em: float = 2.0, depth: Optional[int] = None) -> Tuple[str, List[str]]:
395def tokengraph_to_depth_html(
396    tokengraph: List[TokenAnalysis],
397    indent_em: float = _DEFAULT_DEPTH_INDENT_EM,
398    depth: Optional[int] = None,
399) -> Tuple[str, List[str]]:
400    """Render `tokengraph` as HTML illustrating each verbal expression's
401    *depth of subordination* (see verbal_units.compute_subordination_
402    depths()): tokens are assembled sequentially exactly as
403    tokengraph_to_html() does -- same spacing, escaping, and verbal-unit
404    color highlighting -- but grouped into consecutive-run "blocks" by
405    which verbal unit each token belongs to (per assign_verbal_units()),
406    each rendered as its own <div> indented by a CSS margin-left of
407    `depth * indent_em` em -- 0 for an independent clause, 1 for a clause
408    it introduces (a dependent clause, a direct quote, an aside, a
409    circumstantial participle/genitive absolute, or an indirect statement
410    anchored to an infinitive or participle), 2 for a verbal expression
411    THAT one in turn introduces, and so on. All layout is CSS (margin-left/
412    margin-bottom on each block's <div>) -- no table or nested-list
413    structure is used to produce the indentation.
414
415    `depth`, if given, caps how deep the rendering goes: ONLY blocks whose
416    own depth of subordination is <= `depth` are included in the output --
417    a block deeper than that is dropped entirely, not rendered empty or
418    grayed out. `depth=0` shows root/independent clauses only (and direct
419    quotes, asides, and any other depth-0 construction); omit `depth` (or
420    pass `None`, the default) to show every block, same as before this
421    parameter existed. Valid values run from 0 up to
422    verbal_units.max_subordination_depth()'s own return value for this
423    `tokengraph` (that function exists specifically to help a caller pick
424    a sensible value here); a negative `depth` raises ValueError, since
425    there's no clause shallower than root. A `depth` larger than the
426    passage's actual maximum is accepted, not an error -- it just means
427    "show everything," identical to leaving `depth` unset.
428
429    Block boundaries follow assign_verbal_units()'s token-to-unit
430    assignment, with one adjustment: an **enclitic** token never starts a
431    new block, even when its own assignment differs from the block
432    currently open (see tests/test_rendering.py's connecting-word
433    word-order-mismatch case for exactly this -- an enclitic connecting
434    word can resolve to a DIFFERENT verbal unit than the word it's
435    orthographically glued to, e.g. an enclitic clitic pair, and starting a
436    new block there would split one Greek word across two <div>s). A token
437    with no verbal-unit assignment at all (None -- typically punctuation,
438    or a token syntax_model.md doesn't document a relation for) likewise
439    never starts a new block; it folds into whichever block is currently
440    open, so a stray comma or postpositive particle doesn't fragment the
441    layout. Leading tokens before the first resolvable verbal-unit token
442    (rare) default to depth 0.
443
444    Note that a circumstantial-participle/genitive-absolute noun (e.g.
445    "χρόνου" in "προϊόντος δὲ τοῦ χρόνου ἧκον...", or "ἐγώ" in "ἐγὼ ἅπαντα
446    ἐπιδείξω..., οὐδὲν παραλείπων") resolves, per assign_verbal_units()'s
447    own established convention, to whatever unit ITS OWN relation reaches
448    -- typically the outer clause -- while the participle itself is its
449    own singleton unit; this means a circumstantial-participle phrase
450    renders as the noun staying in the outer clause's block and the bare
451    participle as its own one-word indented block, rather than the whole
452    phrase indenting together. That follows directly from the noun's own
453    documented relation (it fits into the surrounding clause as subject,
454    object, etc., or points at the main verb as a genitive absolute) and
455    isn't specific to this function.
456
457    A verbal expression whose depth couldn't be resolved (see
458    compute_subordination_depths()) renders at depth 0 rather than
459    raising, with a warning explaining why -- the same "degrade visibly,
460    don't crash" convention tokengraph_to_mermaid() uses.
461
462    Returns (html, warnings), combining assign_verbal_unit_colors()'s
463    warnings (colors repeating past 8 verbal units) and
464    compute_subordination_depths()'s (an unresolved governing verbal
465    expression) -- computed the same way, and returned in full, regardless
466    of whether `depth` filters some blocks out of the rendered `html`
467    itself.
468    """
469    if depth is not None and depth < 0:
470        raise ValueError(f"depth must be >= 0 (root clauses only), got {depth!r}")
471
472    assignment = assign_verbal_units(tokengraph)
473    colors, color_warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment)
474    depths, depth_warnings = compute_subordination_depths(tokengraph)
475    warnings = color_warnings + depth_warnings
476
477    blocks = []
478    for tok in tokengraph:
479        unit_id = assignment.get(tok.id)
480        starts_new_block = (
481            unit_id is not None
482            and tok.tokentype != "enclitic"
483            and (not blocks or blocks[-1][0] != unit_id)
484        )
485        if starts_new_block:
486            blocks.append((unit_id, []))
487        elif not blocks:
488            # Leading token(s) with no verbal-unit assignment yet (or a
489            # leading enclitic, in principle) -- open a placeholder block
490            # rather than crashing on an empty blocks list below.
491            blocks.append((None, []))
492        blocks[-1][1].append(tok)
493
494    lines = []
495    for unit_id, block_tokens in blocks:
496        block_depth = depths.get(unit_id) if unit_id is not None else 0
497        if block_depth is None:
498            block_depth = 0
499        if depth is not None and block_depth > depth:
500            # This whole block is deeper than the requested cutoff --
501            # drop it entirely rather than rendering an empty/grayed-out
502            # placeholder for it.
503            continue
504        block_html = _tokens_to_html(block_tokens, assignment, colors)
505        margin_left = block_depth * indent_em
506        lines.append(
507            f'<div style="margin-left: {margin_left}em; margin-bottom: 0.35em;">'
508            f"{block_html}</div>"
509        )
510
511    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 clause it introduces (a dependent clause, a direct quote, an aside, a circumstantial participle/genitive absolute, or an indirect statement anchored to an infinitive or participle), 2 for a verbal expression THAT one in turn introduces, and so on. All layout is CSS (margin-left/ margin-bottom on each block's
) -- 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 -- a block deeper than that is dropped entirely, not rendered empty or grayed out. depth=0 shows root/independent clauses only (and direct quotes, asides, and any other depth-0 construction); omit depth (or pass None, the default) to show every block, same as before this parameter existed. Valid values run from 0 up to verbal_units.max_subordination_depth()'s own return value for this tokengraph (that function exists specifically to help a caller pick a sensible value here); a negative depth raises ValueError, since there's no clause shallower than root. A depth larger than the passage's actual maximum is accepted, not an error -- it just means "show everything," identical to leaving depth unset.

Block boundaries follow assign_verbal_units()'s token-to-unit assignment, with one adjustment: an enclitic token never starts a new block, even when its own assignment differs from the block currently open (see tests/test_rendering.py's connecting-word word-order-mismatch case for exactly this -- an enclitic connecting word can resolve to a DIFFERENT verbal unit than the word it's orthographically glued to, e.g. an enclitic clitic pair, and starting a new block there would split one Greek word across two

s). A token with no verbal-unit assignment at all (None -- typically punctuation, or a token syntax_model.md doesn't document a relation for) likewise never starts a new block; it folds into whichever block is currently open, so a stray comma or postpositive particle doesn't fragment the layout. Leading tokens before the first resolvable verbal-unit token (rare) default to depth 0.

Note that a circumstantial-participle/genitive-absolute noun (e.g. "χρόνου" in "προϊόντος δὲ τοῦ χρόνου ἧκον...", or "ἐγώ" in "ἐγὼ ἅπαντα ἐπιδείξω..., οὐδὲν παραλείπων") resolves, per assign_verbal_units()'s own established convention, to whatever unit ITS OWN relation reaches -- typically the outer clause -- while the participle itself is its own singleton unit; this means a circumstantial-participle phrase renders as the noun staying in the outer clause's block and the bare participle as its own one-word indented block, rather than the whole phrase indenting together. That follows directly from the noun's own documented relation (it fits into the surrounding clause as subject, object, etc., or points at the main verb as a genitive absolute) and isn't specific to this function.

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 -- the same "degrade visibly, don't crash" convention tokengraph_to_mermaid() uses.

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):
 98class SyntaxAnalysis(dspy.Signature):
 99    """Analyze the syntax of a passage of Ancient Greek according to a
100    two-part scheme:
101
102    (1) a list of verbal expressions. Three constructions count as a
103        verbal expression: finite verbs, infinitives, and participles --
104        but for participles, only some of them (see below).
105
106        - A finite verb (including a compound perfect-system form made of
107          a participle plus a conjugated form of εἰμί, e.g. ὁ νόμος
108          γεγραμμένος ἐστίν) is always a verbal expression. Classify its
109          syntactic type as 'independent' (main/principal), 'dependent'
110          (subordinate, introduced by a subordinating word), 'direct
111          quote' (occurring in directly quoted speech framed by another
112          verb, e.g. νόμιζε in '"εὐφίλητε" ἔφη "μηδεμιᾷ πολυπραγμοσύνῃ
113          προσεληλυθέναι με νόμιζε πρὸς σέ."'), or 'aside' (a verbal
114          expression that interrupts the surrounding syntax, e.g. δεῖ in
115          'πρῶτον μὲν οὖν, ὦ ἄνδρες, (δεῖ γὰρ καὶ ταῦθ᾽ ὑμῖν διηγήσασθαι)
116          οἰκίδιον ἔστι μοι διπλοῦν' interrupting the independent verbal
117          expression ἔστι). Example of independent vs. dependent: in
118          'ἐπειδὴ δὲ ἦν πρὸς ἡμέραν, ἧκεν ἐκείνη', ἧκεν is 'independent'
119          and ἦν is 'dependent' (introduced by the subordinating
120          conjunction ἐπειδή).
121        - An infinitive is a verbal expression only when part of an
122          indirect statement; its syntactic type is always 'indirect
123          statement'. Example: in 'ἔφασκε τὸν λύχνον ἀποσβεσθῆναι', ἔφασκε
124          is independent and ἀποσβεσθῆναι anchors the indirect-statement
125          verbal expression. In a compound perfect-system form (participle
126          + a conjugated form of εἰμί), the form of εἰμί anchors the
127          verbal expression, same as any other compound form.
128        - A participle constitutes a verbal expression in THREE cases,
129          each with its own dedicated syntactic_type -- unlike Latin,
130          which uses a single 'dependent' value for every predicate-sense
131          participle, Greek's scheme gives each its own name:
132            - 'indirect statement': a participle expressing indirect
133              speech after a verb of perception or thinking. Example: in
134              'εἶδε δὲ τὴν βασίλειαν φεύγουσαν', εἶδε is independent and
135              φεύγουσαν (not an infinitive here) anchors the
136              indirect-statement verbal expression.
137            - 'attributive': a participle in attributive position.
138              Example: in 'ὁ γὰρ ἀνὴρ ὁ ὑβρίζων εἰς σὲ ἐχθρὸς ὢν ἡμῖν
139              τυγχάνει', the repeated article puts ὑβρίζων in attributive
140              position with ἀνήρ, so ὑβρίζων anchors an 'attributive'
141              verbal expression. In Greek, UNLIKE Latin, every attributive
142              participle counts as its own verbal expression -- there is
143              no purely-adjectival, non-verbal-expression reading for an
144              attributive participle the way Latin's "consentiens laus"
145              was not a verbal expression at all.
146            - 'circumstantial': a participle in circumstantial position
147              (including one forming a genitive absolute). Example: in
148              'χρόνου μεταξὺ διαγενομένου, προσέρχεταί μοί τις πρεσβῦτις
149              ἄνθρωπος', προσέρχεταί is independent and διαγενομένου
150              anchors a 'circumstantial' verbal expression.
151          By contrast, a *supplementary* participle -- one that completes
152          the sense of its governing verb as a single predicate idea
153          (e.g. with τυγχάνω, λανθάνω, φαίνομαι, παύομαι, or the like),
154          rather than standing attributively with a noun or
155          circumstantially/adverbially to the clause -- is explicitly NOT
156          a verbal expression and gets no `verbalunits` entry at all.
157          Example: in that same sentence 'ὁ γὰρ ἀνὴρ ὁ ὑβρίζων εἰς σὲ
158          ἐχθρὸς ὢν ἡμῖν τυγχάνει', ὤν supplements τυγχάνει (there is a
159          single independent verbal expression, anchored to τυγχάνει) and
160          is NOT its own verbal expression. Do not over-generate
161          verbal-expression entries for participles: check first whether a
162          participle is genuinely attributive (repeated article, or
163          agreeing with a noun as its ordinary modifier), genuinely
164          circumstantial (an adverbial predication about a noun, loosely
165          attached to the clause), or genuinely reporting indirect
166          perception -- and only then give it a `verbalunits` entry; a
167          participle that instead completes one predicate idea together
168          with a governing verb like τυγχάνω does not.
169          # TODO: syntax_model.md does not name a RelationLabel for a
170          # supplementary participle's own relation to its governing verb
171          # (no "supplementary" or "complementary participle" value
172          # exists). This port leaves such a participle's own
173          # relatedtoken1/relationship1 unset -- no documented label
174          # fits -- while still letting it take its own predicate/
175          # object/adverbial complements exactly as a linking verb would
176          # (e.g. ἐχθρός, the predicate adjective of ὤν in the example
177          # above, still relates to ὤν with relationship1 'predicate',
178          # exactly as it would to a finite linking verb).
179
180        Classify each verbal expression's semantic type too (transitive
181        active / transitive passive / intransitive / linking verb).
182        Examples: προσεῖχον in 'προσεῖχον τὸν νοῦν' is transitive active;
183        διαφθείρεται in 'ἡ ἐμὴ γυνὴ ὑπὸ τούτου τοῦ ἀνθρώπου διαφθείρεται'
184        is transitive passive; εἰσῄει in 'πάντα μου εἰς τὴν γνώμην
185        εἰσῄει' is intransitive; ἦ in 'μεστὸς ἦ ὑποψίας' is a linking
186        verb.
187
188    (2) a token-by-token dependency graph. For each token, record up to two
189        relations to other tokens (by id), using only these relation
190        labels:
191
192        - unit verb (independent): every INDEPENDENT verb has relatedtoken1
193          = the special sentinel string 'root' -- never an actual token id;
194          no real token may be assigned the id 'root' -- and relationship1
195          = 'unit verb'. Example: in 'τὴν θύραν ἀνέῳξεν', ἀνέῳξεν has
196          relatedtoken1 'root', relationship1 'unit verb'.
197        - unit verb (dependent) / subordinating conjunction / relative
198          pronoun: the verb of a DEPENDENT clause has relatedtoken1 -> the
199          id of its subordinating conjunction or relative/interrogative
200          pronoun, relationship1 = 'unit verb'. That conjunction or pronoun
201          in turn has relatedtoken1 -> the id of the verb of the clause it
202          is subordinate to, with relationship1 = 'subordinating
203          conjunction' for a conjunction, or relatedtoken1 -> its
204          antecedent's id with relationship1 = 'relative pronoun' for a
205          relative pronoun. Example: in 'ἐπειδὴ δὲ ἦν πρὸς ἡμέραν, ἧκεν
206          ἐκείνη', ἐπειδή has relatedtoken1 -> ἧκεν, relationship1
207          'subordinating conjunction', and ἦν has relatedtoken1 ->
208          ἐπειδή, relationship1 'unit verb'. Another example, with a
209          conjunction: in 'κατηγόρει ὡς μετὰ τὴν ἐκφορὰν αὐτῇ προσίοι',
210          ὡς has relatedtoken1 -> κατηγόρει, relationship1 'subordinating
211          conjunction', and προσίοι has relatedtoken1 -> ὡς, relationship1
212          'unit verb'. Indirect questions are treated as a kind of
213          dependent clause: an interrogative pronoun introducing one is
214          treated the same way as a subordinating conjunction -- it has
215          relatedtoken1 -> the id of the verb it introduces, relationship1
216          = 'subordinating conjunction' (no separate label for this case)
217          -- while the dependent verb itself has relatedtoken1 -> the
218          interrogative word's id, relationship1 = 'unit verb', exactly
219          like any other dependent clause.
220          # TODO: syntax_model.md states this indirect-question rule only
221          # in passing ("a subordinating conjunction or a relative or
222          # interrogative pronoun") with no worked example; the following
223          # is constructed by analogy, not quoted from syntax_model.md:
224          # in 'οὐκ οἶδα τίς ἦλθεν' ("I don't know who came"), τίς has
225          # relatedtoken1 -> οἶδα, relationship1 'subordinating
226          # conjunction', and ἦλθεν has relatedtoken1 -> τίς, relationship1
227          # 'unit verb'.
228        - relative pronoun (second relation): a relative pronoun ALSO
229          relates to its own function inside the relative clause, using
230          relatedtoken2/relationship2 (since relatedtoken1/relationship1 is
231          already used for the antecedent link) -- the ordinary relation
232          it would have if it were any other noun/pronoun in that clause
233          (e.g. 'direct object', 'subject', a case relation, etc). Example:
234          in 'οὐκ ἐγώ σε ἀποκτενῶ, ἀλλ᾽ ὁ τῆς πόλεως νόμος, ὃν σὺ περὶ
235          ἐλάττονος τῶν ἡδονῶν ἐποιήσω', ὅν has relatedtoken1 -> νόμος
236          (its antecedent), relationship1 'relative pronoun', AND
237          relatedtoken2 -> ἐποιήσω, relationship2 'direct object'.
238        - indirect statement (governing verb): an infinitive OR participle
239          anchoring an indirect-statement verbal expression ALSO has
240          relatedtoken1 -> the id of the verb that governs the indirect
241          statement (the verb of saying/thinking/perceiving it depends
242          on), relationship1 = 'indirect statement' -- matching its own
243          syntactic type, the same convention 'direct quote' and 'aside'
244          verbal expressions use below. There's no separate
245          subordinating-word token to point at first, so the infinitive or
246          participle points directly at its governing verb, rather than
247          via a conjunction/pronoun intermediary the way a dependent
248          finite verb's 'unit verb' relation does. Examples: in
249          'ἔφασκε τὸν λύχνον ἀποσβεσθῆναι', ἀποσβεσθῆναι has relatedtoken1
250          -> ἔφασκε, relationship1 'indirect statement'; in 'εἶδε δὲ τὴν
251          βασίλειαν φεύγουσαν', φεύγουσαν has relatedtoken1 -> εἶδε,
252          relationship1 'indirect statement'. In a compound perfect-system
253          form, this relation belongs on the conjugated form of εἰμί that
254          anchors the verbal expression, same as any other relation into
255          it.
256        - complementary infinitive: an infinitive that completes the sense
257          of a governing verb like βούλομαι, δεῖ, or ἐθέλω (rather than
258          reporting indirect speech) has relatedtoken1 -> the id of that
259          governing verb, relationship1 = 'complementary infinitive'.
260          Unlike an indirect-statement infinitive, this does NOT make the
261          infinitive its own verbal expression -- it gets no `verbalunits`
262          entry of its own; the governing verb is still the only verbal
263          expression here. Example: in 'ἔξεστι ἑλέσθαι', ἑλέσθαι has
264          relatedtoken1 -> ἔξεστι, relationship1 'complementary
265          infinitive'.
266        - modal particle: the particle ἄν has relatedtoken1 -> the id of
267          the verb of ITS OWN verbal unit (not some other unit's verb),
268          relationship1 = 'modal particle'. Example: in 'εἰ τὴν αὐτὴν
269          γνώμην περὶ τῶν ἄλλων ἔχοιτε, οὐκ ἂν εἴη, ὅστις οὐκ ἐπὶ τοῖς
270          γεγενημένοις ἀγανακτοίη' (two dependent verbal expressions plus
271          one independent verbal expression anchored to εἴη), ἂν has
272          relatedtoken1 -> εἴη, relationship1 'modal particle' -- εἴη is
273          ἂν's own verbal unit's verb, the same verb οὐκ (adverbial) also
274          relates to.
275        - infinitive used as a noun: an infinitive can also function as an
276          ordinary noun -- most often a verb's subject or object -- rather
277          than anchoring an indirect statement or completing another verb.
278          Treat it exactly like any other noun in that role: relatedtoken1
279          -> the verb it's the subject/object of, relationship1 =
280          'subject' or 'direct object' as appropriate (no dedicated label,
281          and again no `verbalunits` entry of its own). If the infinitive
282          carries a definite article (an articular infinitive, e.g. τὸ
283          ζῆν), that article relates to the infinitive exactly as it would
284          to a substantivized adjective or adverb: relatedtoken1 -> the
285          infinitive's id, relationship1 'article'. Like any verbal form,
286          an infinitive used this way can still take its own object or
287          adverb, related to it the same way they'd relate to a finite
288          verb.
289          # TODO: syntax_model.md does not discuss this construction at
290          # all; it is carried over from arsgrammatica's equivalent
291          # section by direct analogy, since substantival infinitives
292          # (often articular) are common in Greek too. No example here is
293          # quoted from syntax_model.md.
294        - sentence connector: true asyndeton is rare at the root level of
295          a sentence -- there is normally a connecting word expressing the
296          relation of the sentence to its predecessor. This connecting
297          word has relatedtoken1 -> the verb of THIS sentence (not the
298          previous one), relationship1 = 'sentence connector'. Example: in
299          'ταύτην γὰρ ἐμαυτῷ μόνην ἡγοῦμαι σωτηρίαν', γάρ has relatedtoken1
300          -> ἡγοῦμαι, relationship1 'sentence connector'. The particle μέν
301          begins a list of items, continued by δέ -- ordinarily WITHIN one
302          sentence (see 'connecting word' below), but when the items are
303          instead split across distinct, separately terminated sentences,
304          μέν or δέ is a 'sentence connector' too, exactly like γάρ, rather
305          than a 'connecting word': it has relatedtoken1 -> the verb of
306          ITS OWN sentence, relationship1 'sentence connector', with no
307          relation at all to the other sentence's verb (a sentence
308          connector never records a cross-sentence link -- only "this
309          sentence's own verb"). Examples: in the complete, terminated
310          sentence 'περὶ μὲν οὖν τοῦ μεγέθους τῆς ζημίας ἅπαντας ὑμᾶς
311          νομίζω τὴν αὐτὴν διάνοιαν ἔχειν, καὶ οὐδένα οὕτως ὀλιγώρως
312          διακεῖσθαι, ὅστις οἴεται δεῖν συγγνώμης τυγχάνειν ἢ μικρᾶς
313          ζημίας ἀξίους ἡγεῖται τοὺς τῶν τοιούτων ἔργων αἰτίους.', μέν has
314          relatedtoken1 -> νομίζω, relationship1 'sentence connector'; in
315          the following, separate sentence 'ἡγοῦμαι δέ, ὦ ἄνδρες, τοῦτό με
316          δεῖν ἐπιδεῖξαι.', δέ has relatedtoken1 -> ἡγοῦμαι, relationship1
317          'sentence connector'.
318        - connecting word: when a connecting word (coordinating conjunction
319          or connecting particle, e.g. καί, ἀλλά, τε, μέν, δέ, οὔτε) joins
320          a pair or series of nouns, adjectives, adverbs, or whole clauses
321          WITHIN a sentence (as opposed to linking one sentence to the
322          previous one -- see 'sentence connector' above), it uses
323          relationship1 = 'connecting word', in one of three shapes:
324            - a SINGLE connecting word joining a pair: relatedtoken1 -> the
325              id of the FIRST connected item, relatedtoken2 -> the id of
326              the SECOND connected item, relationship2 = 'connecting word'
327              too (both fields on the one connecting-word token). Examples:
328              in 'ἐπιτηρῶν γὰρ τὴν θεράπαιναν τὴν εἰς τὴν ἀγορὰν
329              βαδίζουσαν καὶ λόγους προσφέρων ἀπώλεσεν αὐτήν', καί joins
330              the participles ἐπιτηρῶν and προσφέρων: relatedtoken1 ->
331              ἐπιτηρῶν, relatedtoken2 -> προσφέρων. In 'ἐγὼ τοίνυν ἐξ
332              ἀρχῆς ὑμῖν ἅπαντα ἐπιδείξω τὰ ἐμαυτοῦ πράγματα, οὐδὲν
333              παραλείπων, ἀλλὰ λέγων τἀληθῆ', ἀλλά joins the participles
334              παραλείπων and λέγων the same way. In 'οἰκίδιον ἔστι μοι
335              διπλοῦν, ἴσα ἔχον τὰ ἄνω τοῖς κάτω κατὰ τὴν γυναικωνῖτιν καὶ
336              κατὰ τὴν ἀνδρωνῖτιν', καί joins the two prepositional
337              phrases' own prepositions (the first and second κατά).
338            - a PAIRED correlative (e.g. postpositive τε...καί, or a
339              repeated καὶ...καί, or -- within a single sentence -- μέν
340              continued by δέ): each of the two connecting words has
341              relatedtoken1 -> ITS OWN adjacent connected item (not "the
342              first item" generically -- whichever item that particular
343              connector itself sits next to), and relatedtoken2 -> the id
344              of the OTHER connecting word (not another connected item).
345              Example: in 'ἐφύλαττόν τε καὶ προσεῖχον τὸν νοῦν', τε and
346              καί join the verbal expressions ἐφύλαττον and προσεῖχον: τε
347              has relatedtoken1 -> ἐφύλαττον, relatedtoken2 -> καί
348              (relationship2 'connecting word' too); καί has relatedtoken1
349              -> προσεῖχον, relatedtoken2 -> τε. Example (repeated καί):
350              in 'περὶ τούτου γὰρ μόνου τοῦ ἀδικήματος καὶ ἐν δημοκρατίᾳ
351              καὶ ὀλιγαρχίᾳ ἡ αὐτὴ τιμωρία τοῖς ἀσθενεστάτοις πρὸς τοὺς τὰ
352              μέγιστα δυναμένους ἀποδέδοται', the first καί has
353              relatedtoken1 -> δημοκρατίᾳ, relatedtoken2 -> the second
354              καί; the second καί has relatedtoken1 -> ὀλιγαρχίᾳ,
355              relatedtoken2 -> the first καί. Example (μέν...δέ within one
356              sentence, not split across sentences -- contrast 'sentence
357              connector' above): in 'ἐγὼ μὲν ἄνω διῃτώμην, αἱ δὲ γυναῖκες
358              κάτω', μέν has relatedtoken1 -> διῃτώμην (its own,
359              first clause's verb), relatedtoken2 -> δέ; δέ has
360              relatedtoken1 -> the second clause's own verb (here the
361              implied-repetition token standing in for the elided verb --
362              see 'implied repetition' below), relatedtoken2 -> μέν.
363            - a SERIES of 3 or more connected items: every connecting word
364              still has relatedtoken1 -> ITS OWN adjacent item, same as the
365              paired case; relatedtoken2 chains the series together --
366              the FIRST connecting word's relatedtoken2 -> the SECOND
367              connecting word's id (forward), and every LATER connecting
368              word's relatedtoken2 -> the id of the connecting word
369              immediately BEFORE it (backward) -- so the whole series is
370              still traceable by following relatedtoken2 links between the
371              connecting-word tokens, even though no single token names
372              every member. Example: in 'οὔτε γὰρ συκοφαντῶν γραφάς με
373              ἐγράψατο, οὔτε ἐκβάλλειν ἐκ τῆς πόλεως ἐπεχείρησεν, οὔτε
374              ἰδίας δίκας ἐδικάζετο.', the first οὔτε has relatedtoken1 ->
375              ἐγράψατο, relatedtoken2 -> the second οὔτε; the second οὔτε
376              has relatedtoken1 -> ἐπεχείρησεν, relatedtoken2 -> the first
377              οὔτε; the third οὔτε has relatedtoken1 -> ἐδικάζετο,
378              relatedtoken2 -> the second οὔτε. The same pattern can occur
379              with μέν starting a series and δέ continuing it.
380          # TODO: syntax_model.md does not discuss καί's double life as
381          # connective ("and") vs. adverb ("also"/"even"), unlike Latin's
382          # explicit treatment of 'et'. By analogy: when καί modifies a
383          # single word rather than joining two, treat it like any other
384          # adverb instead -- relatedtoken1 -> the verb (or nearest token)
385          # it emphasizes, relationship1 'adverbial', not 'connecting
386          # word'. This guidance is an extrapolation, not sanctioned by a
387          # syntax_model.md example.
388        - direct quote / aside: a verbal expression of syntactic type
389          'direct quote' or 'aside' has relatedtoken1 -> the id of the verb
390          of the clause it interrupts or is framed by, relationship1 =
391          'direct quote' or 'aside' respectively (matching its syntactic
392          type). Examples above under (1).
393        - circumstantial participle / genitive absolute: a circumstantial
394          participial verbal expression's own relatedtoken1 -> the id of
395          the noun or pronoun it agrees with, relationship1 =
396          'circumstantial participle'. That noun in turn: if it also fits
397          a normal role in the surrounding clause (e.g. it's already the
398          main verb's subject), it takes THAT normal relation instead
399          (nothing extra to add). Example: in 'ἐγὼ ἅπαντα ἐπιδείξω τὰ
400          ἐμαυτοῦ πράγματα, οὐδὲν παραλείπων, ἀλλὰ λέγων τἀληθῆ', both
401          παραλείπων and λέγων have relatedtoken1 -> ἐγώ, relationship1
402          'circumstantial participle', and ἐγώ (already the subject of
403          ἐπιδείξω) has relatedtoken1 -> ἐπιδείξω, relationship1
404          'subject' -- nothing further added for the participles. If the
405          noun is a GENITIVE with no other syntactic connection to the
406          sentence (a genitive absolute), it instead has relatedtoken1 ->
407          the id of the main verb, relationship1 = 'genitive absolute'.
408          Example: in 'προϊόντος δὲ τοῦ χρόνου ἧκον μὲν ἀπροσδοκήτως ἐξ
409          ἀγροῦ', προϊόντος has relatedtoken1 -> χρόνου, relationship1
410          'circumstantial participle', and χρόνου in turn has
411          relatedtoken1 -> ἧκον, relationship1 'genitive absolute'.
412        - attributive participle: an attributive participial verbal
413          expression's own relatedtoken1 -> the id of the noun or pronoun
414          it agrees with, relationship1 = 'attributive participle'.
415          Example: in 'ὁ γὰρ ἀνὴρ ὁ ὑβρίζων εἰς σὲ ἐχθρὸς ὢν ἡμῖν
416          τυγχάνει', ὑβρίζων has relatedtoken1 -> ἀνήρ, relationship1
417          'attributive participle'.
418          # TODO: models.py's RelationLabel comment block also glosses
419          # "attributive" itself as covering "participle-in-attributive-
420          # position", alongside the dedicated "attributive participle"
421          # value documented above and in syntax_model.md's own worked
422          # example. Since Greek (unlike Latin) makes EVERY attributive
423          # participle its own verbal expression, this port resolves the
424          # overlap by always using 'attributive participle' for an
425          # attributive participle's relation to its noun, and reserving
426          # plain 'attributive' for ordinary adjectives and prepositional
427          # phrases (see below) -- flagged here as an unresolved tension
428          # in the fixed contract, not something syntax_model.md itself
429          # disambiguates.
430        - auxiliary: in a compound perfect-system verb form (participle +
431          a conjugated form of εἰμί), the form of εἰμί anchors the verbal
432          expression and is the target of every relation into it (subject,
433          direct object, agent, etc); the participle itself has
434          relatedtoken1 -> the id of that form of εἰμί, relationship1 =
435          'auxiliary'. Example: in 'ὁ νόμος γεγραμμένος ἐστίν', γεγραμμένος
436          has relatedtoken1 -> ἐστίν, relationship1 'auxiliary'.
437        - agent: the preposition ὑπό introducing the agent of a passive
438          verb has relatedtoken1 -> the passive verb's id (the id of the
439          form of εἰμί, for a compound form), relationship1 = 'agent'. The
440          noun/pronoun governed by that ὑπό has relatedtoken1 -> the id of
441          ὑπό, relationship1 = 'object of preposition'. Example: in 'ἡ ἐμὴ
442          γυνὴ ὑπὸ τούτου τοῦ ἀνθρώπου διαφθείρεται', ὑπό has relatedtoken1
443          -> διαφθείρεται, relationship1 'agent', and ἀνθρώπου has
444          relatedtoken1 -> ὑπό, relationship1 'object of preposition'.
445        - subject / direct object / predicate: a noun or pronoun serving
446          as subject or direct object has relatedtoken1 -> the id of the
447          verb (the id of the form of εἰμί, for a compound form),
448          relationship1 = 'subject' or 'direct object'. This applies to
449          the accusative subject of an infinitive in indirect statement
450          too. Examples: in 'ἐμοίχευεν Ἐρατοσθένης τὴν γυναῖκα τὴν ἐμήν',
451          Ἐρατοσθένης has relatedtoken1 -> ἐμοίχευεν, relationship1
452          'subject', and γυναῖκα has relatedtoken1 -> ἐμοίχευεν,
453          relationship1 'direct object'. A noun, pronoun, or adjective
454          serving as the predicate complement of a LINKING verb uses
455          relationship1 = 'predicate' instead, same relatedtoken1 target.
456          Example: in 'ᾤμην τὴν ἐμαυτοῦ γυναῖκα πασῶν σωφρονεστάτην εἶναι
457          τῶν ἐν τῇ πόλει', εἶναι anchors an 'indirect statement'/'linking
458          verb' verbal expression governed by ᾤμην; γυναῖκα is its subject
459          (relatedtoken1 -> εἶναι, relationship1 'subject') and
460          σωφρονεστάτην is its predicate adjective (relatedtoken1 ->
461          εἶναι, relationship1 'predicate'). If the token is a relative
462          pronoun already using relatedtoken1/relationship1 for its
463          antecedent link, put this relation in relatedtoken2/
464          relationship2 instead (see 'relative pronoun' above).
465        - article: the definite article relates to the noun (or
466          substantivized adjective, adverb, or infinitive) it accompanies:
467          relatedtoken1 -> that word's id, relationship1 = 'article'.
468          Example: in 'τῷ χρόνῳ πεισθείη', τῷ has relatedtoken1 -> χρόνῳ,
469          relationship1 'article'. When an adjective is in attributive
470          position with a REPEATED article (article-noun-article-
471          adjective), the second article instead has relatedtoken1 -> the
472          id of that adjective, relationship1 = 'article' -- and the
473          adjective itself still gets the ordinary 'attributive' relation
474          to the noun (see below). Example: in 'εἵλου τοιοῦτον ἁμάρτημα
475          ἐξαμαρτάνειν εἰς τὴν γυναῖκα τὴν ἐμήν', the first τήν has
476          relatedtoken1 -> γυναῖκα, relationship1 'article'; the second
477          τήν has relatedtoken1 -> ἐμήν, relationship1 'article'; and ἐμήν
478          has relatedtoken1 -> γυναῖκα, relationship1 'attributive'. (If
479          the adjective instead stood between article and noun with no
480          second article, e.g. 'τὴν ἐμὴν γυναῖκα', the single τήν and ἐμήν
481          keep exactly those same relations -- there is simply no second
482          article token to add.)
483        - attributive: an adjective in attributive position, a participle
484          in attributive position AS AN ORDINARY MODIFIER of a noun
485          distinct from its own verbal-expression relation (see the
486          'attributive participle' TODO above), or a prepositional phrase
487          modifying a noun, has relatedtoken1 -> the noun's id,
488          relationship1 = 'attributive'. Example (adjective): ἐμήν in the
489          εἵλου example above. Example (prepositional phrase):
490          # TODO: syntax_model.md's own worked example here ("attributive
491          # to a noun: in the phrase pugna ad Cannas...") is still the
492          # untranslated Latin example, apparently left over when the
493          # document was adapted for Greek. Substituting a constructed
494          # Greek example instead: in 'ἡ μάχη ἡ ἐν Μαραθῶνι', ἐν has
495          # relatedtoken1 -> μάχη, relationship1 'attributive', and
496          # Μαραθῶνι has relatedtoken1 -> ἐν, relationship1 'object of
497          # preposition'. An adjective used as a substantive (standing in
498          # for a noun) is treated as a noun/pronoun instead, not as
499          # attributive. Example: in 'ἐκείνη μὲν ἀπηλλάγη', ἐκείνη has
500          # relatedtoken1 -> ἀπηλλάγη, relationship1 'subject' (not
501          # 'attributive' or 'demonstrative' -- it stands for a noun
502          # here, it does not modify one).
503        - demonstrative: a demonstrative pronoun modifying a noun -- unlike
504          an ordinary adjective, NOT in attributive position -- has
505          relatedtoken1 -> the noun's id, relationship1 = 'demonstrative'.
506          Example: in 'ταύτην ἔλαβον τὴν δίκην', ταύτην has relatedtoken1
507          -> δίκην, relationship1 'demonstrative'.
508        - adverbial (bare adverb): an adverb modifying a verb has
509          relatedtoken1 -> the verb's id, relationship1 = 'adverbial'.
510          Example: in 'διαρρήδην εἴρηται', διαρρήδην has relatedtoken1 ->
511          εἴρηται, relationship1 'adverbial'. An adverb can also stand in
512          attributive position modifying a noun -- same relationship1
513          value either way, just a noun instead of a verb on the other
514          end. Example: in 'δᾷδας λαβόντες ἐκ τοῦ ἐγγύτατα καπηλείου
515          εἰσερχόμεθα', ἐγγύτατα has relatedtoken1 -> καπηλείου,
516          relationship1 'adverbial'.
517        - prepositional phrases: the preposition has relatedtoken1 -> the
518          id of the verb (adverbial) or noun (attributive) it modifies,
519          relationship1 = 'adverbial' or 'attributive'. The noun/pronoun
520          it governs has relatedtoken1 -> the id of the preposition,
521          relationship1 = 'object of preposition' (or relatedtoken2/
522          relationship2 if relatedtoken1 is already used for a
523          relative-pronoun link). Example: in 'γυναῖκα ἠγαγόμην εἰς τὴν
524          οἰκίαν', εἰς has relatedtoken1 -> ἠγαγόμην, relationship1
525          'adverbial', and οἰκίαν has relatedtoken1 -> εἰς, relationship1
526          'object of preposition'.
527        - genitive: a noun or pronoun in the genitive that modifies
528          ANOTHER NOUN -- and isn't already covered by a more specific
529          relation above (object of preposition, genitive absolute, etc)
530          -- has relatedtoken1 -> the id of that noun, relationship1 =
531          'genitive'. This is purely a syntactic (case-function) label,
532          not a semantic one -- don't distinguish e.g. possessive vs.
533          partitive genitive. Example: in 'ᾤχετο εἰς τὸ ἱερὸν μετὰ τῆς
534          μητρὸς τῆς ἐκείνου', ἐκείνου has relatedtoken1 -> μητρός,
535          relationship1 'genitive'.
536        - dative / accusative: a noun in the dative or accusative case
537          that depends on a verb or another noun -- and isn't already
538          covered by a more specific relation above (subject, direct
539          object, object of preposition, etc) -- has relatedtoken1 -> the
540          id of the verb or noun it depends on, relationship1 = the
541          matching case name ('dative' or 'accusative'). Example (dative,
542          linked to a verb): in 'οὔτε ἔχθρα ἐμοὶ καὶ ἐκείνῳ οὐδεμία ἦν
543          πλὴν ταύτης, οὔτε χρημάτων ἕνεκα ἔπραξα ταῦτα', both ἐμοί and
544          ἐκείνῳ have relatedtoken1 -> ἦν, relationship1 'dative'. Example
545          (accusative of extent of time, linked to a verb): in 'ταῦτα
546          πολὺν χρόνον οὕτως ἐγίγνετο', χρόνον has relatedtoken1 ->
547          ἐγίγνετο, relationship1 'accusative'. Note that Greek has NO
548          ablative case and NO dedicated relation label for one -- a
549          Latin-scheme 'ablative' relation simply does not arise here.
550        - vocative: a noun in the vocative case (direct address) has
551          relatedtoken1 -> the id of the verb of the clause it's addressed
552          within, relationship1 = 'vocative'. Unlike 'genitive'/'dative'/
553          'accusative' above, a vocative relates to a verb only, never to
554          another noun. Example: in 'ἐγὼ μὲν οὖν, ὦ ἄνδρες, οὐκ ἰδίαν ὑπὲρ
555          ἐμαυτοῦ νομίζω ταύτην γενέσθαι τὴν τιμωρίαν', ἄνδρες has
556          relatedtoken1 -> νομίζω, relationship1 'vocative'.
557        - apposition: when one noun stands in apposition to another, the
558          appositive has relatedtoken1 -> the id of the first (the noun it
559          restates or further identifies), relationship1 = 'apposition'. A
560          genitive depending on either noun still gets its own ordinary
561          'genitive' relation, pointing at whichever noun it actually
562          depends on -- apposition doesn't change that.
563          # TODO: syntax_model.md gives only the general definition here,
564          # no worked Greek example. Constructed illustration: in
565          # 'Δημοσθένης ὁ ῥήτωρ ἦλθεν', ῥήτωρ has relatedtoken1 ->
566          # Δημοσθένης, relationship1 'apposition' (and ὁ has
567          # relatedtoken1 -> ῥήτωρ, relationship1 'article').
568        - exclamation: an exclamatory word has relatedtoken1 -> the id of
569          the verb of its own verbal unit, relationship1 = 'exclamation' --
570          EXCEPT the frequent exclamatory particle ὦ introducing a
571          vocative, which instead has relatedtoken1 -> the id of the
572          vocative noun/pronoun it introduces (not the verb directly).
573          Example: in 'ἐγὼ μὲν οὖν, ὦ ἄνδρες, οὐκ ἰδίαν ὑπὲρ ἐμαυτοῦ
574          νομίζω ταύτην γενέσθαι τὴν τιμωρίαν, ἀλλ' ὑπὲρ τῆς πόλεως
575          ἁπάσης', ὦ has relatedtoken1 -> ἄνδρες (the vocative it
576          introduces), relationship1 'exclamation' -- NOT relatedtoken1 ->
577          νομίζω directly, even though ἄνδρες's own relatedtoken1 does
578          point to νομίζω (relationship1 'vocative'). This same pattern
579          applies wherever ὦ introduces a vocative elsewhere in a passage,
580          e.g. 'πρῶτον μὲν οὖν, ὦ ἄνδρες, ...': ὦ -> ἄνδρες, 'exclamation'.
581
582        Only assign relations described above. Leave relatedtoken/
583        relationship fields unset for tokens with no relation of these
584        kinds -- not every token will have one (e.g. a bare accusative of
585        respect not covered above). Use
586        only the token ids given in the input `tokens` list, the sentinel
587        'root', or a NEW id you create for an implied token (see below), in
588        your output; never invent an id for anything else.
589
590    (3) implied/elided tokens. `grammatike` recognizes two DIFFERENT
591        situations where a verbal expression exists grammatically but has
592        no surface realization in the passage at all -- rather than skip
593        these, add a NEW entry to `tokengraph` (and a matching new entry to
594        `verbalunits`, since an implied token always anchors its own
595        verbal expression) with: a brand-new id, not used by any entry in
596        `tokens` or elsewhere in your own output (see the naming rule
597        below); the matching tokentype below; and no `token` value (leave
598        it unset/None) -- these go together, and 'implied eimi' /
599        'implied repetition' are the ONLY two tokentype values whose id
600        isn't one of `tokens`' own ids and whose `token` is empty.
601
602        - tokentype 'implied eimi': an elided form of εἰμί ('to be') in a
603          predicate expression. The documented case is an implied
604          INFINITIVE of εἰμί inside indirect statement: the implied token
605          anchors a verbal expression classified 'indirect statement' and
606          'linking verb', relates to its governing verb of thinking/saying
607          via relatedtoken1/relationship1 = 'indirect statement' exactly
608          as a written-out infinitive would, and the subject/predicate of
609          the predication relate to it as 'subject'/'predicate' exactly as
610          they would to any linking verb. Example: 'ταύτην τὴν ὕβριν
611          ἅπαντες ἄνθρωποι δεινοτάτην ἡγοῦνται' has an independent verbal
612          expression ἡγοῦνται governing an implied infinitive of εἰμί
613          (syntactic type 'indirect statement', semantic type 'linking
614          verb') whose relatedtoken1 -> ἡγοῦνται, relationship1 'indirect
615          statement'; 'ταύτην τὴν ὕβριν' relates to it as 'subject' and
616          δεινοτάτην as 'predicate'.
617          # TODO: the two sub-cases below extrapolate from that one
618          # documented example and from the general phrasing "elided εἰμί
619          # in predicate expressions" -- syntax_model.md gives no worked
620          # example for either:
621            - a bare predicate construction with NO governing verb at all
622              (subject + predicate noun/adjective, nothing else): the
623              implied token anchors a verbal expression classified
624              'independent' (or 'dependent', if the elided-εἰμί clause is
625              itself subordinate) and 'linking verb'; subject and
626              predicate relate to it exactly as they would to any linking
627              verb.
628            - an omitted conjugated εἰμί in a compound perfect-system form
629              (the participle left standing alone for its auxiliary): the
630              implied token stands in for the omitted form of εἰμί --
631              everything that would normally relate to that auxiliary
632              (subject, the participle's own 'auxiliary' relation, etc.)
633              relates to the implied token instead, exactly as if the
634              auxiliary had been written out.
635        - tokentype 'implied repetition': a verb elided from a later
636          verbal expression in a coordinated series because it repeats the
637          verb of an earlier one. Add ONE implied token per omitted
638          repeated verb, repeating that verb's OWN syntactic_type and
639          semantic_type exactly (whatever those happen to be in context --
640          not necessarily 'independent'/'intransitive'), and give whatever
641          would relate to the omitted verb (subject, adverbial, a
642          connecting word, etc.) its normal relation into the implied
643          token instead, exactly as if the verb had been repeated.
644          Example: 'ἐγὼ μὲν ἄνω διῃτώμην, αἱ δὲ γυναῖκες κάτω' has an
645          explicit verbal expression διῃτώμην ('independent'/
646          'intransitive') with subject ἐγώ, and a second, implied verbal
647          expression (tokentype 'implied repetition') repeating
648          διῃτώμην's own 'independent'/'intransitive' classification, with
649          subject γυναῖκες and adverbial κάτω relating to the implied
650          token instead of to διῃτώμην.
651
652        Naming an implied token's id (both tokentypes): append '_implied'
653        to the id of the LAST real token in `tokens` that precedes where
654        the elided word would have stood (or, if the elided word would
655        come before every real token in the sentence, the FIRST real
656        token's id instead). If more than one implied token is ever
657        needed at the same position, append '2', '3', ... after '_implied'
658        to keep them unique (e.g. 't5_implied', 't5_implied2'). Place the
659        new `tokengraph` entry at the list position where the elided word
660        would have appeared, among the tokens of its own clause -- this
661        keeps it grouped with the rest of its verbal expression for
662        anything that reads `tokengraph` in order.
663    """
664
665    passage: str = dspy.InputField(desc="The Ancient Greek passage to analyze, exactly as written.")
666    tokens: List[Token] = dspy.InputField(
667        desc="Pre-segmented tokens of the passage, in order, with fixed ids. Reference these ids in your output; do not create new ones."
668    )
669    verbalunits: List[VerbalExpression] = dspy.OutputField(
670        desc="One entry per verbal expression (finite verb; infinitive or participle used in indirect speech; attributive participle; or circumstantial participle) in the passage."
671    )
672    tokengraph: List[TokenAnalysis] = dspy.OutputField(
673        desc=(
674            "One entry per token in `tokens`, in the same order, with its "
675            "type and any relations -- PLUS one additional entry for each "
676            "implied/elided token you add (see this signature's docstring), "
677            "positioned where that token's clause falls in reading order."
678        )
679    )

Analyze the syntax of a passage of Ancient Greek according to a two-part scheme:

(1) a list of verbal expressions. Three constructions count as a verbal expression: finite verbs, infinitives, and participles -- but for participles, only some of them (see below).

- A finite verb (including a compound perfect-system form made of
  a participle plus a conjugated form of εἰμί, e.g. ὁ νόμος
  γεγραμμένος ἐστίν) is always a verbal expression. Classify its
  syntactic type as 'independent' (main/principal), 'dependent'
  (subordinate, introduced by a subordinating word), 'direct
  quote' (occurring in directly quoted speech framed by another
  verb, e.g. νόμιζε in '"εὐφίλητε" ἔφη "μηδεμιᾷ πολυπραγμοσύνῃ
  προσεληλυθέναι με νόμιζε πρὸς σέ."'), or 'aside' (a verbal
  expression that interrupts the surrounding syntax, e.g. δεῖ in
  'πρῶτον μὲν οὖν, ὦ ἄνδρες, (δεῖ γὰρ καὶ ταῦθ᾽ ὑμῖν διηγήσασθαι)
  οἰκίδιον ἔστι μοι διπλοῦν' interrupting the independent verbal
  expression ἔστι). Example of independent vs. dependent: in
  'ἐπειδὴ δὲ ἦν πρὸς ἡμέραν, ἧκεν ἐκείνη', ἧκεν is 'independent'
  and ἦν is 'dependent' (introduced by the subordinating
  conjunction ἐπειδή).
- An infinitive is a verbal expression only when part of an
  indirect statement; its syntactic type is always 'indirect
  statement'. Example: in 'ἔφασκε τὸν λύχνον ἀποσβεσθῆναι', ἔφασκε
  is independent and ἀποσβεσθῆναι anchors the indirect-statement
  verbal expression. In a compound perfect-system form (participle
  + a conjugated form of εἰμί), the form of εἰμί anchors the
  verbal expression, same as any other compound form.
- A participle constitutes a verbal expression in THREE cases,
  each with its own dedicated syntactic_type -- unlike Latin,
  which uses a single 'dependent' value for every predicate-sense
  participle, Greek's scheme gives each its own name:
    - 'indirect statement': a participle expressing indirect
      speech after a verb of perception or thinking. Example: in
      'εἶδε δὲ τὴν βασίλειαν φεύγουσαν', εἶδε is independent and
      φεύγουσαν (not an infinitive here) anchors the
      indirect-statement verbal expression.
    - 'attributive': a participle in attributive position.
      Example: in 'ὁ γὰρ ἀνὴρ ὁ ὑβρίζων εἰς σὲ ἐχθρὸς ὢν ἡμῖν
      τυγχάνει', the repeated article puts ὑβρίζων in attributive
      position with ἀνήρ, so ὑβρίζων anchors an 'attributive'
      verbal expression. In Greek, UNLIKE Latin, every attributive
      participle counts as its own verbal expression -- there is
      no purely-adjectival, non-verbal-expression reading for an
      attributive participle the way Latin's "consentiens laus"
      was not a verbal expression at all.
    - 'circumstantial': a participle in circumstantial position
      (including one forming a genitive absolute). Example: in
      'χρόνου μεταξὺ διαγενομένου, προσέρχεταί μοί τις πρεσβῦτις
      ἄνθρωπος', προσέρχεταί is independent and διαγενομένου
      anchors a 'circumstantial' verbal expression.
  By contrast, a *supplementary* participle -- one that completes
  the sense of its governing verb as a single predicate idea
  (e.g. with τυγχάνω, λανθάνω, φαίνομαι, παύομαι, or the like),
  rather than standing attributively with a noun or
  circumstantially/adverbially to the clause -- is explicitly NOT
  a verbal expression and gets no `verbalunits` entry at all.
  Example: in that same sentence 'ὁ γὰρ ἀνὴρ ὁ ὑβρίζων εἰς σὲ
  ἐχθρὸς ὢν ἡμῖν τυγχάνει', ὤν supplements τυγχάνει (there is a
  single independent verbal expression, anchored to τυγχάνει) and
  is NOT its own verbal expression. Do not over-generate
  verbal-expression entries for participles: check first whether a
  participle is genuinely attributive (repeated article, or
  agreeing with a noun as its ordinary modifier), genuinely
  circumstantial (an adverbial predication about a noun, loosely
  attached to the clause), or genuinely reporting indirect
  perception -- and only then give it a `verbalunits` entry; a
  participle that instead completes one predicate idea together
  with a governing verb like τυγχάνω does not.
  # TODO: syntax_model.md does not name a RelationLabel for a
  # supplementary participle's own relation to its governing verb
  # (no "supplementary" or "complementary participle" value
  # exists). This port leaves such a participle's own
  # relatedtoken1/relationship1 unset -- no documented label
  # fits -- while still letting it take its own predicate/
  # object/adverbial complements exactly as a linking verb would
  # (e.g. ἐχθρός, the predicate adjective of ὤν in the example
  # above, still relates to ὤν with relationship1 'predicate',
  # exactly as it would to a finite linking verb).

Classify each verbal expression's semantic type too (transitive
active / transitive passive / intransitive / linking verb).
Examples: προσεῖχον in 'προσεῖχον τὸν νοῦν' is transitive active;
διαφθείρεται in 'ἡ ἐμὴ γυνὴ ὑπὸ τούτου τοῦ ἀνθρώπου διαφθείρεται'
is transitive passive; εἰσῄει in 'πάντα μου εἰς τὴν γνώμην
εἰσῄει' is intransitive; ἦ in 'μεστὸς ἦ ὑποψίας' is a 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 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 'τὴν θύραν ἀνέῳξεν', ἀνέῳξεν has
  relatedtoken1 'root', relationship1 'unit verb'.
- unit verb (dependent) / subordinating conjunction / relative
  pronoun: the verb of a DEPENDENT clause has relatedtoken1 -> the
  id of its subordinating conjunction or relative/interrogative
  pronoun, relationship1 = 'unit verb'. That conjunction or pronoun
  in turn has relatedtoken1 -> the id of the verb of the clause it
  is subordinate to, with relationship1 = 'subordinating
  conjunction' for a conjunction, or relatedtoken1 -> its
  antecedent's id with relationship1 = 'relative pronoun' for a
  relative pronoun. Example: in 'ἐπειδὴ δὲ ἦν πρὸς ἡμέραν, ἧκεν
  ἐκείνη', ἐπειδή has relatedtoken1 -> ἧκεν, relationship1
  'subordinating conjunction', and ἦν has relatedtoken1 ->
  ἐπειδή, relationship1 'unit verb'. Another example, with a
  conjunction: in 'κατηγόρει ὡς μετὰ τὴν ἐκφορὰν αὐτῇ προσίοι',
  ὡς has relatedtoken1 -> κατηγόρει, relationship1 'subordinating
  conjunction', and προσίοι has relatedtoken1 -> ὡς, relationship1
  'unit verb'. Indirect questions are treated as a kind of
  dependent clause: an interrogative pronoun introducing one is
  treated the same way as a subordinating conjunction -- it has
  relatedtoken1 -> the id of the verb it introduces, relationship1
  = 'subordinating conjunction' (no separate label for this case)
  -- while the dependent verb itself has relatedtoken1 -> the
  interrogative word's id, relationship1 = 'unit verb', exactly
  like any other dependent clause.
  # TODO: syntax_model.md states this indirect-question rule only
  # in passing ("a subordinating conjunction or a relative or
  # interrogative pronoun") with no worked example; the following
  # is constructed by analogy, not quoted from syntax_model.md:
  # in 'οὐκ οἶδα τίς ἦλθεν' ("I don't know who came"), τίς has
  # relatedtoken1 -> οἶδα, relationship1 'subordinating
  # conjunction', and ἦλθεν has relatedtoken1 -> τίς, relationship1
  # 'unit verb'.
- relative pronoun (second relation): a relative pronoun ALSO
  relates to its own function inside the relative clause, using
  relatedtoken2/relationship2 (since relatedtoken1/relationship1 is
  already used for the antecedent link) -- the ordinary relation
  it would have if it were any other noun/pronoun in that clause
  (e.g. 'direct object', 'subject', a case relation, etc). Example:
  in 'οὐκ ἐγώ σε ἀποκτενῶ, ἀλλ᾽ ὁ τῆς πόλεως νόμος, ὃν σὺ περὶ
  ἐλάττονος τῶν ἡδονῶν ἐποιήσω', ὅν has relatedtoken1 -> νόμος
  (its antecedent), relationship1 'relative pronoun', AND
  relatedtoken2 -> ἐποιήσω, relationship2 'direct object'.
- indirect statement (governing verb): an infinitive OR participle
  anchoring an indirect-statement verbal expression ALSO has
  relatedtoken1 -> the id of the verb that governs the indirect
  statement (the verb of saying/thinking/perceiving it depends
  on), relationship1 = 'indirect statement' -- matching its own
  syntactic type, the same convention 'direct quote' and 'aside'
  verbal expressions use below. There's no separate
  subordinating-word token to point at first, so the infinitive or
  participle points directly at its governing verb, rather than
  via a conjunction/pronoun intermediary the way a dependent
  finite verb's 'unit verb' relation does. Examples: in
  'ἔφασκε τὸν λύχνον ἀποσβεσθῆναι', ἀποσβεσθῆναι has relatedtoken1
  -> ἔφασκε, relationship1 'indirect statement'; in 'εἶδε δὲ τὴν
  βασίλειαν φεύγουσαν', φεύγουσαν has relatedtoken1 -> εἶδε,
  relationship1 'indirect statement'. In a compound perfect-system
  form, this relation belongs on the conjugated form of εἰμί that
  anchors the verbal expression, same as any other relation into
  it.
- complementary infinitive: an infinitive that completes the sense
  of a governing verb like βούλομαι, δεῖ, or ἐθέλω (rather than
  reporting indirect speech) has relatedtoken1 -> the id of that
  governing verb, relationship1 = 'complementary infinitive'.
  Unlike an indirect-statement infinitive, this does NOT make the
  infinitive its own verbal expression -- it gets no `verbalunits`
  entry of its own; the governing verb is still the only verbal
  expression here. Example: in 'ἔξεστι ἑλέσθαι', ἑλέσθαι has
  relatedtoken1 -> ἔξεστι, relationship1 'complementary
  infinitive'.
- modal particle: the particle ἄν has relatedtoken1 -> the id of
  the verb of ITS OWN verbal unit (not some other unit's verb),
  relationship1 = 'modal particle'. Example: in 'εἰ τὴν αὐτὴν
  γνώμην περὶ τῶν ἄλλων ἔχοιτε, οὐκ ἂν εἴη, ὅστις οὐκ ἐπὶ τοῖς
  γεγενημένοις ἀγανακτοίη' (two dependent verbal expressions plus
  one independent verbal expression anchored to εἴη), ἂν has
  relatedtoken1 -> εἴη, relationship1 'modal particle' -- εἴη is
  ἂν's own verbal unit's verb, the same verb οὐκ (adverbial) also
  relates to.
- infinitive used as a noun: an infinitive can also function as an
  ordinary noun -- most often a verb's subject or object -- rather
  than anchoring an indirect statement or completing another verb.
  Treat it exactly like any other noun in that role: relatedtoken1
  -> the verb it's the subject/object of, relationship1 =
  'subject' or 'direct object' as appropriate (no dedicated label,
  and again no `verbalunits` entry of its own). If the infinitive
  carries a definite article (an articular infinitive, e.g. τὸ
  ζῆν), that article relates to the infinitive exactly as it would
  to a substantivized adjective or adverb: relatedtoken1 -> the
  infinitive's id, relationship1 'article'. Like any verbal form,
  an infinitive used this way can still take its own object or
  adverb, related to it the same way they'd relate to a finite
  verb.
  # TODO: syntax_model.md does not discuss this construction at
  # all; it is carried over from arsgrammatica's equivalent
  # section by direct analogy, since substantival infinitives
  # (often articular) are common in Greek too. No example here is
  # quoted from syntax_model.md.
- sentence connector: true asyndeton is rare at the root level of
  a sentence -- there is normally a connecting word expressing the
  relation of the sentence to its predecessor. This connecting
  word has relatedtoken1 -> the verb of THIS sentence (not the
  previous one), relationship1 = 'sentence connector'. Example: in
  'ταύτην γὰρ ἐμαυτῷ μόνην ἡγοῦμαι σωτηρίαν', γάρ has relatedtoken1
  -> ἡγοῦμαι, relationship1 'sentence connector'. The particle μέν
  begins a list of items, continued by δέ -- ordinarily WITHIN one
  sentence (see 'connecting word' below), but when the items are
  instead split across distinct, separately terminated sentences,
  μέν or δέ is a 'sentence connector' too, exactly like γάρ, rather
  than a 'connecting word': it has relatedtoken1 -> the verb of
  ITS OWN sentence, relationship1 'sentence connector', with no
  relation at all to the other sentence's verb (a sentence
  connector never records a cross-sentence link -- only "this
  sentence's own verb"). Examples: in the complete, terminated
  sentence 'περὶ μὲν οὖν τοῦ μεγέθους τῆς ζημίας ἅπαντας ὑμᾶς
  νομίζω τὴν αὐτὴν διάνοιαν ἔχειν, καὶ οὐδένα οὕτως ὀλιγώρως
  διακεῖσθαι, ὅστις οἴεται δεῖν συγγνώμης τυγχάνειν ἢ μικρᾶς
  ζημίας ἀξίους ἡγεῖται τοὺς τῶν τοιούτων ἔργων αἰτίους.', μέν has
  relatedtoken1 -> νομίζω, relationship1 'sentence connector'; in
  the following, separate sentence 'ἡγοῦμαι δέ, ὦ ἄνδρες, τοῦτό με
  δεῖν ἐπιδεῖξαι.', δέ has relatedtoken1 -> ἡγοῦμαι, relationship1
  'sentence connector'.
- connecting word: when a connecting word (coordinating conjunction
  or connecting particle, e.g. καί, ἀλλά, τε, μέν, δέ, οὔτε) joins
  a pair or series of nouns, adjectives, adverbs, or whole clauses
  WITHIN a sentence (as opposed to linking one sentence to the
  previous one -- see 'sentence connector' above), it uses
  relationship1 = 'connecting word', in one of three shapes:
    - a SINGLE connecting word joining a pair: relatedtoken1 -> the
      id of the FIRST connected item, relatedtoken2 -> the id of
      the SECOND connected item, relationship2 = 'connecting word'
      too (both fields on the one connecting-word token). Examples:
      in 'ἐπιτηρῶν γὰρ τὴν θεράπαιναν τὴν εἰς τὴν ἀγορὰν
      βαδίζουσαν καὶ λόγους προσφέρων ἀπώλεσεν αὐτήν', καί joins
      the participles ἐπιτηρῶν and προσφέρων: relatedtoken1 ->
      ἐπιτηρῶν, relatedtoken2 -> προσφέρων. In 'ἐγὼ τοίνυν ἐξ
      ἀρχῆς ὑμῖν ἅπαντα ἐπιδείξω τὰ ἐμαυτοῦ πράγματα, οὐδὲν
      παραλείπων, ἀλλὰ λέγων τἀληθῆ', ἀλλά joins the participles
      παραλείπων and λέγων the same way. In 'οἰκίδιον ἔστι μοι
      διπλοῦν, ἴσα ἔχον τὰ ἄνω τοῖς κάτω κατὰ τὴν γυναικωνῖτιν καὶ
      κατὰ τὴν ἀνδρωνῖτιν', καί joins the two prepositional
      phrases' own prepositions (the first and second κατά).
    - a PAIRED correlative (e.g. postpositive τε...καί, or a
      repeated καὶ...καί, or -- within a single sentence -- μέν
      continued by δέ): each of the two connecting words has
      relatedtoken1 -> ITS OWN adjacent connected item (not "the
      first item" generically -- whichever item that particular
      connector itself sits next to), and relatedtoken2 -> the id
      of the OTHER connecting word (not another connected item).
      Example: in 'ἐφύλαττόν τε καὶ προσεῖχον τὸν νοῦν', τε and
      καί join the verbal expressions ἐφύλαττον and προσεῖχον: τε
      has relatedtoken1 -> ἐφύλαττον, relatedtoken2 -> καί
      (relationship2 'connecting word' too); καί has relatedtoken1
      -> προσεῖχον, relatedtoken2 -> τε. Example (repeated καί):
      in 'περὶ τούτου γὰρ μόνου τοῦ ἀδικήματος καὶ ἐν δημοκρατίᾳ
      καὶ ὀλιγαρχίᾳ ἡ αὐτὴ τιμωρία τοῖς ἀσθενεστάτοις πρὸς τοὺς τὰ
      μέγιστα δυναμένους ἀποδέδοται', the first καί has
      relatedtoken1 -> δημοκρατίᾳ, relatedtoken2 -> the second
      καί; the second καί has relatedtoken1 -> ὀλιγαρχίᾳ,
      relatedtoken2 -> the first καί. Example (μέν...δέ within one
      sentence, not split across sentences -- contrast 'sentence
      connector' above): in 'ἐγὼ μὲν ἄνω διῃτώμην, αἱ δὲ γυναῖκες
      κάτω', μέν has relatedtoken1 -> διῃτώμην (its own,
      first clause's verb), relatedtoken2 -> δέ; δέ has
      relatedtoken1 -> the second clause's own verb (here the
      implied-repetition token standing in for the elided verb --
      see 'implied repetition' below), relatedtoken2 -> μέν.
    - a SERIES of 3 or more connected items: every connecting word
      still has relatedtoken1 -> ITS OWN adjacent item, same as the
      paired case; relatedtoken2 chains the series together --
      the FIRST connecting word's relatedtoken2 -> the SECOND
      connecting word's id (forward), and every LATER connecting
      word's relatedtoken2 -> the id of the connecting word
      immediately BEFORE it (backward) -- so the whole series is
      still traceable by following relatedtoken2 links between the
      connecting-word tokens, even though no single token names
      every member. Example: in 'οὔτε γὰρ συκοφαντῶν γραφάς με
      ἐγράψατο, οὔτε ἐκβάλλειν ἐκ τῆς πόλεως ἐπεχείρησεν, οὔτε
      ἰδίας δίκας ἐδικάζετο.', the first οὔτε has relatedtoken1 ->
      ἐγράψατο, relatedtoken2 -> the second οὔτε; the second οὔτε
      has relatedtoken1 -> ἐπεχείρησεν, relatedtoken2 -> the first
      οὔτε; the third οὔτε has relatedtoken1 -> ἐδικάζετο,
      relatedtoken2 -> the second οὔτε. The same pattern can occur
      with μέν starting a series and δέ continuing it.
  # TODO: syntax_model.md does not discuss καί's double life as
  # connective ("and") vs. adverb ("also"/"even"), unlike Latin's
  # explicit treatment of 'et'. By analogy: when καί modifies a
  # single word rather than joining two, treat it like any other
  # adverb instead -- relatedtoken1 -> the verb (or nearest token)
  # it emphasizes, relationship1 'adverbial', not 'connecting
  # word'. This guidance is an extrapolation, not sanctioned by a
  # syntax_model.md example.
- direct quote / aside: a verbal expression of syntactic type
  'direct quote' or 'aside' has relatedtoken1 -> the id of the verb
  of the clause it interrupts or is framed by, relationship1 =
  'direct quote' or 'aside' respectively (matching its syntactic
  type). Examples above under (1).
- circumstantial participle / genitive absolute: a circumstantial
  participial verbal expression's own relatedtoken1 -> the id of
  the noun or pronoun it agrees with, relationship1 =
  'circumstantial participle'. That noun in turn: if it also fits
  a normal role in the surrounding clause (e.g. it's already the
  main verb's subject), it takes THAT normal relation instead
  (nothing extra to add). Example: in 'ἐγὼ ἅπαντα ἐπιδείξω τὰ
  ἐμαυτοῦ πράγματα, οὐδὲν παραλείπων, ἀλλὰ λέγων τἀληθῆ', both
  παραλείπων and λέγων have relatedtoken1 -> ἐγώ, relationship1
  'circumstantial participle', and ἐγώ (already the subject of
  ἐπιδείξω) has relatedtoken1 -> ἐπιδείξω, relationship1
  'subject' -- nothing further added for the participles. If the
  noun is a GENITIVE with no other syntactic connection to the
  sentence (a genitive absolute), it instead has relatedtoken1 ->
  the id of the main verb, relationship1 = 'genitive absolute'.
  Example: in 'προϊόντος δὲ τοῦ χρόνου ἧκον μὲν ἀπροσδοκήτως ἐξ
  ἀγροῦ', προϊόντος has relatedtoken1 -> χρόνου, relationship1
  'circumstantial participle', and χρόνου in turn has
  relatedtoken1 -> ἧκον, relationship1 'genitive absolute'.
- attributive participle: an attributive participial verbal
  expression's own relatedtoken1 -> the id of the noun or pronoun
  it agrees with, relationship1 = 'attributive participle'.
  Example: in 'ὁ γὰρ ἀνὴρ ὁ ὑβρίζων εἰς σὲ ἐχθρὸς ὢν ἡμῖν
  τυγχάνει', ὑβρίζων has relatedtoken1 -> ἀνήρ, relationship1
  'attributive participle'.
  # TODO: models.py's RelationLabel comment block also glosses
  # "attributive" itself as covering "participle-in-attributive-
  # position", alongside the dedicated "attributive participle"
  # value documented above and in syntax_model.md's own worked
  # example. Since Greek (unlike Latin) makes EVERY attributive
  # participle its own verbal expression, this port resolves the
  # overlap by always using 'attributive participle' for an
  # attributive participle's relation to its noun, and reserving
  # plain 'attributive' for ordinary adjectives and prepositional
  # phrases (see below) -- flagged here as an unresolved tension
  # in the fixed contract, not something syntax_model.md itself
  # disambiguates.
- auxiliary: in a compound perfect-system verb form (participle +
  a conjugated form of εἰμί), the form of εἰμί anchors the verbal
  expression and is the target of every relation into it (subject,
  direct object, agent, etc); the participle itself has
  relatedtoken1 -> the id of that form of εἰμί, relationship1 =
  'auxiliary'. Example: in 'ὁ νόμος γεγραμμένος ἐστίν', γεγραμμένος
  has relatedtoken1 -> ἐστίν, relationship1 'auxiliary'.
- agent: the preposition ὑπό introducing the agent of a passive
  verb has relatedtoken1 -> the passive verb's id (the id of the
  form of εἰμί, for a compound form), relationship1 = 'agent'. The
  noun/pronoun governed by that ὑπό has relatedtoken1 -> the id of
  ὑπό, relationship1 = 'object of preposition'. Example: in 'ἡ ἐμὴ
  γυνὴ ὑπὸ τούτου τοῦ ἀνθρώπου διαφθείρεται', ὑπό has relatedtoken1
  -> διαφθείρεται, relationship1 'agent', and ἀνθρώπου has
  relatedtoken1 -> ὑπό, relationship1 'object of preposition'.
- subject / direct object / predicate: a noun or pronoun serving
  as subject or direct object has relatedtoken1 -> the id of the
  verb (the id of the form of εἰμί, for a compound form),
  relationship1 = 'subject' or 'direct object'. This applies to
  the accusative subject of an infinitive in indirect statement
  too. Examples: in 'ἐμοίχευεν Ἐρατοσθένης τὴν γυναῖκα τὴν ἐμήν',
  Ἐρατοσθένης has relatedtoken1 -> ἐμοίχευεν, relationship1
  'subject', and γυναῖκα has relatedtoken1 -> ἐμοίχευεν,
  relationship1 'direct object'. A noun, pronoun, or adjective
  serving as the predicate complement of a LINKING verb uses
  relationship1 = 'predicate' instead, same relatedtoken1 target.
  Example: in 'ᾤμην τὴν ἐμαυτοῦ γυναῖκα πασῶν σωφρονεστάτην εἶναι
  τῶν ἐν τῇ πόλει', εἶναι anchors an 'indirect statement'/'linking
  verb' verbal expression governed by ᾤμην; γυναῖκα is its subject
  (relatedtoken1 -> εἶναι, relationship1 'subject') and
  σωφρονεστάτην is its predicate adjective (relatedtoken1 ->
  εἶναι, relationship1 'predicate'). If the token is a relative
  pronoun already using relatedtoken1/relationship1 for its
  antecedent link, put this relation in relatedtoken2/
  relationship2 instead (see 'relative pronoun' above).
- article: the definite article relates to the noun (or
  substantivized adjective, adverb, or infinitive) it accompanies:
  relatedtoken1 -> that word's id, relationship1 = 'article'.
  Example: in 'τῷ χρόνῳ πεισθείη', τῷ has relatedtoken1 -> χρόνῳ,
  relationship1 'article'. When an adjective is in attributive
  position with a REPEATED article (article-noun-article-
  adjective), the second article instead has relatedtoken1 -> the
  id of that adjective, relationship1 = 'article' -- and the
  adjective itself still gets the ordinary 'attributive' relation
  to the noun (see below). Example: in 'εἵλου τοιοῦτον ἁμάρτημα
  ἐξαμαρτάνειν εἰς τὴν γυναῖκα τὴν ἐμήν', the first τήν has
  relatedtoken1 -> γυναῖκα, relationship1 'article'; the second
  τήν has relatedtoken1 -> ἐμήν, relationship1 'article'; and ἐμήν
  has relatedtoken1 -> γυναῖκα, relationship1 'attributive'. (If
  the adjective instead stood between article and noun with no
  second article, e.g. 'τὴν ἐμὴν γυναῖκα', the single τήν and ἐμήν
  keep exactly those same relations -- there is simply no second
  article token to add.)
- attributive: an adjective in attributive position, a participle
  in attributive position AS AN ORDINARY MODIFIER of a noun
  distinct from its own verbal-expression relation (see the
  'attributive participle' TODO above), or a prepositional phrase
  modifying a noun, has relatedtoken1 -> the noun's id,
  relationship1 = 'attributive'. Example (adjective): ἐμήν in the
  εἵλου example above. Example (prepositional phrase):
  # TODO: syntax_model.md's own worked example here ("attributive
  # to a noun: in the phrase pugna ad Cannas...") is still the
  # untranslated Latin example, apparently left over when the
  # document was adapted for Greek. Substituting a constructed
  # Greek example instead: in 'ἡ μάχη ἡ ἐν Μαραθῶνι', ἐν has
  # relatedtoken1 -> μάχη, relationship1 'attributive', and
  # Μαραθῶνι has relatedtoken1 -> ἐν, relationship1 'object of
  # preposition'. An adjective used as a substantive (standing in
  # for a noun) is treated as a noun/pronoun instead, not as
  # attributive. Example: in 'ἐκείνη μὲν ἀπηλλάγη', ἐκείνη has
  # relatedtoken1 -> ἀπηλλάγη, relationship1 'subject' (not
  # 'attributive' or 'demonstrative' -- it stands for a noun
  # here, it does not modify one).
- demonstrative: a demonstrative pronoun modifying a noun -- unlike
  an ordinary adjective, NOT in attributive position -- has
  relatedtoken1 -> the noun's id, relationship1 = 'demonstrative'.
  Example: in 'ταύτην ἔλαβον τὴν δίκην', ταύτην has relatedtoken1
  -> δίκην, relationship1 'demonstrative'.
- adverbial (bare adverb): an adverb modifying a verb has
  relatedtoken1 -> the verb's id, relationship1 = 'adverbial'.
  Example: in 'διαρρήδην εἴρηται', διαρρήδην has relatedtoken1 ->
  εἴρηται, relationship1 'adverbial'. An adverb can also stand in
  attributive position modifying a noun -- same relationship1
  value either way, just a noun instead of a verb on the other
  end. Example: in 'δᾷδας λαβόντες ἐκ τοῦ ἐγγύτατα καπηλείου
  εἰσερχόμεθα', ἐγγύτατα has relatedtoken1 -> καπηλείου,
  relationship1 'adverbial'.
- prepositional phrases: the preposition has relatedtoken1 -> the
  id of the verb (adverbial) or noun (attributive) it modifies,
  relationship1 = 'adverbial' or 'attributive'. The noun/pronoun
  it governs has relatedtoken1 -> the id of the preposition,
  relationship1 = 'object of preposition' (or relatedtoken2/
  relationship2 if relatedtoken1 is already used for a
  relative-pronoun link). Example: in 'γυναῖκα ἠγαγόμην εἰς τὴν
  οἰκίαν', εἰς has relatedtoken1 -> ἠγαγόμην, relationship1
  'adverbial', and οἰκίαν has relatedtoken1 -> εἰς, relationship1
  'object of preposition'.
- genitive: a noun or pronoun in the genitive that modifies
  ANOTHER NOUN -- and isn't already covered by a more specific
  relation above (object of preposition, genitive absolute, etc)
  -- has relatedtoken1 -> the id of that noun, relationship1 =
  'genitive'. This is purely a syntactic (case-function) label,
  not a semantic one -- don't distinguish e.g. possessive vs.
  partitive genitive. Example: in 'ᾤχετο εἰς τὸ ἱερὸν μετὰ τῆς
  μητρὸς τῆς ἐκείνου', ἐκείνου has relatedtoken1 -> μητρός,
  relationship1 'genitive'.
- dative / accusative: a noun in the dative or accusative case
  that depends on a verb or another noun -- and isn't already
  covered by a more specific relation above (subject, direct
  object, object of preposition, etc) -- has relatedtoken1 -> the
  id of the verb or noun it depends on, relationship1 = the
  matching case name ('dative' or 'accusative'). Example (dative,
  linked to a verb): in 'οὔτε ἔχθρα ἐμοὶ καὶ ἐκείνῳ οὐδεμία ἦν
  πλὴν ταύτης, οὔτε χρημάτων ἕνεκα ἔπραξα ταῦτα', both ἐμοί and
  ἐκείνῳ have relatedtoken1 -> ἦν, relationship1 'dative'. Example
  (accusative of extent of time, linked to a verb): in 'ταῦτα
  πολὺν χρόνον οὕτως ἐγίγνετο', χρόνον has relatedtoken1 ->
  ἐγίγνετο, relationship1 'accusative'. Note that Greek has NO
  ablative case and NO dedicated relation label for one -- a
  Latin-scheme 'ablative' relation simply does not arise here.
- vocative: a noun in the vocative case (direct address) has
  relatedtoken1 -> the id of the verb of the clause it's addressed
  within, relationship1 = 'vocative'. Unlike 'genitive'/'dative'/
  'accusative' above, a vocative relates to a verb only, never to
  another noun. Example: in 'ἐγὼ μὲν οὖν, ὦ ἄνδρες, οὐκ ἰδίαν ὑπὲρ
  ἐμαυτοῦ νομίζω ταύτην γενέσθαι τὴν τιμωρίαν', ἄνδρες has
  relatedtoken1 -> νομίζω, relationship1 'vocative'.
- apposition: when one noun stands in apposition to another, the
  appositive has relatedtoken1 -> the id of the first (the noun it
  restates or further identifies), relationship1 = 'apposition'. A
  genitive depending on either noun still gets its own ordinary
  'genitive' relation, pointing at whichever noun it actually
  depends on -- apposition doesn't change that.
  # TODO: syntax_model.md gives only the general definition here,
  # no worked Greek example. Constructed illustration: in
  # 'Δημοσθένης ὁ ῥήτωρ ἦλθεν', ῥήτωρ has relatedtoken1 ->
  # Δημοσθένης, relationship1 'apposition' (and ὁ has
  # relatedtoken1 -> ῥήτωρ, relationship1 'article').
- exclamation: an exclamatory word has relatedtoken1 -> the id of
  the verb of its own verbal unit, relationship1 = 'exclamation' --
  EXCEPT the frequent exclamatory particle ὦ introducing a
  vocative, which instead has relatedtoken1 -> the id of the
  vocative noun/pronoun it introduces (not the verb directly).
  Example: in 'ἐγὼ μὲν οὖν, ὦ ἄνδρες, οὐκ ἰδίαν ὑπὲρ ἐμαυτοῦ
  νομίζω ταύτην γενέσθαι τὴν τιμωρίαν, ἀλλ' ὑπὲρ τῆς πόλεως
  ἁπάσης', ὦ has relatedtoken1 -> ἄνδρες (the vocative it
  introduces), relationship1 'exclamation' -- NOT relatedtoken1 ->
  νομίζω directly, even though ἄνδρες's own relatedtoken1 does
  point to νομίζω (relationship1 'vocative'). This same pattern
  applies wherever ὦ introduces a vocative elsewhere in a passage,
  e.g. 'πρῶτον μὲν οὖν, ὦ ἄνδρες, ...': ὦ -> ἄνδρες, 'exclamation'.

Only assign relations described above. Leave relatedtoken/
relationship fields unset for tokens with no relation of these
kinds -- not every token will have one (e.g. a bare accusative of
respect not covered above). 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. grammatike recognizes two DIFFERENT situations where a verbal expression exists grammatically but has no surface realization in the passage at all -- rather than skip these, add a NEW entry to tokengraph (and a matching new entry to verbalunits, since an implied token always anchors its own verbal expression) with: a brand-new id, not used by any entry in tokens or elsewhere in your own output (see the naming rule below); the matching tokentype below; and no token value (leave it unset/None) -- these go together, and 'implied eimi' / 'implied repetition' are the ONLY two tokentype values whose id isn't one of tokens' own ids and whose token is empty.

- tokentype 'implied eimi': an elided form of εἰμί ('to be') in a
  predicate expression. The documented case is an implied
  INFINITIVE of εἰμί inside indirect statement: the implied token
  anchors a verbal expression classified 'indirect statement' and
  'linking verb', relates to its governing verb of thinking/saying
  via relatedtoken1/relationship1 = 'indirect statement' exactly
  as a written-out infinitive would, and the subject/predicate of
  the predication relate to it as 'subject'/'predicate' exactly as
  they would to any linking verb. Example: 'ταύτην τὴν ὕβριν
  ἅπαντες ἄνθρωποι δεινοτάτην ἡγοῦνται' has an independent verbal
  expression ἡγοῦνται governing an implied infinitive of εἰμί
  (syntactic type 'indirect statement', semantic type 'linking
  verb') whose relatedtoken1 -> ἡγοῦνται, relationship1 'indirect
  statement'; 'ταύτην τὴν ὕβριν' relates to it as 'subject' and
  δεινοτάτην as 'predicate'.
  # TODO: the two sub-cases below extrapolate from that one
  # documented example and from the general phrasing "elided εἰμί
  # in predicate expressions" -- syntax_model.md gives no worked
  # example for either:
    - a bare predicate construction with NO governing verb at all
      (subject + predicate noun/adjective, nothing else): the
      implied token anchors a verbal expression classified
      'independent' (or 'dependent', if the elided-εἰμί clause is
      itself subordinate) and 'linking verb'; subject and
      predicate relate to it exactly as they would to any linking
      verb.
    - an omitted conjugated εἰμί in a compound perfect-system form
      (the participle left standing alone for its auxiliary): the
      implied token stands in for the omitted form of εἰμί --
      everything that would normally relate to that auxiliary
      (subject, the participle's own 'auxiliary' relation, etc.)
      relates to the implied token instead, exactly as if the
      auxiliary had been written out.
- tokentype 'implied repetition': a verb elided from a later
  verbal expression in a coordinated series because it repeats the
  verb of an earlier one. Add ONE implied token per omitted
  repeated verb, repeating that verb's OWN syntactic_type and
  semantic_type exactly (whatever those happen to be in context --
  not necessarily 'independent'/'intransitive'), and give whatever
  would relate to the omitted verb (subject, adverbial, a
  connecting word, etc.) its normal relation into the implied
  token instead, exactly as if the verb had been repeated.
  Example: 'ἐγὼ μὲν ἄνω διῃτώμην, αἱ δὲ γυναῖκες κάτω' has an
  explicit verbal expression διῃτώμην ('independent'/
  'intransitive') with subject ἐγώ, and a second, implied verbal
  expression (tokentype 'implied repetition') repeating
  διῃτώμην's own 'independent'/'intransitive' classification, with
  subject γυναῖκες and adverbial κάτω relating to the implied
  token instead of to διῃτώμην.

Naming an implied token's id (both tokentypes): append '_implied'
to the id of the LAST real token in `tokens` that precedes where
the elided word 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 at the same position, 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 -- this
keeps it grouped with the rest of its verbal expression for
anything that reads `tokengraph` in order.
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 Ancient Greek according to a\ntwo-part scheme:\n\n(1) a list of verbal expressions. Three constructions count as a\n verbal expression: finite verbs, infinitives, and participles --\n but for participles, only some of them (see below).\n\n - A finite verb (including a compound perfect-system form made of\n a participle plus a conjugated form of εἰμί, e.g. ὁ νόμος\n γεγραμμένος ἐστίν) is always a verbal expression. Classify its\n syntactic type as \'independent\' (main/principal), \'dependent\'\n (subordinate, introduced by a subordinating word), \'direct\n quote\' (occurring in directly quoted speech framed by another\n verb, e.g. νόμιζε in \'"εὐφίλητε" ἔφη "μηδεμιᾷ πολυπραγμοσύνῃ\n προσεληλυθέναι με νόμιζε πρὸς σέ."\'), or \'aside\' (a verbal\n expression that interrupts the surrounding syntax, e.g. δεῖ in\n \'πρῶτον μὲν οὖν, ὦ ἄνδρες, (δεῖ γὰρ καὶ ταῦθ᾽ ὑμῖν διηγήσασθαι)\n οἰκίδιον ἔστι μοι διπλοῦν\' interrupting the independent verbal\n expression ἔστι). Example of independent vs. dependent: in\n \'ἐπειδὴ δὲ ἦν πρὸς ἡμέραν, ἧκεν ἐκείνη\', ἧκεν is \'independent\'\n and ἦν is \'dependent\' (introduced by the subordinating\n conjunction ἐπειδή).\n - An infinitive is a verbal expression only when part of an\n indirect statement; its syntactic type is always \'indirect\n statement\'. Example: in \'ἔφασκε τὸν λύχνον ἀποσβεσθῆναι\', ἔφασκε\n is independent and ἀποσβεσθῆναι anchors the indirect-statement\n verbal expression. In a compound perfect-system form (participle\n + a conjugated form of εἰμί), the form of εἰμί anchors the\n verbal expression, same as any other compound form.\n - A participle constitutes a verbal expression in THREE cases,\n each with its own dedicated syntactic_type -- unlike Latin,\n which uses a single \'dependent\' value for every predicate-sense\n participle, Greek\'s scheme gives each its own name:\n - \'indirect statement\': a participle expressing indirect\n speech after a verb of perception or thinking. Example: in\n \'εἶδε δὲ τὴν βασίλειαν φεύγουσαν\', εἶδε is independent and\n φεύγουσαν (not an infinitive here) anchors the\n indirect-statement verbal expression.\n - \'attributive\': a participle in attributive position.\n Example: in \'ὁ γὰρ ἀνὴρ ὁ ὑβρίζων εἰς σὲ ἐχθρὸς ὢν ἡμῖν\n τυγχάνει\', the repeated article puts ὑβρίζων in attributive\n position with ἀνήρ, so ὑβρίζων anchors an \'attributive\'\n verbal expression. In Greek, UNLIKE Latin, every attributive\n participle counts as its own verbal expression -- there is\n no purely-adjectival, non-verbal-expression reading for an\n attributive participle the way Latin\'s "consentiens laus"\n was not a verbal expression at all.\n - \'circumstantial\': a participle in circumstantial position\n (including one forming a genitive absolute). Example: in\n \'χρόνου μεταξὺ διαγενομένου, προσέρχεταί μοί τις πρεσβῦτις\n ἄνθρωπος\', προσέρχεταί is independent and διαγενομένου\n anchors a \'circumstantial\' verbal expression.\n By contrast, a *supplementary* participle -- one that completes\n the sense of its governing verb as a single predicate idea\n (e.g. with τυγχάνω, λανθάνω, φαίνομαι, παύομαι, or the like),\n rather than standing attributively with a noun or\n circumstantially/adverbially to the clause -- is explicitly NOT\n a verbal expression and gets no `verbalunits` entry at all.\n Example: in that same sentence \'ὁ γὰρ ἀνὴρ ὁ ὑβρίζων εἰς σὲ\n ἐχθρὸς ὢν ἡμῖν τυγχάνει\', ὤν supplements τυγχάνει (there is a\n single independent verbal expression, anchored to τυγχάνει) and\n is NOT its own verbal expression. Do not over-generate\n verbal-expression entries for participles: check first whether a\n participle is genuinely attributive (repeated article, or\n agreeing with a noun as its ordinary modifier), genuinely\n circumstantial (an adverbial predication about a noun, loosely\n attached to the clause), or genuinely reporting indirect\n perception -- and only then give it a `verbalunits` entry; a\n participle that instead completes one predicate idea together\n with a governing verb like τυγχάνω does not.\n # TODO: syntax_model.md does not name a RelationLabel for a\n # supplementary participle\'s own relation to its governing verb\n # (no "supplementary" or "complementary participle" value\n # exists). This port leaves such a participle\'s own\n # relatedtoken1/relationship1 unset -- no documented label\n # fits -- while still letting it take its own predicate/\n # object/adverbial complements exactly as a linking verb would\n # (e.g. ἐχθρός, the predicate adjective of ὤν in the example\n # above, still relates to ὤν with relationship1 \'predicate\',\n # exactly as it would to a finite linking verb).\n\n Classify each verbal expression\'s semantic type too (transitive\n active / transitive passive / intransitive / linking verb).\n Examples: προσεῖχον in \'προσεῖχον τὸν νοῦν\' is transitive active;\n διαφθείρεται in \'ἡ ἐμὴ γυνὴ ὑπὸ τούτου τοῦ ἀνθρώπου διαφθείρεται\'\n is transitive passive; εἰσῄει in \'πάντα μου εἰς τὴν γνώμην\n εἰσῄει\' is intransitive; ἦ in \'μεστὸς ἦ ὑποψίας\' is a linking\n 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 has relatedtoken1\n = the special sentinel string \'root\' -- never an actual token id;\n no real token may be assigned the id \'root\' -- and relationship1\n = \'unit verb\'. Example: in \'τὴν θύραν ἀνέῳξεν\', ἀνέῳξεν has\n relatedtoken1 \'root\', relationship1 \'unit verb\'.\n - unit verb (dependent) / subordinating conjunction / relative\n pronoun: the verb of a DEPENDENT clause has relatedtoken1 -> the\n id of its subordinating conjunction or relative/interrogative\n pronoun, relationship1 = \'unit verb\'. That conjunction or pronoun\n in turn has relatedtoken1 -> the id of the verb of the clause it\n is subordinate to, with relationship1 = \'subordinating\n conjunction\' for a conjunction, or relatedtoken1 -> its\n antecedent\'s id with relationship1 = \'relative pronoun\' for a\n relative pronoun. Example: in \'ἐπειδὴ δὲ ἦν πρὸς ἡμέραν, ἧκεν\n ἐκείνη\', ἐπειδή has relatedtoken1 -> ἧκεν, relationship1\n \'subordinating conjunction\', and ἦν has relatedtoken1 ->\n ἐπειδή, relationship1 \'unit verb\'. Another example, with a\n conjunction: in \'κατηγόρει ὡς μετὰ τὴν ἐκφορὰν αὐτῇ προσίοι\',\n ὡς has relatedtoken1 -> κατηγόρει, relationship1 \'subordinating\n conjunction\', and προσίοι has relatedtoken1 -> ὡς, relationship1\n \'unit verb\'. Indirect questions are treated as a kind of\n dependent clause: an interrogative pronoun introducing one is\n treated the same way as a subordinating conjunction -- it has\n relatedtoken1 -> the id of the verb it introduces, relationship1\n = \'subordinating conjunction\' (no separate label for this case)\n -- while the dependent verb itself has relatedtoken1 -> the\n interrogative word\'s id, relationship1 = \'unit verb\', exactly\n like any other dependent clause.\n # TODO: syntax_model.md states this indirect-question rule only\n # in passing ("a subordinating conjunction or a relative or\n # interrogative pronoun") with no worked example; the following\n # is constructed by analogy, not quoted from syntax_model.md:\n # in \'οὐκ οἶδα τίς ἦλθεν\' ("I don\'t know who came"), τίς has\n # relatedtoken1 -> οἶδα, relationship1 \'subordinating\n # conjunction\', and ἦλθεν has relatedtoken1 -> τίς, relationship1\n # \'unit verb\'.\n - relative pronoun (second relation): a relative pronoun ALSO\n relates to its own function inside the relative clause, using\n relatedtoken2/relationship2 (since relatedtoken1/relationship1 is\n already used for the antecedent link) -- the ordinary relation\n it would have if it were any other noun/pronoun in that clause\n (e.g. \'direct object\', \'subject\', a case relation, etc). Example:\n in \'οὐκ ἐγώ σε ἀποκτενῶ, ἀλλ᾽ ὁ τῆς πόλεως νόμος, ὃν σὺ περὶ\n ἐλάττονος τῶν ἡδονῶν ἐποιήσω\', ὅν has relatedtoken1 -> νόμος\n (its antecedent), relationship1 \'relative pronoun\', AND\n relatedtoken2 -> ἐποιήσω, relationship2 \'direct object\'.\n - indirect statement (governing verb): an infinitive OR participle\n anchoring an indirect-statement verbal expression ALSO has\n relatedtoken1 -> the id of the verb that governs the indirect\n statement (the verb of saying/thinking/perceiving it depends\n on), relationship1 = \'indirect statement\' -- matching its own\n syntactic type, the same convention \'direct quote\' and \'aside\'\n verbal expressions use below. There\'s no separate\n subordinating-word token to point at first, so the infinitive or\n participle points directly at its governing verb, rather than\n via a conjunction/pronoun intermediary the way a dependent\n finite verb\'s \'unit verb\' relation does. Examples: in\n \'ἔφασκε τὸν λύχνον ἀποσβεσθῆναι\', ἀποσβεσθῆναι has relatedtoken1\n -> ἔφασκε, relationship1 \'indirect statement\'; in \'εἶδε δὲ τὴν\n βασίλειαν φεύγουσαν\', φεύγουσαν has relatedtoken1 -> εἶδε,\n relationship1 \'indirect statement\'. In a compound perfect-system\n form, this relation belongs on the conjugated form of εἰμί that\n anchors the verbal expression, same as any other relation into\n it.\n - complementary infinitive: an infinitive that completes the sense\n of a governing verb like βούλομαι, δεῖ, or ἐθέλω (rather than\n reporting indirect speech) has relatedtoken1 -> the id of that\n governing verb, relationship1 = \'complementary infinitive\'.\n Unlike an indirect-statement infinitive, this does NOT make the\n infinitive its own verbal expression -- it gets no `verbalunits`\n entry of its own; the governing verb is still the only verbal\n expression here. Example: in \'ἔξεστι ἑλέσθαι\', ἑλέσθαι has\n relatedtoken1 -> ἔξεστι, relationship1 \'complementary\n infinitive\'.\n - modal particle: the particle ἄν has relatedtoken1 -> the id of\n the verb of ITS OWN verbal unit (not some other unit\'s verb),\n relationship1 = \'modal particle\'. Example: in \'εἰ τὴν αὐτὴν\n γνώμην περὶ τῶν ἄλλων ἔχοιτε, οὐκ ἂν εἴη, ὅστις οὐκ ἐπὶ τοῖς\n γεγενημένοις ἀγανακτοίη\' (two dependent verbal expressions plus\n one independent verbal expression anchored to εἴη), ἂν has\n relatedtoken1 -> εἴη, relationship1 \'modal particle\' -- εἴη is\n ἂν\'s own verbal unit\'s verb, the same verb οὐκ (adverbial) also\n relates to.\n - infinitive used as a noun: an infinitive can also function as an\n ordinary noun -- most often a verb\'s subject or object -- rather\n than anchoring an indirect statement or completing another verb.\n Treat it exactly like any other noun in that role: relatedtoken1\n -> the verb it\'s the subject/object of, relationship1 =\n \'subject\' or \'direct object\' as appropriate (no dedicated label,\n and again no `verbalunits` entry of its own). If the infinitive\n carries a definite article (an articular infinitive, e.g. τὸ\n ζῆν), that article relates to the infinitive exactly as it would\n to a substantivized adjective or adverb: relatedtoken1 -> the\n infinitive\'s id, relationship1 \'article\'. Like any verbal form,\n an infinitive used this way can still take its own object or\n adverb, related to it the same way they\'d relate to a finite\n verb.\n # TODO: syntax_model.md does not discuss this construction at\n # all; it is carried over from arsgrammatica\'s equivalent\n # section by direct analogy, since substantival infinitives\n # (often articular) are common in Greek too. No example here is\n # quoted from syntax_model.md.\n - sentence connector: true asyndeton is rare at the root level of\n a sentence -- there is normally a connecting word expressing the\n relation of the sentence to its predecessor. This connecting\n word has relatedtoken1 -> the verb of THIS sentence (not the\n previous one), relationship1 = \'sentence connector\'. Example: in\n \'ταύτην γὰρ ἐμαυτῷ μόνην ἡγοῦμαι σωτηρίαν\', γάρ has relatedtoken1\n -> ἡγοῦμαι, relationship1 \'sentence connector\'. The particle μέν\n begins a list of items, continued by δέ -- ordinarily WITHIN one\n sentence (see \'connecting word\' below), but when the items are\n instead split across distinct, separately terminated sentences,\n μέν or δέ is a \'sentence connector\' too, exactly like γάρ, rather\n than a \'connecting word\': it has relatedtoken1 -> the verb of\n ITS OWN sentence, relationship1 \'sentence connector\', with no\n relation at all to the other sentence\'s verb (a sentence\n connector never records a cross-sentence link -- only "this\n sentence\'s own verb"). Examples: in the complete, terminated\n sentence \'περὶ μὲν οὖν τοῦ μεγέθους τῆς ζημίας ἅπαντας ὑμᾶς\n νομίζω τὴν αὐτὴν διάνοιαν ἔχειν, καὶ οὐδένα οὕτως ὀλιγώρως\n διακεῖσθαι, ὅστις οἴεται δεῖν συγγνώμης τυγχάνειν ἢ μικρᾶς\n ζημίας ἀξίους ἡγεῖται τοὺς τῶν τοιούτων ἔργων αἰτίους.\', μέν has\n relatedtoken1 -> νομίζω, relationship1 \'sentence connector\'; in\n the following, separate sentence \'ἡγοῦμαι δέ, ὦ ἄνδρες, τοῦτό με\n δεῖν ἐπιδεῖξαι.\', δέ has relatedtoken1 -> ἡγοῦμαι, relationship1\n \'sentence connector\'.\n - connecting word: when a connecting word (coordinating conjunction\n or connecting particle, e.g. καί, ἀλλά, τε, μέν, δέ, οὔτε) joins\n a pair or series of nouns, adjectives, adverbs, or whole clauses\n WITHIN a sentence (as opposed to linking one sentence to the\n previous one -- see \'sentence connector\' above), it uses\n relationship1 = \'connecting word\', in one of three shapes:\n - a SINGLE connecting word joining a pair: relatedtoken1 -> the\n id of the FIRST connected item, relatedtoken2 -> the id of\n the SECOND connected item, relationship2 = \'connecting word\'\n too (both fields on the one connecting-word token). Examples:\n in \'ἐπιτηρῶν γὰρ τὴν θεράπαιναν τὴν εἰς τὴν ἀγορὰν\n βαδίζουσαν καὶ λόγους προσφέρων ἀπώλεσεν αὐτήν\', καί joins\n the participles ἐπιτηρῶν and προσφέρων: relatedtoken1 ->\n ἐπιτηρῶν, relatedtoken2 -> προσφέρων. In \'ἐγὼ τοίνυν ἐξ\n ἀρχῆς ὑμῖν ἅπαντα ἐπιδείξω τὰ ἐμαυτοῦ πράγματα, οὐδὲν\n παραλείπων, ἀλλὰ λέγων τἀληθῆ\', ἀλλά joins the participles\n παραλείπων and λέγων the same way. In \'οἰκίδιον ἔστι μοι\n διπλοῦν, ἴσα ἔχον τὰ ἄνω τοῖς κάτω κατὰ τὴν γυναικωνῖτιν καὶ\n κατὰ τὴν ἀνδρωνῖτιν\', καί joins the two prepositional\n phrases\' own prepositions (the first and second κατά).\n - a PAIRED correlative (e.g. postpositive τε...καί, or a\n repeated καὶ...καί, or -- within a single sentence -- μέν\n continued by δέ): each of the two connecting words has\n relatedtoken1 -> ITS OWN adjacent connected item (not "the\n first item" generically -- whichever item that particular\n connector itself sits next to), and relatedtoken2 -> the id\n of the OTHER connecting word (not another connected item).\n Example: in \'ἐφύλαττόν τε καὶ προσεῖχον τὸν νοῦν\', τε and\n καί join the verbal expressions ἐφύλαττον and προσεῖχον: τε\n has relatedtoken1 -> ἐφύλαττον, relatedtoken2 -> καί\n (relationship2 \'connecting word\' too); καί has relatedtoken1\n -> προσεῖχον, relatedtoken2 -> τε. Example (repeated καί):\n in \'περὶ τούτου γὰρ μόνου τοῦ ἀδικήματος καὶ ἐν δημοκρατίᾳ\n καὶ ὀλιγαρχίᾳ ἡ αὐτὴ τιμωρία τοῖς ἀσθενεστάτοις πρὸς τοὺς τὰ\n μέγιστα δυναμένους ἀποδέδοται\', the first καί has\n relatedtoken1 -> δημοκρατίᾳ, relatedtoken2 -> the second\n καί; the second καί has relatedtoken1 -> ὀλιγαρχίᾳ,\n relatedtoken2 -> the first καί. Example (μέν...δέ within one\n sentence, not split across sentences -- contrast \'sentence\n connector\' above): in \'ἐγὼ μὲν ἄνω διῃτώμην, αἱ δὲ γυναῖκες\n κάτω\', μέν has relatedtoken1 -> διῃτώμην (its own,\n first clause\'s verb), relatedtoken2 -> δέ; δέ has\n relatedtoken1 -> the second clause\'s own verb (here the\n implied-repetition token standing in for the elided verb --\n see \'implied repetition\' below), relatedtoken2 -> μέν.\n - a SERIES of 3 or more connected items: every connecting word\n still has relatedtoken1 -> ITS OWN adjacent item, same as the\n paired case; relatedtoken2 chains the series together --\n the FIRST connecting word\'s relatedtoken2 -> the SECOND\n connecting word\'s id (forward), and every LATER connecting\n word\'s relatedtoken2 -> the id of the connecting word\n immediately BEFORE it (backward) -- so the whole series is\n still traceable by following relatedtoken2 links between the\n connecting-word tokens, even though no single token names\n every member. Example: in \'οὔτε γὰρ συκοφαντῶν γραφάς με\n ἐγράψατο, οὔτε ἐκβάλλειν ἐκ τῆς πόλεως ἐπεχείρησεν, οὔτε\n ἰδίας δίκας ἐδικάζετο.\', the first οὔτε has relatedtoken1 ->\n ἐγράψατο, relatedtoken2 -> the second οὔτε; the second οὔτε\n has relatedtoken1 -> ἐπεχείρησεν, relatedtoken2 -> the first\n οὔτε; the third οὔτε has relatedtoken1 -> ἐδικάζετο,\n relatedtoken2 -> the second οὔτε. The same pattern can occur\n with μέν starting a series and δέ continuing it.\n # TODO: syntax_model.md does not discuss καί\'s double life as\n # connective ("and") vs. adverb ("also"/"even"), unlike Latin\'s\n # explicit treatment of \'et\'. By analogy: when καί modifies a\n # single word rather than joining two, treat it like any other\n # adverb instead -- relatedtoken1 -> the verb (or nearest token)\n # it emphasizes, relationship1 \'adverbial\', not \'connecting\n # word\'. This guidance is an extrapolation, not sanctioned by a\n # syntax_model.md example.\n - direct quote / aside: a verbal expression of syntactic type\n \'direct quote\' or \'aside\' has relatedtoken1 -> the id of the verb\n of the clause it interrupts or is framed by, relationship1 =\n \'direct quote\' or \'aside\' respectively (matching its syntactic\n type). Examples above under (1).\n - circumstantial participle / genitive absolute: a circumstantial\n participial verbal expression\'s own relatedtoken1 -> the id of\n the noun or pronoun it agrees with, relationship1 =\n \'circumstantial participle\'. That noun in turn: if it also fits\n a normal role in the surrounding clause (e.g. it\'s already the\n main verb\'s subject), it takes THAT normal relation instead\n (nothing extra to add). Example: in \'ἐγὼ ἅπαντα ἐπιδείξω τὰ\n ἐμαυτοῦ πράγματα, οὐδὲν παραλείπων, ἀλλὰ λέγων τἀληθῆ\', both\n παραλείπων and λέγων have relatedtoken1 -> ἐγώ, relationship1\n \'circumstantial participle\', and ἐγώ (already the subject of\n ἐπιδείξω) has relatedtoken1 -> ἐπιδείξω, relationship1\n \'subject\' -- nothing further added for the participles. If the\n noun is a GENITIVE with no other syntactic connection to the\n sentence (a genitive absolute), it instead has relatedtoken1 ->\n the id of the main verb, relationship1 = \'genitive absolute\'.\n Example: in \'προϊόντος δὲ τοῦ χρόνου ἧκον μὲν ἀπροσδοκήτως ἐξ\n ἀγροῦ\', προϊόντος has relatedtoken1 -> χρόνου, relationship1\n \'circumstantial participle\', and χρόνου in turn has\n relatedtoken1 -> ἧκον, relationship1 \'genitive absolute\'.\n - attributive participle: an attributive participial verbal\n expression\'s own relatedtoken1 -> the id of the noun or pronoun\n it agrees with, relationship1 = \'attributive participle\'.\n Example: in \'ὁ γὰρ ἀνὴρ ὁ ὑβρίζων εἰς σὲ ἐχθρὸς ὢν ἡμῖν\n τυγχάνει\', ὑβρίζων has relatedtoken1 -> ἀνήρ, relationship1\n \'attributive participle\'.\n # TODO: models.py\'s RelationLabel comment block also glosses\n # "attributive" itself as covering "participle-in-attributive-\n # position", alongside the dedicated "attributive participle"\n # value documented above and in syntax_model.md\'s own worked\n # example. Since Greek (unlike Latin) makes EVERY attributive\n # participle its own verbal expression, this port resolves the\n # overlap by always using \'attributive participle\' for an\n # attributive participle\'s relation to its noun, and reserving\n # plain \'attributive\' for ordinary adjectives and prepositional\n # phrases (see below) -- flagged here as an unresolved tension\n # in the fixed contract, not something syntax_model.md itself\n # disambiguates.\n - auxiliary: in a compound perfect-system verb form (participle +\n a conjugated form of εἰμί), the form of εἰμί anchors the verbal\n expression and is the target of every relation into it (subject,\n direct object, agent, etc); the participle itself has\n relatedtoken1 -> the id of that form of εἰμί, relationship1 =\n \'auxiliary\'. Example: in \'ὁ νόμος γεγραμμένος ἐστίν\', γεγραμμένος\n has relatedtoken1 -> ἐστίν, relationship1 \'auxiliary\'.\n - agent: the preposition ὑπό introducing the agent of a passive\n verb has relatedtoken1 -> the passive verb\'s id (the id of the\n form of εἰμί, for a compound form), relationship1 = \'agent\'. The\n noun/pronoun governed by that ὑπό has relatedtoken1 -> the id of\n ὑπό, relationship1 = \'object of preposition\'. Example: in \'ἡ ἐμὴ\n γυνὴ ὑπὸ τούτου τοῦ ἀνθρώπου διαφθείρεται\', ὑπό has relatedtoken1\n -> διαφθείρεται, relationship1 \'agent\', and ἀνθρώπου has\n relatedtoken1 -> ὑπό, relationship1 \'object of preposition\'.\n - subject / direct object / predicate: a noun or pronoun serving\n as subject or direct object has relatedtoken1 -> the id of the\n verb (the id of the form of εἰμί, for a compound form),\n relationship1 = \'subject\' or \'direct object\'. This applies to\n the accusative subject of an infinitive in indirect statement\n too. Examples: in \'ἐμοίχευεν Ἐρατοσθένης τὴν γυναῖκα τὴν ἐμήν\',\n Ἐρατοσθένης has relatedtoken1 -> ἐμοίχευεν, relationship1\n \'subject\', and γυναῖκα has relatedtoken1 -> ἐμοίχευεν,\n relationship1 \'direct object\'. A noun, pronoun, or adjective\n serving as the predicate complement of a LINKING verb uses\n relationship1 = \'predicate\' instead, same relatedtoken1 target.\n Example: in \'ᾤμην τὴν ἐμαυτοῦ γυναῖκα πασῶν σωφρονεστάτην εἶναι\n τῶν ἐν τῇ πόλει\', εἶναι anchors an \'indirect statement\'/\'linking\n verb\' verbal expression governed by ᾤμην; γυναῖκα is its subject\n (relatedtoken1 -> εἶναι, relationship1 \'subject\') and\n σωφρονεστάτην is its predicate adjective (relatedtoken1 ->\n εἶναι, relationship1 \'predicate\'). If the token is a relative\n pronoun already using relatedtoken1/relationship1 for its\n antecedent link, put this relation in relatedtoken2/\n relationship2 instead (see \'relative pronoun\' above).\n - article: the definite article relates to the noun (or\n substantivized adjective, adverb, or infinitive) it accompanies:\n relatedtoken1 -> that word\'s id, relationship1 = \'article\'.\n Example: in \'τῷ χρόνῳ πεισθείη\', τῷ has relatedtoken1 -> χρόνῳ,\n relationship1 \'article\'. When an adjective is in attributive\n position with a REPEATED article (article-noun-article-\n adjective), the second article instead has relatedtoken1 -> the\n id of that adjective, relationship1 = \'article\' -- and the\n adjective itself still gets the ordinary \'attributive\' relation\n to the noun (see below). Example: in \'εἵλου τοιοῦτον ἁμάρτημα\n ἐξαμαρτάνειν εἰς τὴν γυναῖκα τὴν ἐμήν\', the first τήν has\n relatedtoken1 -> γυναῖκα, relationship1 \'article\'; the second\n τήν has relatedtoken1 -> ἐμήν, relationship1 \'article\'; and ἐμήν\n has relatedtoken1 -> γυναῖκα, relationship1 \'attributive\'. (If\n the adjective instead stood between article and noun with no\n second article, e.g. \'τὴν ἐμὴν γυναῖκα\', the single τήν and ἐμήν\n keep exactly those same relations -- there is simply no second\n article token to add.)\n - attributive: an adjective in attributive position, a participle\n in attributive position AS AN ORDINARY MODIFIER of a noun\n distinct from its own verbal-expression relation (see the\n \'attributive participle\' TODO above), or a prepositional phrase\n modifying a noun, has relatedtoken1 -> the noun\'s id,\n relationship1 = \'attributive\'. Example (adjective): ἐμήν in the\n εἵλου example above. Example (prepositional phrase):\n # TODO: syntax_model.md\'s own worked example here ("attributive\n # to a noun: in the phrase pugna ad Cannas...") is still the\n # untranslated Latin example, apparently left over when the\n # document was adapted for Greek. Substituting a constructed\n # Greek example instead: in \'ἡ μάχη ἡ ἐν Μαραθῶνι\', ἐν has\n # relatedtoken1 -> μάχη, relationship1 \'attributive\', and\n # Μαραθῶνι has relatedtoken1 -> ἐν, relationship1 \'object of\n # preposition\'. An adjective used as a substantive (standing in\n # for a noun) is treated as a noun/pronoun instead, not as\n # attributive. Example: in \'ἐκείνη μὲν ἀπηλλάγη\', ἐκείνη has\n # relatedtoken1 -> ἀπηλλάγη, relationship1 \'subject\' (not\n # \'attributive\' or \'demonstrative\' -- it stands for a noun\n # here, it does not modify one).\n - demonstrative: a demonstrative pronoun modifying a noun -- unlike\n an ordinary adjective, NOT in attributive position -- has\n relatedtoken1 -> the noun\'s id, relationship1 = \'demonstrative\'.\n Example: in \'ταύτην ἔλαβον τὴν δίκην\', ταύτην has relatedtoken1\n -> δίκην, relationship1 \'demonstrative\'.\n - adverbial (bare adverb): an adverb modifying a verb has\n relatedtoken1 -> the verb\'s id, relationship1 = \'adverbial\'.\n Example: in \'διαρρήδην εἴρηται\', διαρρήδην has relatedtoken1 ->\n εἴρηται, relationship1 \'adverbial\'. An adverb can also stand in\n attributive position modifying a noun -- same relationship1\n value either way, just a noun instead of a verb on the other\n end. Example: in \'δᾷδας λαβόντες ἐκ τοῦ ἐγγύτατα καπηλείου\n εἰσερχόμεθα\', ἐγγύτατα has relatedtoken1 -> καπηλείου,\n relationship1 \'adverbial\'.\n - prepositional phrases: the preposition has relatedtoken1 -> the\n id of the verb (adverbial) or noun (attributive) it modifies,\n relationship1 = \'adverbial\' or \'attributive\'. The noun/pronoun\n it governs has relatedtoken1 -> the id of the preposition,\n relationship1 = \'object of preposition\' (or relatedtoken2/\n relationship2 if relatedtoken1 is already used for a\n relative-pronoun link). Example: in \'γυναῖκα ἠγαγόμην εἰς τὴν\n οἰκίαν\', εἰς has relatedtoken1 -> ἠγαγόμην, relationship1\n \'adverbial\', and οἰκίαν has relatedtoken1 -> εἰς, relationship1\n \'object of preposition\'.\n - genitive: a noun or pronoun in the genitive that modifies\n ANOTHER NOUN -- and isn\'t already covered by a more specific\n relation above (object of preposition, genitive absolute, etc)\n -- has relatedtoken1 -> the id of that noun, relationship1 =\n \'genitive\'. This is purely a syntactic (case-function) label,\n not a semantic one -- don\'t distinguish e.g. possessive vs.\n partitive genitive. Example: in \'ᾤχετο εἰς τὸ ἱερὸν μετὰ τῆς\n μητρὸς τῆς ἐκείνου\', ἐκείνου has relatedtoken1 -> μητρός,\n relationship1 \'genitive\'.\n - dative / accusative: a noun in the dative or accusative case\n that depends on a verb or another noun -- and isn\'t already\n covered by a more specific relation above (subject, direct\n object, object of preposition, etc) -- has relatedtoken1 -> the\n id of the verb or noun it depends on, relationship1 = the\n matching case name (\'dative\' or \'accusative\'). Example (dative,\n linked to a verb): in \'οὔτε ἔχθρα ἐμοὶ καὶ ἐκείνῳ οὐδεμία ἦν\n πλὴν ταύτης, οὔτε χρημάτων ἕνεκα ἔπραξα ταῦτα\', both ἐμοί and\n ἐκείνῳ have relatedtoken1 -> ἦν, relationship1 \'dative\'. Example\n (accusative of extent of time, linked to a verb): in \'ταῦτα\n πολὺν χρόνον οὕτως ἐγίγνετο\', χρόνον has relatedtoken1 ->\n ἐγίγνετο, relationship1 \'accusative\'. Note that Greek has NO\n ablative case and NO dedicated relation label for one -- a\n Latin-scheme \'ablative\' relation simply does not arise here.\n - vocative: a noun in the vocative case (direct address) has\n relatedtoken1 -> the id of the verb of the clause it\'s addressed\n within, relationship1 = \'vocative\'. Unlike \'genitive\'/\'dative\'/\n \'accusative\' above, a vocative relates to a verb only, never to\n another noun. Example: in \'ἐγὼ μὲν οὖν, ὦ ἄνδρες, οὐκ ἰδίαν ὑπὲρ\n ἐμαυτοῦ νομίζω ταύτην γενέσθαι τὴν τιμωρίαν\', ἄνδρες has\n relatedtoken1 -> νομίζω, relationship1 \'vocative\'.\n - apposition: when one noun stands in apposition to another, the\n appositive has relatedtoken1 -> the id of the first (the noun it\n restates or further identifies), relationship1 = \'apposition\'. A\n genitive depending on either noun still gets its own ordinary\n \'genitive\' relation, pointing at whichever noun it actually\n depends on -- apposition doesn\'t change that.\n # TODO: syntax_model.md gives only the general definition here,\n # no worked Greek example. Constructed illustration: in\n # \'Δημοσθένης ὁ ῥήτωρ ἦλθεν\', ῥήτωρ has relatedtoken1 ->\n # Δημοσθένης, relationship1 \'apposition\' (and ὁ has\n # relatedtoken1 -> ῥήτωρ, relationship1 \'article\').\n - exclamation: an exclamatory word has relatedtoken1 -> the id of\n the verb of its own verbal unit, relationship1 = \'exclamation\' --\n EXCEPT the frequent exclamatory particle ὦ introducing a\n vocative, which instead has relatedtoken1 -> the id of the\n vocative noun/pronoun it introduces (not the verb directly).\n Example: in \'ἐγὼ μὲν οὖν, ὦ ἄνδρες, οὐκ ἰδίαν ὑπὲρ ἐμαυτοῦ\n νομίζω ταύτην γενέσθαι τὴν τιμωρίαν, ἀλλ\' ὑπὲρ τῆς πόλεως\n ἁπάσης\', ὦ has relatedtoken1 -> ἄνδρες (the vocative it\n introduces), relationship1 \'exclamation\' -- NOT relatedtoken1 ->\n νομίζω directly, even though ἄνδρες\'s own relatedtoken1 does\n point to νομίζω (relationship1 \'vocative\'). This same pattern\n applies wherever ὦ introduces a vocative elsewhere in a passage,\n e.g. \'πρῶτον μὲν οὖν, ὦ ἄνδρες, ...\': ὦ -> ἄνδρες, \'exclamation\'.\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 (e.g. a bare accusative of\n respect not covered above). Use\n only the token ids given in the input `tokens` list, the sentinel\n \'root\', or a NEW id you create for an implied token (see below), in\n your output; never invent an id for anything else.\n\n(3) implied/elided tokens. `grammatike` recognizes two DIFFERENT\n situations where a verbal expression exists grammatically but has\n no surface realization in the passage at all -- rather than skip\n these, add a NEW entry to `tokengraph` (and a matching new entry to\n `verbalunits`, since an implied token always anchors its own\n verbal expression) with: a brand-new id, not used by any entry in\n `tokens` or elsewhere in your own output (see the naming rule\n below); the matching tokentype below; and no `token` value (leave\n it unset/None) -- these go together, and \'implied eimi\' /\n \'implied repetition\' are the ONLY two tokentype values whose id\n isn\'t one of `tokens`\' own ids and whose `token` is empty.\n\n - tokentype \'implied eimi\': an elided form of εἰμί (\'to be\') in a\n predicate expression. The documented case is an implied\n INFINITIVE of εἰμί inside indirect statement: the implied token\n anchors a verbal expression classified \'indirect statement\' and\n \'linking verb\', relates to its governing verb of thinking/saying\n via relatedtoken1/relationship1 = \'indirect statement\' exactly\n as a written-out infinitive would, and the subject/predicate of\n the predication relate to it as \'subject\'/\'predicate\' exactly as\n they would to any linking verb. Example: \'ταύτην τὴν ὕβριν\n ἅπαντες ἄνθρωποι δεινοτάτην ἡγοῦνται\' has an independent verbal\n expression ἡγοῦνται governing an implied infinitive of εἰμί\n (syntactic type \'indirect statement\', semantic type \'linking\n verb\') whose relatedtoken1 -> ἡγοῦνται, relationship1 \'indirect\n statement\'; \'ταύτην τὴν ὕβριν\' relates to it as \'subject\' and\n δεινοτάτην as \'predicate\'.\n # TODO: the two sub-cases below extrapolate from that one\n # documented example and from the general phrasing "elided εἰμί\n # in predicate expressions" -- syntax_model.md gives no worked\n # example for either:\n - a bare predicate construction with NO governing verb at all\n (subject + predicate noun/adjective, nothing else): the\n implied token anchors a verbal expression classified\n \'independent\' (or \'dependent\', if the elided-εἰμί clause is\n itself subordinate) and \'linking verb\'; subject and\n predicate relate to it exactly as they would to any linking\n verb.\n - an omitted conjugated εἰμί in a compound perfect-system form\n (the participle left standing alone for its auxiliary): the\n implied token stands in for the omitted form of εἰμί --\n everything that would normally relate to that auxiliary\n (subject, the participle\'s own \'auxiliary\' relation, etc.)\n relates to the implied token instead, exactly as if the\n auxiliary had been written out.\n - tokentype \'implied repetition\': a verb elided from a later\n verbal expression in a coordinated series because it repeats the\n verb of an earlier one. Add ONE implied token per omitted\n repeated verb, repeating that verb\'s OWN syntactic_type and\n semantic_type exactly (whatever those happen to be in context --\n not necessarily \'independent\'/\'intransitive\'), and give whatever\n would relate to the omitted verb (subject, adverbial, a\n connecting word, etc.) its normal relation into the implied\n token instead, exactly as if the verb had been repeated.\n Example: \'ἐγὼ μὲν ἄνω διῃτώμην, αἱ δὲ γυναῖκες κάτω\' has an\n explicit verbal expression διῃτώμην (\'independent\'/\n \'intransitive\') with subject ἐγώ, and a second, implied verbal\n expression (tokentype \'implied repetition\') repeating\n διῃτώμην\'s own \'independent\'/\'intransitive\' classification, with\n subject γυναῖκες and adverbial κάτω relating to the implied\n token instead of to διῃτώμην.\n\n Naming an implied token\'s id (both tokentypes): append \'_implied\'\n to the id of the LAST real token in `tokens` that precedes where\n the elided word would have stood (or, if the elided word would\n come before every real token in the sentence, the FIRST real\n token\'s id instead). If more than one implied token is ever\n needed at the same position, append \'2\', \'3\', ... after \'_implied\'\n to keep them unique (e.g. \'t5_implied\', \'t5_implied2\'). Place the\n new `tokengraph` entry at the list position where the elided word\n would have appeared, among the tokens of its own clause -- this\n keeps it grouped with the rest of its verbal expression for\n anything that reads `tokengraph` in order.' passage = Field(annotation=str required=True json_schema_extra={'desc': 'The Ancient Greek 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; infinitive or participle used in indirect speech; attributive participle; or circumstantial 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 validate(tokens: List[Token], result) -> List[str]:
689def validate(tokens: List[Token], result) -> List[str]:
690    """Check that every id the LM produced actually exists among `tokens`
691    -- OR is a legitimately new implied token (tokentype in
692    IMPLIED_TOKENTYPES -- 'implied eimi' or 'implied repetition'; see
693    SyntaxAnalysis's docstring) -- and that implied tokens themselves are
694    well-formed. Returns a list of human-readable problem descriptions
695    (empty if clean).
696
697    'root' is a special sentinel value for an independent verb's own
698    relatedtoken1 (see SyntaxAnalysis's docstring) -- it is never treated as
699    an unknown id, but syntax_model.md also requires that no actual token
700    ever be assigned the id 'root', so that's checked here too.
701
702    Implied tokens get their own, narrower checks: a tokengraph entry
703    claiming an IMPLIED_TOKENTYPES value must use a genuinely NEW id (not
704    one already in `tokens`) and must leave `token` unset (None) -- getting
705    either wrong is exactly the kind of malformed output this function
706    exists to catch, not a legitimate implied token. A non-implied entry,
707    conversely, must use one of `tokens`' own ids and must NOT have
708    `token=None` -- only 'implied eimi'/'implied repetition' may omit real
709    surface text."""
710    valid_ids = {t.id for t in tokens}
711    problems = []
712
713    if "root" in valid_ids:
714        problems.append(
715            "token id 'root' is reserved as the sentinel relatedtoken1 "
716            "value for independent verbs and must not be assigned to an "
717            "actual token"
718        )
719
720    implied_ids = {tok.id for tok in result.tokengraph if tok.tokentype in IMPLIED_TOKENTYPES}
721    known_ids = valid_ids | implied_ids
722
723    for tok in result.tokengraph:
724        if tok.tokentype in IMPLIED_TOKENTYPES:
725            if tok.id in valid_ids:
726                problems.append(
727                    f"tokengraph entry {tok.id!r} is tokentype={tok.tokentype!r} but "
728                    "reuses an id already in the input `tokens` list -- an "
729                    "implied token must use a new id"
730                )
731            if tok.token is not None:
732                problems.append(
733                    f"tokengraph entry {tok.id!r} is tokentype={tok.tokentype!r} but "
734                    f"has a non-None token value {tok.token!r} -- an implied "
735                    "token's text must be left unset"
736                )
737        else:
738            if tok.id not in valid_ids:
739                problems.append(f"tokengraph entry has unknown id {tok.id!r}")
740            if tok.token is None:
741                problems.append(
742                    f"tokengraph entry {tok.id!r} has token=None but "
743                    f"tokentype={tok.tokentype!r} -- only 'implied eimi'/"
744                    "'implied repetition' may omit surface text"
745                )
746        for field in ("relatedtoken1", "relatedtoken2"):
747            val = getattr(tok, field)
748            if val is not None and val != "root" and val not in known_ids:
749                problems.append(f"token {tok.id!r} {field}={val!r} is not a known token id")
750
751    for vu in result.verbalunits:
752        if vu.id not in known_ids:
753            problems.append(f"verbal expression id {vu.id!r} is not a known token id")
754
755    return problems

Check that every id the LM produced actually exists among tokens -- OR is a legitimately new implied token (tokentype in IMPLIED_TOKENTYPES -- 'implied eimi' or 'implied repetition'; 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) -- getting either wrong is exactly the kind of malformed output this function exists to catch, not a legitimate implied token. A non-implied entry, conversely, must use one of tokens' own ids and must NOT have token=None -- only 'implied eimi'/'implied repetition' may omit real surface text.

def segment_sources( sources: List[CitedText]) -> List[Sentence]:
228def segment_sources(sources: List[CitedText]) -> List[Sentence]:
229    """Deterministically segment `sources` into sentences, in two passes:
230
231    1. _group_into_sentences() splits the input into sentence-strings by
232       regex alone (a period or interrogative always ends one; nothing else
233       does) -- no tokenization involved yet.
234    2. Tokenize each sentence's string(s) in turn (see this module's own
235       docstring for the two tokenization decisions this involves), handing
236       out ids sequentially (t0, t1, ...) from ONE counter that runs across
237       the whole input -- never restarted per sentence, so a Sentence stays
238       a contiguous slice of the passage's global id sequence.
239
240    Running this on the same `sources` again always produces the same ids
241    for the same tokens -- there is nothing non-deterministic left in this
242    stage at all.
243    """
244    next_id = 0
245    sentences: List[Sentence] = []
246
247    for fragments in _group_into_sentences(sources):
248        tokens: List[Token] = []
249        for citation, text in fragments:
250            for word in text.split():
251                for piece in _split_word(word):
252                    tokens.append(Token(id=f"t{next_id}", text=piece, citation=citation))
253                    next_id += 1
254        sentences.append(Sentence(tokens=tokens))
255
256    return sentences

Deterministically segment sources into sentences, in two passes:

  1. _group_into_sentences() splits the input into sentence-strings by regex alone (a period or interrogative always ends one; nothing else does) -- no tokenization involved yet.
  2. Tokenize each sentence's string(s) in turn (see this module's own docstring for the two tokenization decisions this involves), handing out ids sequentially (t0, t1, ...) from ONE counter that runs across the whole input -- never restarted per sentence, so a Sentence stays a contiguous slice of the passage's global id sequence.

Running this on the same sources again always produces the same ids for the same tokens -- there is nothing non-deterministic left in this stage at all.

def analyze_sources( sources: List[CitedText]) -> Tuple[List[Sentence], list]:
39def analyze_sources(sources: List[CitedText]) -> Tuple[List[Sentence], list]:
40    """Segment `sources` into citation-aware sentences, run each sentence's
41    tokens through SyntaxAnalysis, and validate each result.
42
43    Returns (sentences, results): results[i] is the SyntaxAnalysis result
44    for sentences[i], same order, one entry per sentence.
45
46    Each sentence's SyntaxAnalysis call goes through
47    `token_budget.analyze_with_retry()` rather than calling `analyze()`
48    directly, so a sentence whose analysis needs more output than a fixed
49    `max_tokens` would allow (a long or deeply subordinated sentence) gets
50    an estimated, appropriately-sized budget up front, and a retry with a
51    larger one if it still comes back truncated -- see token_budget.py's
52    module docstring for the full design.
53    """
54    sentences = segment_sources(sources)
55
56    results = []
57    for sentence in sentences:
58        result = analyze_with_retry(passage=_render_sentence_text(sentence), tokens=sentence.tokens)
59
60        problems = validate(sentence.tokens, result)
61        if problems:
62            first_id = sentence.tokens[0].id if sentence.tokens else "?"
63            print(f"Validation warnings (sentence starting at {first_id}):")
64            for p in problems:
65                print(f"  - {p}")
66
67        results.append(result)
68
69    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.

Each sentence's SyntaxAnalysis call goes through token_budget.analyze_with_retry() rather than calling analyze() directly, so a sentence whose analysis needs more output than a fixed max_tokens would allow (a long or deeply subordinated sentence) gets an estimated, appropriately-sized budget up front, and a retry with a larger one if it still comes back truncated -- see token_budget.py's module docstring for the full design.

def combined_tokengraph(results) -> list:
72def combined_tokengraph(results) -> list:
73    """Concatenate every sentence result's tokengraph, in order, into one
74    flat list spanning the whole input -- since token ids are global,
75    tokengraph_to_mermaid() (mermaid.py) needs no changes at all to render
76    this as one diagram for a multi-sentence, multi-citation passage."""
77    combined = []
78    for result in results:
79        combined.extend(result.tokengraph)
80    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 analyze_passage( passage: str, citation: str = '') -> Tuple[List[Sentence], list]:
 83def analyze_passage(passage: str, citation: str = "") -> Tuple[List[Sentence], list]:
 84    """Convenience wrapper for the common case of a single string rather
 85    than a list of citation-labeled CitedText sources -- kept here so
 86    existing callers (syntaxer_main.py, the marimo notebook) have a
 87    one-string entry point rather than needing to build a CitedText list
 88    themselves for the ordinary case of one passage from one source.
 89
 90    Wraps `passage` as one CitedText (using `citation` if given, else an
 91    empty string -- fine for callers that don't track citations) and runs
 92    it through analyze_sources(). Returns (sentences, results) -- the exact
 93    same shape analyze_sources() returns, one entry per sentence
 94    segmentation finds in `passage`, in order.
 95
 96    `passage` may contain any number of sentences: each is segmented and
 97    analyzed successively, same as if you'd called analyze_sources() with
 98    one CitedText yourself. (An earlier version of this function raised
 99    ValueError on multi-sentence input and returned a single (tokens,
100    result) pair for exactly one sentence; callers written against that
101    contract need to change to unpack (sentences, results) and iterate.)
102
103    Example: `analyze_passage("...", citation="urn:cts:greekLit:tlg0059.tlg030.perseus-grc2:1")`
104    for a passage of Plato, or `analyze_passage("...", citation="Lysias 1.1")`
105    for a citation scheme keyed by author/work/section instead of a URN.
106    """
107    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 existing callers (syntaxer_main.py, the marimo notebook) 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) -- the exact same shape analyze_sources() returns, one entry per sentence segmentation finds in passage, in order.

passage may contain any number of sentences: each is segmented and analyzed successively, same as if you'd called analyze_sources() with one CitedText yourself. (An earlier version of this function raised ValueError on multi-sentence input and returned a single (tokens, result) pair for exactly one sentence; callers written against that contract need to change to unpack (sentences, results) and iterate.)

Example: analyze_passage("...", citation="urn:cts:greekLit:tlg0059.tlg030.perseus-grc2:1") for a passage of Plato, or analyze_passage("...", citation="Lysias 1.1") for a citation scheme keyed by author/work/section instead of a URN.

def serialize_analyses( sentences: List[Sentence], verbalunits: List[VerbalExpression], tokengraph: List[TokenAnalysis], results: Optional[list] = None) -> Tuple[str, List[str]]:
234def serialize_analyses(
235    sentences: List[Sentence],
236    verbalunits: List[VerbalExpression],
237    tokengraph: List[TokenAnalysis],
238    results: Optional[list] = None,
239) -> Tuple[str, List[str]]:
240    """Build the exact text write_analyses() would write to a file, and
241    return it directly as `(content, warnings)` instead of writing it
242    anywhere -- see the module docstring for why this exists alongside
243    write_analyses(). All three lists are flat and span however many
244    sentences/citation sources were analyzed -- the same shape
245    analyze_sources() (for `sentences`) and combined_tokengraph() (for
246    `tokengraph`; `verbalunits` needs the analogous concatenation, which
247    this function does not do for you) already produce.
248
249    `results` is optional -- the same list analyze_sources()/
250    analyze_passage() return alongside `sentences` (one entry per
251    sentence, each with a `.reasoning` attribute; a dspy prediction from
252    SyntaxAnalysis has exactly this shape). When given, it must have
253    exactly one entry per entry of `sentences` (raises ValueError
254    otherwise, naming the mismatched lengths) -- one '#!llm' block is
255    written per sentence, in order, each recording the `MODEL` environment
256    variable's current value and that sentence's own `result.reasoning`
257    text (see the module docstring for the exact block shape). Omit
258    `results` (the default) to write a file with no '#!llm' blocks at all,
259    exactly as before this parameter existed.
260
261    `content` is the complete file body, including its trailing newline,
262    exactly as write_analyses() would have written it. `warnings` is a
263    list of warning strings (empty if nothing looks wrong), matching this
264    codebase's "degrade visibly, don't raise" convention for warnings
265    distinct from hard errors:
266
267    - a tokengraph or verbalunits entry whose id isn't found among any
268      given sentence's tokens (so no citation is known for it -- an empty
269      context is written, same as a token that legitimately has no
270      citation at all, but this case specifically means the id wasn't
271      found anywhere in `sentences` -- EXCEPT for an implied token
272      (tokentype in IMPLIED_TOKENTYPES), which never appears in any sentence's own
273      `tokens` by design, so this warning is suppressed for those
274      specifically rather than flagged as an anomaly);
275    - a sentence whose own tokens don't form a contiguous, matching-order
276      run in `tokengraph`'s given order -- see the module docstring for
277      why this matters for read_analyses() to recover sentence boundaries
278      correctly.
279
280    Raises ValueError for a sentence with no tokens at all (nothing to
281    derive first_token/last_token from), or if any field value contains
282    '|' or a newline (see `_field`).
283    """
284    warnings: List[str] = []
285
286    id_to_citation: Dict[str, Optional[str]] = {}
287    for sentence in sentences:
288        for tok in sentence.tokens:
289            id_to_citation[tok.id] = tok.citation
290
291    # Implied tokens (tokentype in IMPLIED_TOKENTYPES) never appear in any sentence's
292    # own `tokens` list by design (see the module docstring's note above)
293    # -- so having no recorded citation is expected and correct for them,
294    # not the kind of anomaly the "not found among the given sentences'
295    # tokens" warning below exists to flag.
296    implied_ids = {tok.id for tok in tokengraph if tok.tokentype in IMPLIED_TOKENTYPES}
297
298    tg_index = {tok.id: i for i, tok in enumerate(tokengraph)}
299
300    lines: List[str] = []
301
302    lines.append(SENTENCES_LABEL)
303    lines.append(SENTENCES_HEADER)
304    for s_idx, sentence in enumerate(sentences):
305        if not sentence.tokens:
306            raise ValueError(
307                f"sentence at index {s_idx} has no tokens -- cannot derive "
308                "first_token/last_token for an empty sentence"
309            )
310        first_tok = sentence.tokens[0]
311        last_tok = sentence.tokens[-1]
312
313        first_pos = tg_index.get(first_tok.id)
314        last_pos = tg_index.get(last_tok.id)
315        if first_pos is None or last_pos is None:
316            warnings.append(
317                f"sentence at index {s_idx} (tokens {first_tok.id!r}.."
318                f"{last_tok.id!r}) has a boundary token not present in the "
319                "given tokengraph -- reading this file back may not "
320                "reconstruct this sentence's tokens correctly"
321            )
322        else:
323            expected_ids = [t.id for t in sentence.tokens]
324            # Implied tokens (tokentype in IMPLIED_TOKENTYPES) were never part of the
325            # original per-sentence `tokens` list -- they're synthesized by
326            # analysis itself -- so exclude them here before comparing, or
327            # every sentence containing one would spuriously warn.
328            actual_ids = [
329                tok.id
330                for tok in tokengraph[first_pos : last_pos + 1]
331                if tok.tokentype not in IMPLIED_TOKENTYPES
332            ]
333            if actual_ids != expected_ids:
334                warnings.append(
335                    f"sentence at index {s_idx} (tokens {first_tok.id!r}.."
336                    f"{last_tok.id!r}) is not a contiguous, matching-order "
337                    "run in the given tokengraph -- reading this file back "
338                    "may not reconstruct this sentence's tokens correctly"
339                )
340
341        where = f"#!sentences row for sentence {s_idx}"
342        lines.append(
343            "|".join(
344                [
345                    _field(first_tok.citation, where=where),
346                    _field(first_tok.id, where=where),
347                    _field(last_tok.citation, where=where),
348                    _field(last_tok.id, where=where),
349                ]
350            )
351        )
352
353    lines.append("")
354    lines.append(VERBAL_UNITS_LABEL)
355    lines.append(VERBAL_UNITS_HEADER)
356    for vu in verbalunits:
357        if vu.id not in id_to_citation and vu.id not in implied_ids:
358            warnings.append(
359                f"verbal expression {vu.id!r} not found among the given "
360                "sentences' tokens -- writing an empty context for it"
361            )
362        where = f"#!verbal_units row for {vu.id}"
363        lines.append(
364            "|".join(
365                [
366                    _field(id_to_citation.get(vu.id), where=where),
367                    _field(vu.id, where=where),
368                    _field(vu.syntactic_type, where=where),
369                    _field(vu.semantic_type, where=where),
370                ]
371            )
372        )
373
374    lines.append("")
375    lines.append(TOKENS_LABEL)
376    lines.append(TOKENS_HEADER)
377    for tok in tokengraph:
378        if tok.id not in id_to_citation and tok.id not in implied_ids:
379            warnings.append(
380                f"token {tok.id!r} not found among the given sentences' "
381                "tokens -- writing an empty context for it"
382            )
383        where = f"#!tokens row for {tok.id}"
384        lines.append(
385            "|".join(
386                [
387                    _field(id_to_citation.get(tok.id), where=where),
388                    _field(tok.id, where=where),
389                    _field(tok.tokentype, where=where),
390                    _field(tok.token, where=where),
391                    _field(tok.lemma, where=where),
392                    _field(tok.verbalunitid, where=where),
393                    _field(tok.relatedtoken1, where=where),
394                    _field(tok.relationship1, where=where),
395                    _field(tok.relatedtoken2, where=where),
396                    _field(tok.relationship2, where=where),
397                ]
398            )
399        )
400
401    if results is not None:
402        if len(results) != len(sentences):
403            raise ValueError(
404                f"results has {len(results)} entries but sentences has "
405                f"{len(sentences)} -- serialize_analyses() needs exactly "
406                "one result per sentence to label each '#!llm' block"
407            )
408        model = os.environ.get("MODEL")
409        for s_idx, result in enumerate(results):
410            where = f"#!llm block for sentence {s_idx}"
411            lines.append("")
412            lines.append(LLM_LABEL)
413            lines.append(_MODEL_PREFIX + _field(model, where=where))
414            normalized_reasoning = str(result.reasoning).replace("\r\n", "\n").replace("\r", "\n")
415            for reasoning_line in normalized_reasoning.split("\n"):
416                _validate_llm_body_line(reasoning_line, where=where)
417                lines.append(reasoning_line)
418
419    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 -- see the module docstring for why this exists alongside write_analyses(). 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, which this function does not do for you) already produce.

results is optional -- the same list analyze_sources()/ analyze_passage() return alongside sentences (one entry per sentence, each with a .reasoning attribute; a dspy prediction from SyntaxAnalysis has exactly this shape). When given, it must have exactly one entry per entry of sentences (raises ValueError otherwise, naming the mismatched lengths) -- one '#!llm' block is written per sentence, in order, each recording the MODEL environment variable's current value and that sentence's own result.reasoning text (see the module docstring for the exact block shape). Omit results (the default) to write a file with no '#!llm' blocks at all, exactly as before this parameter existed.

content is the complete file body, including its trailing newline, exactly as write_analyses() would have written it. warnings is a list of warning strings (empty if nothing looks wrong), matching this codebase's "degrade visibly, don't raise" convention for warnings distinct from hard errors:

  • a tokengraph or verbalunits entry whose id isn't found among any given sentence's tokens (so no citation is known for it -- an empty context is written, same as a token that legitimately has no citation at all, but this case specifically means the id wasn't found anywhere in sentences -- EXCEPT for an implied token (tokentype in IMPLIED_TOKENTYPES), which never appears in any sentence's own tokens by design, so this warning is suppressed for those specifically rather than flagged as an anomaly);
  • a sentence whose own tokens don't form a contiguous, matching-order run in tokengraph's given order -- see the module docstring for why this matters for read_analyses() to recover sentence boundaries correctly.

Raises ValueError for a sentence with no tokens at all (nothing to derive first_token/last_token from), 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, results: Optional[list] = None) -> List[str]:
422def write_analyses(
423    sentences: List[Sentence],
424    verbalunits: List[VerbalExpression],
425    tokengraph: List[TokenAnalysis],
426    path: str,
427    results: Optional[list] = None,
428) -> List[str]:
429    """Write `sentences`/`verbalunits`/`tokengraph` to `path` in the format
430    this module's docstring describes -- see serialize_analyses() (which
431    this is a thin wrapper around) for what's actually written and for the
432    full list of warnings this can return. `results` is optional and
433    passed straight through -- see serialize_analyses()'s own docstring
434    for the '#!llm' blocks it produces when given.
435
436    Returns a list of warning strings (empty if nothing looks wrong); see
437    serialize_analyses()'s docstring for what each one means. Raises
438    ValueError for a sentence with no tokens at all (nothing to derive
439    first_token/last_token from), if any field value contains '|' or a
440    newline (see `_field`), if `results` is given with a different length
441    than `sentences`, or if a reasoning line collides with a block label
442    (see `_validate_llm_body_line`) -- all raised by serialize_analyses()
443    before this function ever opens `path`.
444    """
445    content, warnings = serialize_analyses(sentences, verbalunits, tokengraph, results=results)
446    with open(path, "w", encoding="utf-8") as f:
447        f.write(content)
448    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. results is optional and passed straight through -- see serialize_analyses()'s own docstring for the '#!llm' blocks it produces when given.

Returns a list of warning strings (empty if nothing looks wrong); see serialize_analyses()'s docstring for what each one means. Raises ValueError for a sentence with no tokens at all (nothing to derive first_token/last_token from), if any field value contains '|' or a newline (see _field), if results is given with a different length than sentences, or if a reasoning line collides with a block label (see _validate_llm_body_line) -- all raised by serialize_analyses() before this function ever opens path.

def read_analyses( path: str) -> Tuple[List[TokenAnalysis], List[VerbalExpression], List[Sentence]]:
499def read_analyses(
500    path: str,
501) -> Tuple[List[TokenAnalysis], List[VerbalExpression], List[Sentence]]:
502    """Read `path` (as written by write_analyses()/serialize_analyses()) and
503    reconstruct `(tokengraph, verbalunits, sentences)` -- in that order,
504    matching the order these three types are usually discussed in this
505    codebase (the token-level graph, then the verbal-expression table,
506    then the sentence/citation structure that supplies context for both).
507
508    Each of the three block labels may appear more than once in `path`
509    (see the module docstring) -- every instance contributes its own rows,
510    in file order, to that label's combined row list, as if the file were
511    the concatenation of however many separate write_analyses()/
512    serialize_analyses() outputs it actually is. Any '#!llm' blocks in
513    `path` are checked for well-formedness and then skipped -- their
514    (model, reasoning) content isn't part of this function's return shape;
515    use read_llm_notes() to get it.
516
517    Raises ValueError, naming the offending line and problem, for anything
518    that isn't a faithful, internally-consistent file written by
519    write_analyses() -- see this module's own docstring for exactly what's
520    checked. This function does not accept a file with warnings-worthy
521    inconsistencies silently patched over; if write_analyses() returned
522    warnings when the file was written, fix the input and re-write it
523    rather than expecting read_analyses() to compensate.
524    """
525    with open(path, "r", encoding="utf-8") as f:
526        raw_lines = f.read().splitlines()
527
528    # blocks[label] accumulates (line_no, line) data rows across every
529    # instance of that label found in the file, in file order. A label
530    # line always starts a new instance and must be immediately followed
531    # by that label's header line (`awaiting_header` tracks this) before
532    # any more data rows can be appended to it -- this holds per instance,
533    # not just for the label's first appearance, so every repeated block
534    # must repeat its own header line too.
535    blocks: Dict[str, List[Tuple[int, str]]] = {label: [] for label in _EXPECTED_HEADERS}
536    seen_labels = set()
537    current_label: Optional[str] = None
538    awaiting_header = False
539
540    i = 0
541    n = len(raw_lines)
542    while i < n:
543        line_no = i + 1
544        line = raw_lines[i]
545
546        if line == LLM_LABEL:
547            if awaiting_header:
548                raise ValueError(
549                    f"line {line_no}: block {current_label!r} has a label "
550                    "line but no header line before the next block starts"
551                )
552            # Validated and discarded here -- current_label/awaiting_header
553            # are left exactly as they were, so an '#!llm' block can sit
554            # between two other blocks (or inside one's own data run,
555            # though this module never writes it that way itself) without
556            # disturbing whatever block was already in progress.
557            _model, _reasoning, i = _scan_llm_block(raw_lines, i + 1, line_no)
558            continue
559
560        if line.strip() == "":
561            i += 1
562            continue
563
564        if line in _EXPECTED_HEADERS:
565            if awaiting_header:
566                raise ValueError(
567                    f"line {line_no}: block {current_label!r} has a label "
568                    "line but no header line before the next block starts"
569                )
570            current_label = line
571            seen_labels.add(line)
572            awaiting_header = True
573            i += 1
574            continue
575
576        if current_label is None:
577            raise ValueError(
578                f"line {line_no}: data line {line!r} appears before any "
579                "'#!' block label"
580            )
581
582        if awaiting_header:
583            expected = _EXPECTED_HEADERS[current_label]
584            if line != expected:
585                raise ValueError(
586                    f"line {line_no}: expected header {expected!r} for "
587                    f"block {current_label!r}, got {line!r}"
588                )
589            awaiting_header = False
590            i += 1
591            continue
592
593        blocks[current_label].append((line_no, line))
594        i += 1
595
596    missing = sorted(set(_EXPECTED_HEADERS) - seen_labels)
597    if missing:
598        raise ValueError(f"file is missing required block(s): {missing}")
599    if awaiting_header:
600        raise ValueError(
601            f"block {current_label!r} has a label line but no header line "
602            "(and no data) -- the file ends too early"
603        )
604
605    # --- #!tokens: build the TokenAnalysis list, the id->citation map,
606    # and the row-order index sentence reconstruction relies on. ---
607    tokengraph: List[TokenAnalysis] = []
608    id_to_citation: Dict[str, Optional[str]] = {}
609    row_order: List[str] = []
610
611    for line_no, line in blocks[TOKENS_LABEL]:
612        parts = line.split("|")
613        if len(parts) != 10:
614            raise ValueError(
615                f"line {line_no}: #!tokens row has {len(parts)} columns, "
616                f"expected 10: {line!r}"
617            )
618        (
619            context,
620            tok_id,
621            tokentype,
622            text,
623            lemma,
624            verbalunit,
625            related1,
626            relationship1,
627            related2,
628            relationship2,
629        ) = parts
630        if tok_id == "":
631            raise ValueError(f"line {line_no}: #!tokens row has an empty id")
632        if tok_id in id_to_citation:
633            raise ValueError(f"line {line_no}: duplicate token id {tok_id!r} in #!tokens")
634
635        tokengraph.append(
636            TokenAnalysis(
637                id=tok_id,
638                token=_parse_optional(text),
639                tokentype=tokentype,
640                lemma=_parse_optional(lemma),
641                verbalunitid=_parse_optional(verbalunit),
642                relatedtoken1=_parse_optional(related1),
643                relationship1=_parse_optional(relationship1),
644                relatedtoken2=_parse_optional(related2),
645                relationship2=_parse_optional(relationship2),
646            )
647        )
648        id_to_citation[tok_id] = _parse_optional(context)
649        row_order.append(tok_id)
650
651    id_position = {tid: i for i, tid in enumerate(row_order)}
652
653    # --- #!verbal_units ---
654    verbalunits: List[VerbalExpression] = []
655    for line_no, line in blocks[VERBAL_UNITS_LABEL]:
656        parts = line.split("|")
657        if len(parts) != 4:
658            raise ValueError(
659                f"line {line_no}: #!verbal_units row has {len(parts)} "
660                f"columns, expected 4: {line!r}"
661            )
662        context, vu_id, syntactic_type, semantic_type = parts
663        if vu_id == "":
664            raise ValueError(f"line {line_no}: #!verbal_units row has an empty token id")
665        if vu_id not in id_to_citation:
666            raise ValueError(
667                f"line {line_no}: #!verbal_units references token id "
668                f"{vu_id!r}, which does not appear in the #!tokens block"
669            )
670        recorded_context = _parse_optional(context)
671        expected_context = id_to_citation[vu_id]
672        if recorded_context != expected_context:
673            raise ValueError(
674                f"line {line_no}: #!verbal_units row's context "
675                f"{recorded_context!r} for token {vu_id!r} does not match "
676                f"the #!tokens block's recorded context {expected_context!r} "
677                "for the same id"
678            )
679
680        verbalunits.append(
681            VerbalExpression(
682                id=vu_id,
683                syntactic_type=syntactic_type,
684                semantic_type=semantic_type,
685            )
686        )
687
688    # --- #!sentences ---
689    sentences: List[Sentence] = []
690    for line_no, line in blocks[SENTENCES_LABEL]:
691        parts = line.split("|")
692        if len(parts) != 4:
693            raise ValueError(
694                f"line {line_no}: #!sentences row has {len(parts)} "
695                f"columns, expected 4: {line!r}"
696            )
697        context_begin, first_id, context_end, last_id = parts
698        if first_id == "" or last_id == "":
699            raise ValueError(
700                f"line {line_no}: #!sentences row is missing first_token "
701                f"or last_token: {line!r}"
702            )
703        if first_id not in id_position or last_id not in id_position:
704            raise ValueError(
705                f"line {line_no}: #!sentences references a first_token/"
706                "last_token id not found in the #!tokens block"
707            )
708
709        start = id_position[first_id]
710        end = id_position[last_id]
711        if start > end:
712            raise ValueError(
713                f"line {line_no}: #!sentences row's first_token "
714                f"{first_id!r} comes after last_token {last_id!r} in the "
715                "#!tokens block's row order"
716            )
717
718        parsed_begin = _parse_optional(context_begin)
719        parsed_end = _parse_optional(context_end)
720        if parsed_begin != id_to_citation[first_id]:
721            raise ValueError(
722                f"line {line_no}: #!sentences row's context_begin "
723                f"{parsed_begin!r} does not match the #!tokens block's "
724                f"recorded context {id_to_citation[first_id]!r} for token "
725                f"{first_id!r}"
726            )
727        if parsed_end != id_to_citation[last_id]:
728            raise ValueError(
729                f"line {line_no}: #!sentences row's context_end "
730                f"{parsed_end!r} does not match the #!tokens block's "
731                f"recorded context {id_to_citation[last_id]!r} for token "
732                f"{last_id!r}"
733            )
734
735        sentence_ids = [
736            tid
737            for tid in row_order[start : end + 1]
738            if tokengraph[id_position[tid]].tokentype not in IMPLIED_TOKENTYPES
739        ]
740        sentences.append(
741            Sentence(
742                tokens=[
743                    Token(
744                        id=tid,
745                        text=tokengraph[id_position[tid]].token,
746                        citation=id_to_citation[tid],
747                    )
748                    for tid in sentence_ids
749                ]
750            )
751        )
752
753    return tokengraph, verbalunits, sentences

Read path (as written by write_analyses()/serialize_analyses()) and reconstruct (tokengraph, verbalunits, sentences) -- in that order, matching the order these three types are usually discussed in this codebase (the token-level graph, then the verbal-expression table, then the sentence/citation structure that supplies context for both).

Each of the three block labels may appear more than once in path (see the module docstring) -- every instance contributes its own rows, in file order, to that label's combined row list, as if the file were the concatenation of however many separate write_analyses()/ serialize_analyses() outputs it actually is. Any '#!llm' blocks in path are checked for well-formedness and then skipped -- their (model, reasoning) content isn't part of this function's return shape; use read_llm_notes() to get it.

Raises ValueError, naming the offending line and problem, for anything that isn't a faithful, internally-consistent file written by write_analyses() -- see this module's own docstring for exactly what's checked. This function does not accept a file with warnings-worthy inconsistencies silently patched over; if write_analyses() returned warnings when the file was written, fix the input and re-write it rather than expecting read_analyses() to compensate.

def read_llm_notes(path: str) -> List[Tuple[Optional[str], str]]:
756def read_llm_notes(path: str) -> List[Tuple[Optional[str], str]]:
757    """Read `path` (as written by write_analyses()/serialize_analyses())
758    and return every '#!llm' block's own `(model, reasoning)` pair, in
759    file order -- the counterpart to read_analyses(), which parses the
760    same file but deliberately discards '#!llm' content (see the module
761    docstring for why: none of Sentence/VerbalExpression/TokenAnalysis has
762    a `reasoning` field to reconstruct one into, and changing
763    read_analyses()'s own 3-tuple return would break every existing
764    caller). Concatenates every '#!llm' block found in `path`, the same
765    "multiple instances, in file order" convention read_analyses() already
766    applies to the three core blocks -- so a file built by literally
767    concatenating several write_analyses(..., results=...) outputs returns
768    every one of their reasoning entries, in order, exactly as if they'd
769    all been written by a single call with a longer `results` list.
770
771    `model` is None wherever the 'MODEL' environment variable was unset at
772    write time (an empty 'MODEL=' line, same None-as-empty-field
773    convention used everywhere else in this format); `reasoning` is the
774    exact, verbatim multiline text originally passed as that sentence's
775    own `result.reasoning`, with exactly one trailing blank line stripped
776    (the writer's own block separator -- see the module docstring).
777
778    Returns an empty list for a file with no '#!llm' blocks at all --
779    including any file written before this parameter existed, or any
780    write_analyses()/serialize_analyses() call that omitted `results`.
781
782    Raises ValueError, naming the line, for a malformed '#!llm' block (a
783    label line with nothing after it before the next block or EOF, or a
784    first line that doesn't start with 'MODEL=') -- the same check
785    read_analyses() applies to every '#!llm' block it skips over, so a
786    file that reads cleanly with one of these two functions reads cleanly
787    with the other.
788    """
789    with open(path, "r", encoding="utf-8") as f:
790        raw_lines = f.read().splitlines()
791
792    notes: List[Tuple[Optional[str], str]] = []
793    i = 0
794    n = len(raw_lines)
795    while i < n:
796        if raw_lines[i] == LLM_LABEL:
797            model, reasoning, i = _scan_llm_block(raw_lines, i + 1, i + 1)
798            notes.append((model, reasoning))
799        else:
800            i += 1
801
802    return notes

Read path (as written by write_analyses()/serialize_analyses()) and return every '#!llm' block's own (model, reasoning) pair, in file order -- the counterpart to read_analyses(), which parses the same file but deliberately discards '#!llm' content (see the module docstring for why: none of Sentence/VerbalExpression/TokenAnalysis has a reasoning field to reconstruct one into, and changing read_analyses()'s own 3-tuple return would break every existing caller). Concatenates every '#!llm' block found in path, the same "multiple instances, in file order" convention read_analyses() already applies to the three core blocks -- so a file built by literally concatenating several write_analyses(..., results=...) outputs returns every one of their reasoning entries, in order, exactly as if they'd all been written by a single call with a longer results list.

model is None wherever the 'MODEL' environment variable was unset at write time (an empty 'MODEL=' line, same None-as-empty-field convention used everywhere else in this format); reasoning is the exact, verbatim multiline text originally passed as that sentence's own result.reasoning, with exactly one trailing blank line stripped (the writer's own block separator -- see the module docstring).

Returns an empty list for a file with no '#!llm' blocks at all -- including any file written before this parameter existed, or any write_analyses()/serialize_analyses() call that omitted results.

Raises ValueError, naming the line, for a malformed '#!llm' block (a label line with nothing after it before the next block or EOF, or a first line that doesn't start with 'MODEL=') -- the same check read_analyses() applies to every '#!llm' block it skips over, so a file that reads cleanly with one of these two functions reads cleanly with the other.

def split_analysis_by_sentence( tokengraph: List[TokenAnalysis], verbalunits: List[VerbalExpression], sentences: List[Sentence]) -> List[Tuple[List[TokenAnalysis], List[VerbalExpression]]]:
805def split_analysis_by_sentence(
806    tokengraph: List[TokenAnalysis],
807    verbalunits: List[VerbalExpression],
808    sentences: List[Sentence],
809) -> List[Tuple[List[TokenAnalysis], List[VerbalExpression]]]:
810    """The inverse of what write_analyses()/serialize_analyses() flatten
811    together: given the same `(tokengraph, verbalunits, sentences)` triple
812    read_analyses() returns (or that analyze_sources()/combined_tokengraph()
813    produce before ever being written to a file), split `tokengraph` and
814    `verbalunits` back into one slice per sentence.
815
816    Returns a list the same length and order as `sentences` -- entry i is
817    `(sentence_tokengraph, sentence_verbalunits)` for `sentences[i]`. Useful
818    for anything that wants to review or render one sentence's analysis at
819    a time (e.g. a sentence-picker UI, like marimo/greek_syntaxer_review.py)
820    without re-running analysis or re-deriving the same id-position
821    bookkeeping read_analyses()/write_analyses() already do internally.
822
823    Relies on the same invariant read_analyses() and write_analyses()
824    already depend on: a sentence's own tokens form a contiguous,
825    matching-order run in `tokengraph` (see this module's own docstring).
826    `sentence_tokengraph` is the slice of `tokengraph` between that
827    sentence's first and last token's positions, inclusive -- which also
828    picks up any implied/elided tokens (tokentype in IMPLIED_TOKENTYPES)
829    interspersed within that range, since those were never part of
830    `sentence.tokens` to begin with but do belong to that sentence's own
831    analysis. `sentence_verbalunits` is every VerbalExpression whose id
832    falls within that same slice.
833
834    One consequence of using [first, last] *real* token positions as the
835    slice boundary, shared with read_analyses()'s own sentence
836    reconstruction: an implied token placed AFTER a sentence's last real
837    token (rather than nested between two real tokens) falls just outside
838    that slice, since there's no further real token of the same sentence
839    to bound it from above -- e.g. a one-real-token sentence whose only
840    verbal expression is an implied eimi that comes after it (see
841    tests/test_serialization.py's
842    test_split_excludes_a_trailing_implied_token_past_the_sentences_last_real_token).
843    An implied token nested between two real tokens of the same sentence
844    is included as expected; only this specific trailing case isn't.
845
846    Raises ValueError for a sentence with no tokens at all, or whose first
847    or last token id isn't present in `tokengraph` -- both should be
848    impossible for a triple that actually came from read_analyses(), which
849    already guarantees this by construction, but this function checks
850    explicitly anyway rather than trusting the caller, since nothing stops
851    it being called with a hand-built triple too.
852    """
853    id_position: Dict[str, int] = {tok.id: i for i, tok in enumerate(tokengraph)}
854
855    result: List[Tuple[List[TokenAnalysis], List[VerbalExpression]]] = []
856    for s_idx, sentence in enumerate(sentences):
857        if not sentence.tokens:
858            raise ValueError(f"sentence at index {s_idx} has no tokens")
859
860        first_id = sentence.tokens[0].id
861        last_id = sentence.tokens[-1].id
862        if first_id not in id_position or last_id not in id_position:
863            raise ValueError(
864                f"sentence at index {s_idx} (tokens {first_id!r}.."
865                f"{last_id!r}) has a boundary token not present in the "
866                "given tokengraph"
867            )
868
869        start = id_position[first_id]
870        end = id_position[last_id]
871        sentence_tokengraph = tokengraph[start : end + 1]
872        sentence_ids = {tok.id for tok in sentence_tokengraph}
873        sentence_verbalunits = [vu for vu in verbalunits if vu.id in sentence_ids]
874        result.append((sentence_tokengraph, sentence_verbalunits))
875
876    return result

The inverse of what write_analyses()/serialize_analyses() flatten together: given the same (tokengraph, verbalunits, sentences) triple read_analyses() returns (or that analyze_sources()/combined_tokengraph() produce before ever being written to a file), 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]. Useful for anything that wants to review or render one sentence's analysis at a time (e.g. a sentence-picker UI, like marimo/greek_syntaxer_review.py) without re-running analysis or re-deriving the same id-position bookkeeping read_analyses()/write_analyses() already do internally.

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 (see this module's own docstring). 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 (tokentype in IMPLIED_TOKENTYPES) interspersed within that range, since those were never part of sentence.tokens to begin with but do belong to that sentence's own analysis. 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, shared with read_analyses()'s own sentence reconstruction: 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 -- e.g. a one-real-token sentence whose only verbal expression is an implied eimi that comes after it (see tests/test_serialization.py's test_split_excludes_a_trailing_implied_token_past_the_sentences_last_real_token). An implied token nested between two real tokens of the same sentence is included as expected; only this specific trailing case isn't.

Raises ValueError for a sentence with no tokens at all, or whose first or last token id isn't present in tokengraph -- both should be impossible for a triple that actually came from read_analyses(), which already guarantees this by construction, but this function checks explicitly anyway rather than trusting the caller, since nothing stops it being called with a hand-built triple too.

@dataclass
class CtsDataRow:
54@dataclass
55class CtsDataRow:
56    """One passage from a `#!ctsdata` source file: `urnbase` (the first 4
57    colon-separated parts of the row's own CTS URN, rejoined with ':', plus
58    a trailing ':') and `citation` (the URN's 5th part) together
59    reconstruct the full URN as `urnbase + citation` -- the same
60    concatenation greek_syntaxer_workflow.py's manual-entry form uses for its own
61    `urnbase`/`citation_context` fields. `text` is the passage's own
62    surface text, verbatim."""
63
64    urnbase: str
65    citation: str
66    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 -- the same concatenation greek_syntaxer_workflow.py's manual-entry form uses for its own urnbase/citation_context fields. 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]:
 69def read_ctsdata(path: str, delimiter: str = "|") -> List[CtsDataRow]:
 70    """Read every `#!ctsdata` block in `path` and return their rows,
 71    concatenated in file order, as a list of CtsDataRow -- see this
 72    module's docstring for the file shape and what counts as malformed.
 73
 74    `delimiter` is the column separator used both for the header line
 75    ('urn' + delimiter + 'text') and for splitting each data row; '|' by
 76    default, matching serialization.py's own convention. Pass a different
 77    character if the source file's own text content might contain '|' (the
 78    same escaping caveat serialization.py's module docstring notes for its
 79    own fields applies here too -- there is no escaping mechanism for
 80    whichever character is chosen as the delimiter).
 81
 82    Raises ValueError, naming the offending line, for: a data line
 83    appearing before any '#!ctsdata' label; a label line with no header
 84    line before the next block or before the file ends; a header line that
 85    doesn't match `delimiter`-joined 'urn'/'text' exactly; a data row that
 86    isn't exactly 2 columns; a blank urn or text column; or a urn that
 87    doesn't split into exactly 5 colon-separated parts. Raises ValueError
 88    (not returning an empty list) if the file has no '#!ctsdata' block at
 89    all, so a caller can't mistake "wrong file" for "file with zero
 90    passages".
 91    """
 92    expected_header = delimiter.join(["urn", "text"])
 93
 94    with open(path, "r", encoding="utf-8") as f:
 95        raw_lines = f.read().splitlines()
 96
 97    rows: List[CtsDataRow] = []
 98    seen_block = False
 99    awaiting_header = False
100
101    for line_no, line in enumerate(raw_lines, start=1):
102        if line.strip() == "":
103            continue
104
105        if line == CTSDATA_LABEL:
106            if awaiting_header:
107                raise ValueError(
108                    f"line {line_no}: a {CTSDATA_LABEL!r} block has a label "
109                    "line but no header line before the next block starts"
110                )
111            seen_block = True
112            awaiting_header = True
113            continue
114
115        if not seen_block:
116            raise ValueError(
117                f"line {line_no}: data line {line!r} appears before any "
118                f"{CTSDATA_LABEL!r} block label"
119            )
120
121        if awaiting_header:
122            if line != expected_header:
123                raise ValueError(
124                    f"line {line_no}: expected header {expected_header!r} "
125                    f"for a {CTSDATA_LABEL!r} block, got {line!r}"
126                )
127            awaiting_header = False
128            continue
129
130        parts = line.split(delimiter)
131        if len(parts) != 2:
132            raise ValueError(
133                f"line {line_no}: {CTSDATA_LABEL!r} row has {len(parts)} "
134                f"column(s) (delimiter {delimiter!r}), expected 2: {line!r}"
135            )
136        urn, text = parts
137        if urn == "":
138            raise ValueError(f"line {line_no}: {CTSDATA_LABEL!r} row has an empty urn column")
139        if text == "":
140            raise ValueError(f"line {line_no}: {CTSDATA_LABEL!r} row has an empty text column")
141
142        urn_parts = urn.split(":")
143        if len(urn_parts) != 5:
144            raise ValueError(
145                f"line {line_no}: urn {urn!r} has {len(urn_parts)} "
146                "colon-separated part(s), expected 5 (e.g. "
147                "'urn:cts:greekLit:tlg0059.tlg030.perseus-grc2:1')"
148            )
149        citation = urn_parts[4]
150        if citation == "":
151            raise ValueError(
152                f"line {line_no}: urn {urn!r} has an empty final (citation) part"
153            )
154        urnbase = ":".join(urn_parts[:4]) + ":"
155
156        rows.append(CtsDataRow(urnbase=urnbase, citation=citation, text=text))
157
158    if not seen_block:
159        raise ValueError(f"file has no {CTSDATA_LABEL!r} block")
160    if awaiting_header:
161        raise ValueError(
162            f"a {CTSDATA_LABEL!r} block has a label line but no header "
163            "line (and no data) -- the file ends too early"
164        )
165
166    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, matching serialization.py's own convention. Pass a different character if the source file's own text content might contain '|' (the same escaping caveat serialization.py's module docstring notes for its own fields applies here too -- 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 = 32000) -> int:
148def estimate_max_tokens(
149    num_tokens: int,
150    *,
151    safety_margin: float = DEFAULT_SAFETY_MARGIN,
152    floor: int = DEFAULT_FLOOR,
153    ceiling: int = DEFAULT_CEILING,
154) -> int:
155    """Estimate a `max_tokens` budget for a SyntaxAnalysis call over a
156    sentence with `num_tokens` input tokens.
157
158    `raw = intercept + slope * num_tokens` comes from the calibrated (or
159    fallback) linear fit (see _load_calibration()); `safety_margin`
160    multiplies that to leave room for the reasoning field's length being
161    only roughly, not exactly, a function of passage length. The result is
162    clamped to `[floor, ceiling]` -- `floor` guards against a degenerate
163    tiny estimate for a 1-2 token sentence, `ceiling` is a hard cap you
164    should set to your actual model's real max-output-tokens limit (see
165    DEFAULT_CEILING's docstring note).
166
167    Raises ValueError if `num_tokens` is negative.
168    """
169    if num_tokens < 0:
170        raise ValueError(f"num_tokens must be >= 0, got {num_tokens}")
171
172    calibration = _load_calibration()
173    raw = calibration["intercept"] + calibration["slope"] * num_tokens
174    budget = math.ceil(raw * safety_margin)
175    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 (see DEFAULT_CEILING's docstring note).

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 = 32000, initial_max_tokens: Optional[int] = None, disable_cache: bool = False):
224def analyze_with_retry(
225    passage: str,
226    tokens: List[Token],
227    *,
228    max_retries: int = 1,
229    growth_factor: float = 2.0,
230    safety_margin: float = DEFAULT_SAFETY_MARGIN,
231    floor: int = DEFAULT_FLOOR,
232    ceiling: int = DEFAULT_CEILING,
233    initial_max_tokens: Optional[int] = None,
234    disable_cache: bool = False,
235):
236    """Call `analyze()`, detecting truncation and retrying with a larger
237    `max_tokens` budget instead of either crashing or silently returning an
238    incomplete result.
239
240    The starting budget is `initial_max_tokens` if given, else
241    `estimate_max_tokens(len(tokens), safety_margin=safety_margin,
242    floor=floor, ceiling=ceiling)`.
243
244    `disable_cache` is the caller-facing counterpart to the automatic
245    cache-bypass described below: pass `True` to force EVERY attempt in
246    this call (not just an internal malformed-output retry) to bypass
247    DSPy's own LM response cache. This is for a human deliberately
248    resubmitting the exact same `passage`/`tokens` -- e.g. from a "disable
249    cache" checkbox in a notebook, while iterating on the configured
250    model, the `SyntaxAnalysis` prompt, or the schema -- where the whole
251    point is a genuinely fresh LM call, not a replay of whatever this
252    passage returned last time. Leave it `False` (the default) for
253    ordinary pipeline use, where the cache is a real cost/latency win.
254
255    After each attempt, truncation is checked two ways: `_missing_token_ids`
256    against the result (the primary, LM-independent signal -- works
257    whenever a result exists at all, parsed or not, including under
258    DummyLM in tests) and, if the call raised `AdapterParseError` instead
259    of returning a result (the JSON was cut off badly enough to not parse
260    at all), `_finish_reason_was_length()` as a corroborating check.
261
262    An `AdapterParseError` whose `finish_reason` ISN'T "length" means the
263    response was well-terminated but still malformed somewhere -- e.g. one
264    `tokengraph` entry coming back as a bare `["id"]` list instead of a
265    full `TokenAnalysis` object. A bigger budget wouldn't have fixed that,
266    but the malformation itself is very often a one-off sampling glitch
267    rather than a systematic prompt/schema problem, so it's retried once
268    too (still counted against `max_retries`, at the SAME budget) with
269    dspy's own response cache explicitly bypassed for that one attempt
270    (`config={"cache": False, ...}`) -- without that, an identical request
271    would just replay the identical broken response, retrying nothing. If
272    that retry also fails to parse, or `max_retries` is already exhausted,
273    the exception propagates. (When `disable_cache` is already `True`, this
274    one-shot bypass is redundant but harmless -- the cache is off either
275    way.)
276
277    If truncation is detected and there's still a retry available (fewer
278    than `max_retries` attempts so far, and the budget hasn't already hit
279    `ceiling`), the budget is multiplied by `growth_factor` (capped at
280    `ceiling`) and the call is retried. `max_tokens` is part of DSPy's own
281    LM cache key, so a retry with a different budget always reaches the LM
282    again rather than replaying a cached truncated response.
283
284    Once retries are exhausted: if the last attempt raised, that exception
285    propagates (there's no result to fall back to). If the last attempt
286    returned a still-incomplete result, it's returned anyway -- with a
287    `UserWarning` naming the missing token ids -- rather than raising,
288    matching this codebase's existing convention of surfacing analysis
289    problems as warnings (see pipeline.py's own validate() warning-printing
290    and this module's docstring) instead of treating an imperfect LM
291    result as fatal.
292    """
293    budget = initial_max_tokens if initial_max_tokens is not None else estimate_max_tokens(
294        len(tokens), safety_margin=safety_margin, floor=floor, ceiling=ceiling
295    )
296
297    attempt = 0
298    retry_bypass_cache = False  # one-shot: set only for the malformed-output retry below
299    while True:
300        old_budget = budget
301        call_config = {"max_tokens": budget}
302        if disable_cache or retry_bypass_cache:
303            call_config["cache"] = False
304        retry_bypass_cache = False  # only meant for the one attempt it was set for
305        try:
306            result = analyze(passage=passage, tokens=tokens, config=call_config)
307        except AdapterParseError as exc:
308            if attempt >= max_retries:
309                raise
310            if budget < ceiling and _finish_reason_was_length():
311                attempt += 1
312                budget = min(ceiling, math.ceil(budget * growth_factor))
313                warnings.warn(
314                    f"SyntaxAnalysis call truncated at max_tokens={old_budget} before it "
315                    f"could be parsed at all; retrying with max_tokens={budget} "
316                    f"(attempt {attempt}/{max_retries}).",
317                    stacklevel=2,
318                )
319                continue
320            # Not a (detectable) truncation -- the response finished
321            # normally but was malformed somewhere (see this function's own
322            # docstring). Retry once more at the SAME budget, but with
323            # dspy's cache explicitly bypassed for that one attempt, so a
324            # retry is a genuinely fresh LM call rather than a replay of
325            # the same broken response -- otherwise, if this exact request
326            # was already served from cache (e.g. a repeat of an earlier,
327            # already-broken run), simply calling analyze() again would
328            # just return the identical malformed result again and again,
329            # even with dspy's cache enabled as normal for every other call.
330            attempt += 1
331            retry_bypass_cache = True
332            warnings.warn(
333                f"SyntaxAnalysis call at max_tokens={old_budget} returned output that "
334                f"failed to parse, but doesn't look like a truncation (finish_reason "
335                f"wasn't 'length'): {exc} Retrying once at the same budget with the LM "
336                f"cache bypassed, in case this was a one-off malformed-output glitch "
337                f"(attempt {attempt}/{max_retries}).",
338                stacklevel=2,
339            )
340            continue
341
342        missing = _missing_token_ids(tokens, result)
343        truncated = bool(missing) or _finish_reason_was_length()
344        if truncated and attempt < max_retries and budget < ceiling:
345            attempt += 1
346            budget = min(ceiling, math.ceil(budget * growth_factor))
347            warnings.warn(
348                f"SyntaxAnalysis call at max_tokens={old_budget} returned a tokengraph "
349                f"missing {len(missing)} input token id(s) ({sorted(missing)}); retrying "
350                f"with a larger max_tokens={budget} (attempt {attempt}/{max_retries}).",
351                stacklevel=2,
352            )
353            continue
354
355        if truncated:
356            missing_desc = sorted(missing) if missing else "(finish_reason indicated truncation, but no ids are directly missing)"
357            warnings.warn(
358                f"SyntaxAnalysis call still looks truncated after {attempt} retry(ies) "
359                f"(max_tokens={old_budget}) -- returning it anyway. Missing input token "
360                f"id(s): {missing_desc}.",
361                stacklevel=2,
362            )
363
364        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).

disable_cache is the caller-facing counterpart to the automatic cache-bypass described below: pass True to force EVERY attempt in this call (not just an internal malformed-output retry) to bypass DSPy's own LM response cache. This is for a human deliberately resubmitting the exact same passage/tokens -- e.g. from a "disable cache" checkbox in a notebook, while iterating on the configured model, the SyntaxAnalysis prompt, or the schema -- where the whole point is a genuinely fresh LM call, not a replay of whatever this passage returned last time. Leave it False (the default) for ordinary pipeline use, where the cache is a real cost/latency win.

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.

An AdapterParseError whose finish_reason ISN'T "length" means the response was well-terminated but still malformed somewhere -- e.g. one tokengraph entry coming back as a bare ["id"] list instead of a full TokenAnalysis object. A bigger budget wouldn't have fixed that, but the malformation itself is very often a one-off sampling glitch rather than a systematic prompt/schema problem, so it's retried once too (still counted against max_retries, at the SAME budget) with dspy's own response cache explicitly bypassed for that one attempt (config={"cache": False, ...}) -- without that, an identical request would just replay the identical broken response, retrying nothing. If that retry also fails to parse, or max_retries is already exhausted, the exception propagates. (When disable_cache is already True, this one-shot bypass is redundant but harmless -- the cache is off either way.)

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 (there's no result to fall back to). 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 existing convention of surfacing analysis problems as warnings (see pipeline.py's own validate() warning-printing and this module's docstring) instead of treating an imperfect LM result as fatal.

def get_calibration() -> dict:
140def get_calibration() -> dict:
141    """Public introspection: what (intercept, slope) is estimate_max_tokens()
142    currently using, and did it come from calibrate_max_tokens.py's fit or
143    from this module's untuned fallback? See _load_calibration()'s
144    docstring for the shape returned."""
145    return _load_calibration()

Public introspection: what (intercept, slope) is estimate_max_tokens() currently using, and did it come from calibrate_max_tokens.py's fit or from this module's untuned fallback? See _load_calibration()'s docstring for the shape returned.

DEFAULT_CEILING = 32000
def syntax_metric( gold: dspy.primitives.example.Example, pred: dspy.primitives.prediction.Prediction, trace: Optional[Any] = None, pred_name: Optional[str] = None, pred_trace: Optional[Any] = None, program_trace: Optional[Any] = None) -> dspy.primitives.prediction.Prediction:
 85def syntax_metric(
 86    gold: "dspy.Example",
 87    pred: "dspy.Prediction",
 88    trace: Optional[Any] = None,
 89    pred_name: Optional[str] = None,
 90    pred_trace: Optional[Any] = None,
 91    program_trace: Optional[Any] = None,
 92) -> "dspy.Prediction":
 93    """Score a SyntaxAnalysis prediction against a gold answer built from a
 94    GoldExample (see optimize_gepa.py's build_trainset()).
 95
 96    `gold` must have `.tokengraph` (list of TokenAnalysis) and
 97    `.verbalunits` (list of VerbalExpression) fields -- the same shape
 98    SyntaxAnalysis itself outputs, so the exact same dict-or-model access
 99    pattern works for both the gold example and a live prediction.
100
101    Returns a dspy.Prediction(score=..., feedback=...) -- GEPA's expected
102    "ScoreWithFeedback" shape (see dspy.teleprompt.gepa.gepa_utils). `score`
103    is a single float in [0, 1], a weighted blend of three dimensions
104    (relations, verbal-expression classification, and basic per-token
105    fields); `feedback` is a human-readable list of every mismatch found,
106    for GEPA's reflection LM to read. There's only one predictor in
107    `analyze` (SyntaxAnalysis is a single dspy.ChainOfThought), so
108    `pred_name`/`pred_trace` are accepted for protocol compatibility but
109    not used to change scoring -- GEPA will use the same feedback whether
110    it's asking at the program level or the (only) predictor level.
111
112    The returned Prediction also carries the three unblended dimension
113    scores as their own fields -- `field_score`, `relation_score`,
114    `vu_score` -- alongside `score` and `feedback`. GEPA itself only reads
115    `score`/`feedback`, so this is purely additive (existing callers that
116    only look at those two are unaffected), but it's useful for any caller
117    that wants to know WHERE a prediction fell down rather than just by how
118    much -- e.g. a model-bakeoff script uses these to tell "gets the
119    surface tokenization right but can't chase multi-hop relations" apart
120    from "just generally worse," which a single blended number can't
121    distinguish.
122
123    The 0.2/0.5/0.3 weighting (fields/relations/verbal-expressions) is a
124    judgment call, not something syntax_model.md specifies -- relations are
125    weighted highest since they're the heart of the scheme, but this is an
126    easy knob to retune once real GEPA runs are observed.
127    """
128    problems = []
129
130    gold_tg = {_get(t, "id"): t for t in gold.tokengraph}
131    pred_tokengraph = list(getattr(pred, "tokengraph", None) or [])
132    pred_tg = {}
133    for tok in pred_tokengraph:
134        tid = _get(tok, "id")
135        if tid in pred_tg:
136            problems.append(
137                f"tokengraph has more than one entry for id {tid!r} "
138                "(only the first is scored)"
139            )
140            continue
141        pred_tg[tid] = tok
142
143    gold_ids = set(gold_tg)
144    pred_ids = set(pred_tg)
145
146    field_total = 0
147    field_correct = 0
148
149    for tid in gold_ids:
150        g = gold_tg[tid]
151        gtext = _get(g, "token")
152        if tid not in pred_tg:
153            problems.append(f"token {tid} ({gtext!r}) is missing from tokengraph entirely")
154            field_total += 2
155            continue
156        p = pred_tg[tid]
157        for field in ("tokentype", "verbalunitid"):
158            gval = _get(g, field)
159            pval = _get(p, field)
160            field_total += 1
161            if gval == pval:
162                field_correct += 1
163            else:
164                problems.append(f"token {tid} ({gtext!r}): expected {field}={gval!r}, got {pval!r}")
165        gval = _get(g, "lemma")
166        if gval is not None:
167            pval = _get(p, "lemma")
168            field_total += 1
169            if gval == pval:
170                field_correct += 1
171            else:
172                problems.append(f"token {tid} ({gtext!r}): expected lemma={gval!r}, got {pval!r}")
173
174    extra_tok_ids = pred_ids - gold_ids
175    if extra_tok_ids:
176        problems.append(
177            f"tokengraph has entries for id(s) not in the input tokens: {sorted(extra_tok_ids)}"
178        )
179
180    gold_rels = _relation_triples(gold_tg)
181    pred_rels = _relation_triples(pred_tg)
182    missing_rels = gold_rels - pred_rels
183    extra_rels = pred_rels - gold_rels
184
185    for tid, label, related in sorted(missing_rels):
186        gtext = _get(gold_tg.get(tid), "token")
187        problems.append(f"token {tid} ({gtext!r}) is missing the relation {label!r} -> {related}")
188    for tid, label, related in sorted(extra_rels):
189        text = _get(pred_tg.get(tid), "token")
190        problems.append(
191            f"token {tid} ({text!r}) has an unexpected relation {label!r} -> {related} "
192            "not in the gold answer"
193        )
194
195    relation_denominator = len(gold_rels) + len(extra_rels)
196    relation_correct = len(gold_rels) - len(missing_rels)
197    relation_score = _safe_ratio(relation_correct, relation_denominator)
198
199    gold_vu = {_get(v, "id"): v for v in gold.verbalunits}
200    pred_vu = {_get(v, "id"): v for v in (getattr(pred, "verbalunits", None) or [])}
201
202    vu_total = 0
203    vu_correct = 0
204    for vid, gv in gold_vu.items():
205        gtext = _get(gold_tg.get(vid), "token") or vid
206        if vid not in pred_vu:
207            problems.append(f"verbal expression at {vid} ({gtext!r}) is missing from verbalunits entirely")
208            vu_total += 2
209            continue
210        pv = pred_vu[vid]
211        for field in ("syntactic_type", "semantic_type"):
212            vu_total += 1
213            if _get(gv, field) == _get(pv, field):
214                vu_correct += 1
215            else:
216                problems.append(
217                    f"verbal expression at {vid} ({gtext!r}): expected {field}={_get(gv, field)!r}, "
218                    f"got {_get(pv, field)!r}"
219                )
220
221    extra_vu_ids = set(pred_vu) - set(gold_vu)
222    if extra_vu_ids:
223        problems.append(
224            "verbalunits has unexpected extra entries not anchored on a gold verbal "
225            f"expression: {sorted(extra_vu_ids)}"
226        )
227
228    field_score = _safe_ratio(field_correct, field_total)
229    vu_score = _safe_ratio(vu_correct, vu_total)
230
231    score = 0.2 * field_score + 0.5 * relation_score + 0.3 * vu_score
232
233    if not problems:
234        feedback = (
235            "Perfect match with the gold analysis: every token's fields, relations, "
236            "and verbal-expression classification are correct."
237        )
238    else:
239        feedback = (
240            f"Score {score:.2f} (fields {field_score:.2f}, relations {relation_score:.2f}, "
241            f"verbal expressions {vu_score:.2f}). Problems found:\n- " + "\n- ".join(problems)
242        )
243
244    return dspy.Prediction(
245        score=score,
246        feedback=feedback,
247        field_score=field_score,
248        relation_score=relation_score,
249        vu_score=vu_score,
250    )

Score a SyntaxAnalysis prediction against a gold answer built from a GoldExample (see optimize_gepa.py's build_trainset()).

gold must have .tokengraph (list of TokenAnalysis) and .verbalunits (list of VerbalExpression) fields -- the same shape SyntaxAnalysis itself outputs, so the exact same dict-or-model access pattern works for both the gold example and a live prediction.

Returns a dspy.Prediction(score=..., feedback=...) -- GEPA's expected "ScoreWithFeedback" shape (see dspy.teleprompt.gepa.gepa_utils). score is a single float in [0, 1], a weighted blend of three dimensions (relations, verbal-expression classification, and basic per-token fields); feedback is a human-readable list of every mismatch found, for GEPA's reflection LM to read. There's only one predictor in analyze (SyntaxAnalysis is a single dspy.ChainOfThought), so pred_name/pred_trace are accepted for protocol compatibility but not used to change scoring -- GEPA will use the same feedback whether it's asking at the program level or the (only) predictor level.

The returned Prediction also carries the three unblended dimension scores as their own fields -- field_score, relation_score, vu_score -- alongside score and feedback. GEPA itself only reads score/feedback, so this is purely additive (existing callers that only look at those two are unaffected), but it's useful for any caller that wants to know WHERE a prediction fell down rather than just by how much -- e.g. a model-bakeoff script uses these to tell "gets the surface tokenization right but can't chase multi-hop relations" apart from "just generally worse," which a single blended number can't distinguish.

The 0.2/0.5/0.3 weighting (fields/relations/verbal-expressions) is a judgment call, not something syntax_model.md specifies -- relations are weighted highest since they're the heart of the scheme, but this is an easy knob to retune once real GEPA runs are observed.