arsgrammatica

arsgrammatica: a DSPy program analyzing the syntax of Latin passages according to the scheme documented in syntax_model.md.

  1"""arsgrammatica: a DSPy program analyzing the syntax of Latin passages
  2according to the scheme documented in syntax_model.md.
  3"""
  4 
  5from .models import Token, CitedText, Sentence, VerbalExpression, TokenAnalysis, RelationLabel
  6from .mermaid import tokengraph_to_mermaid, token_label, save_mermaid
  7from .dot import tokengraph_to_dot, compute_graph_depths, max_graph_depth, save_dot
  8from .graphs import GraphMetrics, tokengraph_to_networkx, graph_metrics
  9from .verbal_units import (
 10    assign_verbal_units,
 11    assign_verbal_unit_colors,
 12    compute_aat_depths,
 13    compute_subordination_depths,
 14    find_governing_verbal_expression,
 15    max_subordination_depth,
 16    find_unanchored_coordinated_verbs,
 17)
 18from .rendering import tokengraph_to_text, tokengraph_to_html, tokengraph_to_depth_html
 19from .latin_syntax_dspy import (
 20    SentenceAnalysis,
 21    analyze,
 22    validate,
 23    print_analysis,
 24)
 25from .segmentation_dspy import SegmentPassage, segment_sources
 26from .pipeline import (
 27    analyze_string,
 28    analyze_sources,
 29    analyze_selected_passages,
 30    analyze_ctsdata,
 31    combined_tokengraph,
 32)
 33from .passage_grouping import group_passages_by_sentence_boundary
 34from .serialization import (
 35    LMInfo,
 36    serialize_analyses,
 37    write_analyses,
 38    read_analyses,
 39    split_analysis_by_sentence,
 40)
 41from .ctsdata import read_ctsdata
 42from .segmentation_serialization import (
 43    serialize_segmentation,
 44    write_segmentation,
 45    read_segmentation,
 46)
 47from .token_budget import estimate_max_tokens, analyze_with_retry, get_calibration, DEFAULT_CEILING
 48from .lm_cost import LMCostSummary, summarize_lm_cost, format_lm_cost
 49from .lewis_short import (
 50    LewisShortEntry,
 51    LewisShortMatch,
 52    LewisShortLexicon,
 53    LEWIS_SHORT_URL,
 54    read_lewis_short,
 55    read_lewis_short_from_url,
 56)
 57
 58# attgraph() depends on the separate `aat` package, which most callers of
 59# arsgrammatica have no need to install at all -- not on PyPI, so
 60# `pip install git+https://github.com/neelsmith/aat.git` (not a bare
 61# `pip install aat`) is what actually installs it; pyproject.toml's "aat"
 62# extra is only reachable if arsgrammatica itself is pip-installed
 63# (`pip install '.[aat]'` from a checkout, or an editable install) rather
 64# than just run from a checkout on sys.path, which is how this project is
 65# normally used. Importing it lazily/defensively here, rather than
 66# unconditionally like every other submodule above, means `import
 67# arsgrammatica` still succeeds without `aat` installed; only actually
 68# calling `arsgrammatica.attgraph(...)` without it raises, with a message
 69# naming the missing package and how to get it.
 70try:
 71    from .aat_bridge import attgraph
 72except ImportError as _exc:  # pragma: no cover -- exercised only when `aat` isn't installed
 73    # `except ... as name` implicitly deletes `name` once this block ends
 74    # (a Python gotcha, not specific to this code) -- reassign to a plain
 75    # variable first so attgraph(), called later, can still reference it.
 76    _aat_import_error = _exc
 77
 78    def attgraph(*args, **kwargs):
 79        raise ImportError(
 80            "attgraph() needs the separate 'aat' package "
 81            "(https://github.com/neelsmith/aat), which isn't installed. "
 82            "Install it with: pip install git+https://github.com/"
 83            "neelsmith/aat.git -- (if you've also `pip install`ed "
 84            "arsgrammatica itself, rather than just running it from a "
 85            "checkout, `pip install '.[aat]'` from its own directory "
 86            "does the same thing via this package's 'aat' extra)."
 87        ) from _aat_import_error
 88
 89__all__ = [
 90    "Token",
 91    "CitedText",
 92    "Sentence",
 93    "VerbalExpression",
 94    "TokenAnalysis",
 95    "RelationLabel",
 96    "tokengraph_to_mermaid",
 97    "token_label",
 98    "save_mermaid",
 99    "tokengraph_to_dot",
100    "compute_graph_depths",
101    "max_graph_depth",
102    "save_dot",
103    "GraphMetrics",
104    "tokengraph_to_networkx",
105    "graph_metrics",
106    "assign_verbal_units",
107    "assign_verbal_unit_colors",
108    "compute_aat_depths",
109    "compute_subordination_depths",
110    "find_governing_verbal_expression",
111    "max_subordination_depth",
112    "find_unanchored_coordinated_verbs",
113    "tokengraph_to_text",
114    "tokengraph_to_html",
115    "tokengraph_to_depth_html",
116    "SentenceAnalysis",
117    "analyze",
118    "analyze_string",
119    "validate",
120    "print_analysis",
121    "SegmentPassage",
122    "segment_sources",
123    "analyze_sources",
124    "analyze_selected_passages",
125    "analyze_ctsdata",
126    "combined_tokengraph",
127    "group_passages_by_sentence_boundary",
128    "LMInfo",
129    "serialize_analyses",
130    "write_analyses",
131    "read_analyses",
132    "split_analysis_by_sentence",
133    "read_ctsdata",
134    "serialize_segmentation",
135    "write_segmentation",
136    "read_segmentation",
137    "estimate_max_tokens",
138    "analyze_with_retry",
139    "get_calibration",
140    "DEFAULT_CEILING",
141    "LMCostSummary",
142    "summarize_lm_cost",
143    "format_lm_cost",
144    "LewisShortEntry",
145    "LewisShortMatch",
146    "LewisShortLexicon",
147    "LEWIS_SHORT_URL",
148    "read_lewis_short",
149    "read_lewis_short_from_url",
150    "attgraph",
151]
class Token(pydantic.main.BaseModel):
30class Token(BaseModel):
31    """A single pre-segmented token with a stable id.
32 
33    `citation` is optional so this model still works for citation-free
34    callers -- e.g. a test fixture built directly from a canned tokengraph,
35    with no CitedText source at all -- as well as for the citation-aware
36    segmentation stage (segmentation_dspy.py), which is the only thing that
37    actually populates it, knowing which CitedText source unit each token
38    came from."""
39 
40    id: str = Field(description="Stable token id, globally unique and sequential across the whole input, e.g. 't0', 't1', ...")
41    text: str = Field(description="The token's surface text, exactly as it appears in the source.")
42    citation: Optional[str] = Field(
43        default=None,
44        description="Citation label of the source unit this token came from (e.g. 'Aeneid 1.1'), if known.",
45    )

A single pre-segmented token with a stable id.

citation is optional so this model still works for citation-free callers -- e.g. a test fixture built directly from a canned tokengraph, with no CitedText source at all -- as well as for the citation-aware segmentation stage (segmentation_dspy.py), which is the only thing that actually populates it, knowing which CitedText source unit each token came from.

id: str = PydanticUndefined

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

text: str = PydanticUndefined

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

citation: str | None = None

Citation label of the source unit this token came from (e.g. 'Aeneid 1.1'), if known.

class CitedText(pydantic.main.BaseModel):
18class CitedText(BaseModel):
19    """One citable unit of source text -- e.g. one line of poetry, one
20    section of prose -- paired with its citation label. A sequence of
21    these is segmentation_dspy.py's input: sentence boundaries do NOT need
22    to respect CitedText boundaries (one sentence may span several units),
23    but every resulting token still records which unit it came from via
24    Token.citation."""
25 
26    citation: str = Field(description="Citation label for this unit, e.g. 'Aeneid 1.1'.")
27    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_dspy.py's input: sentence boundaries do NOT need to respect CitedText boundaries (one sentence may span several units), but every resulting token still records which unit it came from via Token.citation.

citation: str = PydanticUndefined

Citation label for this unit, e.g. 'Aeneid 1.1'.

text: str = PydanticUndefined

This unit's raw text, exactly as written.

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

One sentence's worth of tokens, in reading order, as produced by the LLM-driven segmentation stage (segmentation_dspy.py). Token ids are global across the whole passage -- numbering continues across sentence boundaries rather than restarting at t0 for each sentence -- so a Sentence is a contiguous slice of the passage's id sequence, not an independently-numbered unit.

tokens: List[Token] = PydanticUndefined

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

class VerbalExpression(pydantic.main.BaseModel):
 61class VerbalExpression(BaseModel):
 62    """One entry in the table of verbal expressions (syntax_model.md, 'Table
 63    of verbal expressions'). Three constructions count as a verbal
 64    expression: finite verbs, infinitives (when part of indirect speech),
 65    and participles (when they have a *predicate* sense -- e.g. an
 66    ablative-absolute-like "Anco regnante...", 'while Ancus was reigning'
 67    -- rather than a purely *attributive* sense, like an ordinary adjective,
 68    e.g. "consentiens laus", 'universal praise': that case is NOT a verbal
 69    expression at all).
 70 
 71    Each construction has its own set of allowed `syntactic_type` values:
 72    a finite verb is 'independent', 'dependent', 'direct quote' (occurring
 73    in directly quoted speech, e.g. "est" in `"Tuum est," inquit,`), or
 74    'aside' (a verbal expression that interrupts the surrounding syntax,
 75    e.g. "dixerim" in "pace dixerim deum"); an infinitive anchoring an
 76    indirect statement is always 'indirect statement'. syntax_model.md
 77    doesn't specify a dedicated syntactic_type value for participles --
 78    this codebase's convention is to use 'dependent' for a predicate
 79    participle's verbal expression, since a circumstantial participle /
 80    ablative absolute functions like a subordinate clause; flag it if you
 81    intended something else."""
 82 
 83    id: str = Field(
 84        description=(
 85            "The token id (from the input `tokens` list) of the finite verb, "
 86            "infinitive, or predicate-sense participle that anchors this "
 87            "verbal expression. For a compound perfect/pluperfect passive or "
 88            "future-infinitive form (participle + a form of *sum*), use the id "
 89            "of the participle or infinitive itself, NOT the auxiliary form of "
 90            "*sum* (which instead relates to it via the 'auxiliary' relation). "
 91            "For an implied/elided verbal expression (see "
 92            "TokenAnalysis's 'implied sum'/'continued discourse' tokentypes, "
 93            "IMPLIED_TOKENTYPES), use the new implied token's id instead. "
 94            "(A third IMPLIED_TOKENTYPES value, 'implied subject', is NOT "
 95            "a verbal expression and never gets an entry here -- see "
 96            "TokenAnalysis's own docstring.)"
 97        )
 98    )
 99    syntactic_type: Literal[
100        "independent", "dependent", "direct quote", "aside", "indirect statement"
101    ] = Field(
102        description=(
103            "For a finite verb: 'independent' (main/principal), 'dependent' "
104            "(subordinate/secondary), 'direct quote' (occurring in directly "
105            "quoted speech), or 'aside' (interrupts the surrounding syntax). "
106            "For an infinitive anchoring an indirect statement: 'indirect "
107            "statement'. For a predicate-sense participle: 'dependent' (this "
108            "codebase's convention; syntax_model.md doesn't specify)."
109        )
110    )
111    semantic_type: Literal[
112        "transitive active", "transitive passive", "intransitive", "linking verb"
113    ] = Field(description="The verb's semantic/voice type.")

One entry in the table of verbal expressions (syntax_model.md, 'Table of verbal expressions'). Three constructions count as a verbal expression: finite verbs, infinitives (when part of indirect speech), and participles (when they have a predicate sense -- e.g. an ablative-absolute-like "Anco regnante...", 'while Ancus was reigning' -- rather than a purely attributive sense, like an ordinary adjective, e.g. "consentiens laus", 'universal praise': that case is NOT a verbal expression at all).

Each construction has its own set of allowed syntactic_type values: a finite verb is 'independent', 'dependent', 'direct quote' (occurring in directly quoted speech, e.g. "est" in "Tuum est," inquit,), or 'aside' (a verbal expression that interrupts the surrounding syntax, e.g. "dixerim" in "pace dixerim deum"); an infinitive anchoring an indirect statement is always 'indirect statement'. syntax_model.md doesn't specify a dedicated syntactic_type value for participles -- this codebase's convention is to use 'dependent' for a predicate participle's verbal expression, since a circumstantial participle / ablative absolute functions like a subordinate clause; flag it if you intended something else.

id: str = PydanticUndefined

The token id (from the input tokens list) of the finite verb, infinitive, or predicate-sense participle that anchors this verbal expression. For a compound perfect/pluperfect passive or future-infinitive form (participle + a form of sum), use the id of the participle or infinitive itself, NOT the auxiliary form of sum (which instead relates to it via the 'auxiliary' relation). For an implied/elided verbal expression (see TokenAnalysis's 'implied sum'/'continued discourse' tokentypes, IMPLIED_TOKENTYPES), use the new implied token's id instead. (A third IMPLIED_TOKENTYPES value, 'implied subject', is NOT a verbal expression and never gets an entry here -- see TokenAnalysis's own docstring.)

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

For a finite verb: 'independent' (main/principal), 'dependent' (subordinate/secondary), 'direct quote' (occurring in directly quoted speech), or 'aside' (interrupts the surrounding syntax). For an infinitive anchoring an indirect statement: 'indirect statement'. For a predicate-sense participle: 'dependent' (this codebase's convention; syntax_model.md doesn't specify).

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

The verb's semantic/voice type.

class TokenAnalysis(pydantic.main.BaseModel):
241class TokenAnalysis(BaseModel):
242    """One entry per token in the dependency graph (syntax_model.md,
243    'Token-level table of dependencies'). Per syntax_model.md's 'Incomplete
244    status' section, not every token will have a relation -- leave the
245    relatedtoken*/relationship* fields unset when none of the documented
246    relations apply.
247
248    Most entries correspond 1:1 to an entry in the input `tokens` list. The
249    exceptions are the three IMPLIED_TOKENTYPES values below, which fall
250    into two DIFFERENT groups. syntax_model.md's 'understood or implied
251    verbal expressions' section documents two situations where a VERBAL
252    EXPRESSION exists grammatically but has no surface realization at all
253    in the passage, and this codebase distinguishes them with two distinct
254    tokentype values rather than one generic 'implied':
255
256    - 'implied sum': an elided form of *sum* -- syntax_model.md's three
257      elided-sum sub-cases (a bare predicate construction, a compound
258      perfect passive/future infinitive missing its auxiliary, or the
259      always-implied present participle of *sum*, which doesn't exist in
260      Latin at all) all use this one value.
261    - 'continued discourse': a governing verb of indirect discourse left
262      unrepeated across several continuation clauses.
263
264    'continued discourse' always anchors its own entry in `verbalunits`,
265    same as any other verbal expression -- and so do two of 'implied
266    sum's own three sub-cases (the bare predicate construction, and the
267    always-implied present participle of *sum*). The THIRD 'implied sum'
268    sub-case -- a compound perfect passive/future infinitive with its
269    auxiliary omitted (e.g. 'facti' for 'facti sunt') -- is the one
270    exception: there, a real, already-present participle/infinitive
271    anchors the verbal expression instead (per VerbalExpression.id's own
272    docstring), and the implied token merely relates to IT via
273    'auxiliary', exactly as a written-out auxiliary would; it gets no
274    `verbalunits` entry of its own in that sub-case. The third
275    IMPLIED_TOKENTYPES value is a DIFFERENT kind of gap entirely -- not a
276    missing verb, but a missing NOUN or pronoun:
277
278    - 'implied subject': a participle's own antecedent (the noun/pronoun
279      it agrees with via 'circumstantial participle') can itself be
280      unexpressed, most often when the participle agrees with a governing
281      verb's own unexpressed subject. This token never anchors a
282      `verbalunits` entry -- it isn't a verbal expression at all -- and
283      instead takes a normal 'subject' relation into the verb whose
284      subject it stands in for, exactly as if that subject had been
285      written out; the participle then relates to THIS token via
286      'circumstantial participle', same as it would to any real noun. See
287      latin_syntax_dspy.SentenceAnalysis's docstring for the worked example.
288
289    For any of the three, add a NEW entry here -- with a NEW id, not
290    present in `tokens` -- rather than skipping the construction entirely;
291    see latin_syntax_dspy.SentenceAnalysis's docstring for the full rules and
292    the id-naming convention."""
293
294    id: str = Field(
295        description=(
296            "For an ordinary entry, must match the id of the corresponding "
297            "entry in the input `tokens` list. For an implied token "
298            "(tokentype in IMPLIED_TOKENTYPES -- 'implied sum', "
299            "'continued discourse', or 'implied subject'), a NEW id not "
300            "used by any entry in `tokens` or elsewhere in this "
301            "tokengraph -- see SentenceAnalysis's docstring for the naming "
302            "convention."
303        )
304    )
305    token: Optional[str] = Field(
306        default=None,
307        description=(
308            "The token's surface text; should match the `text` of the input "
309            "token with this id. Leave as None ONLY for an implied token "
310            "(tokentype 'implied sum', 'continued discourse', or 'implied "
311            "subject') -- one with no surface realization in the passage at "
312            "all; every other tokentype must have real text."
313        ),
314    )
315    tokentype: Literal[
316        "lexical", "enclitic", "punctuation", "numeral", "praenomen", "abbreviation",
317        "implied sum", "continued discourse", "implied subject",
318    ] = Field(
319        description=(
320            "'numeral' is a number written NUMERICALLY -- Roman (e.g. "
321            "'XXV') or Arabic -- rather than spelled out as a word; a "
322            "number spelled out as an ordinary word (e.g. 'decem' for "
323            "'ten') is 'lexical' instead, even though it's semantically a "
324            "number -- e.g. in 'fratres Joseph decem', all three tokens "
325            "are 'lexical', not 'numeral'. "
326            "'praenomen' is specifically an abbreviated Roman first name "
327            "(e.g. 'M.' for Marcus), including its period; 'abbreviation' is "
328            "any other abbreviation, including its period (e.g. 'f.' for "
329            "filius, 'cos.' for consul) -- syntax_model.md's tokenization "
330            "section documents these as two distinct token types, not one. "
331            "'implied sum', 'continued discourse', and 'implied subject' "
332            "each mark a token with NO surface realization at all (see "
333            "this model's own docstring for the distinction) -- the only "
334            "three tokentypes whose `token` field is None and whose `id` "
335            "is not one of the input `tokens`' own ids. 'continued discourse' "
336            "always anchors its own verbal expression (an entry in "
337            "`verbalunits`), and so does 'implied sum' in two of its three "
338            "sub-cases -- EXCEPT the omitted-auxiliary sub-case (e.g. 'facti' "
339            "for 'facti sunt'), where the real, already-present participle "
340            "anchors instead and this implied token only relates to it via "
341            "'auxiliary' (see this model's own docstring). 'implied subject' "
342            "never anchors a verbal expression -- it stands in for an "
343            "unexpressed NOUN or pronoun, not a verb."
344        )
345    )
346 
347    lemma: Optional[str] = Field(default=None, description="Dictionary headword, for lexical tokens. Omit for punctuation.")
348    verbalunitid: Optional[str] = Field(
349        default=None,
350        description="If this token anchors a verbal expression in `verbalunits`, repeat its own id here; otherwise omit.",
351    )
352 
353    relatedtoken1: Optional[str] = Field(
354        default=None,
355        description=(
356            "Token id this token relates to (primary relation). For an "
357            "INDEPENDENT verb's own 'unit verb' relation, use the special "
358            "sentinel string 'root' instead of a token id -- 'root' is "
359            "reserved and must never be assigned as an actual token's id."
360        ),
361    )
362    relationship1: Optional[RelationLabel] = Field(default=None, description="The primary relation type, if any.")
363 
364    relatedtoken2: Optional[str] = Field(default=None, description="Token id this token relates to (secondary relation, used when relation1 is already occupied).")
365    relationship2: Optional[RelationLabel] = Field(default=None, description="The secondary relation type, if any.")

One entry per token in the dependency graph (syntax_model.md, 'Token-level table of dependencies'). Per syntax_model.md's 'Incomplete status' section, not every token will have a relation -- leave the relatedtoken*/relationship* fields unset when none of the documented relations apply.

Most entries correspond 1:1 to an entry in the input tokens list. The exceptions are the three IMPLIED_TOKENTYPES values below, which fall into two DIFFERENT groups. syntax_model.md's 'understood or implied verbal expressions' section documents two 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 sum': an elided form of sum -- syntax_model.md's three elided-sum sub-cases (a bare predicate construction, a compound perfect passive/future infinitive missing its auxiliary, or the always-implied present participle of sum, which doesn't exist in Latin at all) all use this one value.
  • 'continued discourse': a governing verb of indirect discourse left unrepeated across several continuation clauses.

'continued discourse' always anchors its own entry in verbalunits, same as any other verbal expression -- and so do two of 'implied sum's own three sub-cases (the bare predicate construction, and the always-implied present participle of sum). The THIRD 'implied sum' sub-case -- a compound perfect passive/future infinitive with its auxiliary omitted (e.g. 'facti' for 'facti sunt') -- is the one exception: there, a real, already-present participle/infinitive anchors the verbal expression instead (per VerbalExpression.id's own docstring), and the implied token merely relates to IT via 'auxiliary', exactly as a written-out auxiliary would; it gets no verbalunits entry of its own in that sub-case. The third IMPLIED_TOKENTYPES value is a DIFFERENT kind of gap entirely -- not a missing verb, but a missing NOUN or pronoun:

  • 'implied subject': a participle's own antecedent (the noun/pronoun it agrees with via 'circumstantial participle') can itself be unexpressed, most often when the participle agrees with a governing verb's own unexpressed subject. This token never anchors a verbalunits entry -- it isn't a verbal expression at all -- and instead takes a normal 'subject' relation into the verb whose subject it stands in for, exactly as if that subject had been written out; the participle then relates to THIS token via 'circumstantial participle', same as it would to any real noun. See latin_syntax_dspy.SentenceAnalysis's docstring for the worked example.

For any of the three, add a NEW entry here -- with a NEW id, not present in tokens -- rather than skipping the construction entirely; see latin_syntax_dspy.SentenceAnalysis'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 sum', 'continued discourse', or 'implied subject'), a NEW id not used by any entry in tokens or elsewhere in this tokengraph -- see SentenceAnalysis's docstring for the naming convention.

token: str | None = None

The token's surface text; should match the text of the input token with this id. Leave as None ONLY for an implied token (tokentype 'implied sum', 'continued discourse', or 'implied subject') -- one with no surface realization in the passage at all; every other tokentype must have real text.

tokentype: Literal['lexical', 'enclitic', 'punctuation', 'numeral', 'praenomen', 'abbreviation', 'implied sum', 'continued discourse', 'implied subject'] = PydanticUndefined

'numeral' is a number written NUMERICALLY -- Roman (e.g. 'XXV') or Arabic -- rather than spelled out as a word; a number spelled out as an ordinary word (e.g. 'decem' for 'ten') is 'lexical' instead, even though it's semantically a number -- e.g. in 'fratres Joseph decem', all three tokens are 'lexical', not 'numeral'. 'praenomen' is specifically an abbreviated Roman first name (e.g. 'M.' for Marcus), including its period; 'abbreviation' is any other abbreviation, including its period (e.g. 'f.' for filius, 'cos.' for consul) -- syntax_model.md's tokenization section documents these as two distinct token types, not one. 'implied sum', 'continued discourse', and 'implied subject' each mark a token with NO surface realization at all (see this model's own docstring for the distinction) -- the only three tokentypes whose token field is None and whose id is not one of the input tokens' own ids. 'continued discourse' always anchors its own verbal expression (an entry in verbalunits), and so does 'implied sum' in two of its three sub-cases -- EXCEPT the omitted-auxiliary sub-case (e.g. 'facti' for 'facti sunt'), where the real, already-present participle anchors instead and this implied token only relates to it via 'auxiliary' (see this model's own docstring). 'implied subject' never anchors a verbal expression -- it stands in for an unexpressed NOUN or pronoun, not a verb.

lemma: str | None = None

Dictionary headword, for lexical tokens. Omit for punctuation.

verbalunitid: str | None = None

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

relatedtoken1: str | None = 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: Literal['unit verb', 'subordinating conjunction', 'relative pronoun', 'subject', 'direct object', 'predicate', 'agent', 'auxiliary', 'object of preposition', 'adverbial', 'attributive', 'adjectival', 'genitive', 'dative', 'ablative', 'accusative', 'vocative', 'direct quote', 'aside', 'indirect statement', 'circumstantial participle', 'ablative absolute', 'coordinating conjunction', 'apposition', 'complementary infinitive', 'praenomen'] | None = None

The primary relation type, if any.

relatedtoken2: str | None = None

Token id this token relates to (secondary relation, used when relation1 is already occupied).

relationship2: Literal['unit verb', 'subordinating conjunction', 'relative pronoun', 'subject', 'direct object', 'predicate', 'agent', 'auxiliary', 'object of preposition', 'adverbial', 'attributive', 'adjectival', 'genitive', 'dative', 'ablative', 'accusative', 'vocative', 'direct quote', 'aside', 'indirect statement', 'circumstantial participle', 'ablative absolute', 'coordinating conjunction', 'apposition', 'complementary infinitive', 'praenomen'] | None = None

The secondary relation type, if any.

RelationLabel = typing.Literal['unit verb', 'subordinating conjunction', 'relative pronoun', 'subject', 'direct object', 'predicate', 'agent', 'auxiliary', 'object of preposition', 'adverbial', 'attributive', 'adjectival', 'genitive', 'dative', 'ablative', 'accusative', 'vocative', 'direct quote', 'aside', 'indirect statement', 'circumstantial participle', 'ablative absolute', 'coordinating conjunction', 'apposition', 'complementary infinitive', 'praenomen']
def tokengraph_to_mermaid( tokengraph: List[TokenAnalysis], orientation: str = 'BT', color_by_verbal_unit: bool = True, rank_by_depth: bool = True) -> Tuple[str, List[str]]:
131def tokengraph_to_mermaid(
132    tokengraph: List[TokenAnalysis],
133    orientation: str = "BT",
134    color_by_verbal_unit: bool = True,
135    rank_by_depth: bool = True,
136) -> Tuple[str, List[str]]:
137    """Build a Mermaid `graph` diagram from a tokengraph.
138
139    `orientation` is Mermaid's own flowchart orientation code -- `BT`
140    (bottom-to-top, the default here), `TB`, `LR`, or `RL` -- used verbatim
141    in the diagram's opening line (`graph BT`, `graph LR`, etc.). See
142    https://mermaid.js.org/syntax/flowchart.html for what each value looks
143    like; this function doesn't validate it, so a typo just becomes invalid
144    Mermaid syntax in the output rather than an error here.
145
146    `color_by_verbal_unit` (default True) colors every node by the verbal
147    unit it belongs to, per verbal_units.assign_verbal_units() -- so each
148    clause is visually distinguishable. Verbal units are assigned colors
149    from `_VERBAL_UNIT_PALETTE` in the order their tokens first appear in
150    `tokengraph`; a token assigned to no verbal unit is left with Mermaid's
151    default node styling. The one exception is an implied/elided token
152    (models.py's IMPLIED_TOKENTYPES) -- it always gets its own dedicated
153    `implied` class, colored with `verbal_units._IMPLIED_TOKEN_COLOR`,
154    instead of whatever `_VERBAL_UNIT_PALETTE` color its own verbal unit
155    would otherwise get (see this module's own docstring for why). Pass
156    False to skip coloring and get a plain diagram, as before this
157    parameter existed.
158
159    `rank_by_depth` (default True) makes the diagram's layout respect each
160    verbal expression's own depth in the `aat` package's Agent-Action-
161    Target model (see verbal_units.compute_aat_depths()) -- the same depth
162    aat_bridge.attgraph() would give that verbal expression's own AAT
163    action node, walking related_node chains to an independent (depth 0)
164    action: every verbal-unit anchor node (any token with `verbalunitid`
165    set to its own id, implied tokens included) at the SAME depth gets
166    chained together with Mermaid's invisible-link syntax (`~~~`), e.g.
167    `t1 ~~~ t6 ~~~ t9` for three anchors all at depth 2. This draws no
168    visible edge and adds no relation of its own -- it only nudges
169    Mermaid's layout engine to keep same-depth verbal expressions level
170    with each other, the same way independent clauses, the dependent
171    clauses one level below them, and so on, end up visually aligned by
172    rank rather than scattered -- and lines this diagram's layout up with
173    an AAT graph of the same sentence (see compute_aat_depths()'s own
174    docstring for exactly how its numbers relate to arsgrammatica's own,
175    richer subordination-depth notion, which this does NOT use). A depth
176    with only one anchor gets no chain at all (nothing to link); unlike
177    the old depth-of-subordination ranking, no anchor is ever excluded for
178    having an unresolved depth -- compute_aat_depths() never leaves one
179    unresolved (see its own docstring for the one malformed-input case,
180    a relation cycle, where that means an arbitrary rather than a
181    meaningful depth, rather than an excluded one). Pass False to skip
182    this and get the diagram's previous, unranked layout.
183
184    Returns (diagram_text, warnings). `warnings` lists any edges that were
185    skipped because they referenced a punctuation token or an id not present
186    in `tokengraph` -- worth checking, since it usually means the id came
187    from a validation problem upstream (see latin_syntax_dspy.validate) --
188    plus, if `color_by_verbal_unit` is True and the passage has more than 8
189    verbal units, one warning that colors are repeating rather than staying
190    distinct (the palette has 8 slots; see _VERBAL_UNIT_PALETTE).
191    `rank_by_depth` itself never adds a warning -- see compute_aat_depths().
192    """
193    node_ids = {tok.id for tok in tokengraph if tok.tokentype != "punctuation"}
194 
195    lines = [f"graph {orientation}"]
196    for tok in tokengraph:
197        if tok.id not in node_ids:
198            continue
199        # An implied/elided token (see models.py's IMPLIED_TOKENTYPES) has
200        # no surface text at all -- tok.token is None -- so it needs a
201        # placeholder label rather than crashing _escape_label() on None;
202        # token_label() supplies that. The node's color (below) is what
203        # actually marks it as an implied token, not the label text.
204        label = token_label(tok)
205        # An implied/elided token (models.py's IMPLIED_TOKENTYPES) gets a
206        # rounded-corner rectangle -- Mermaid's `(...)` node shape -- instead
207        # of the plain `[...]` rectangle every other node uses, as a second,
208        # shape-based signal (on top of the dedicated amber color below)
209        # that this node stands in for a word that isn't actually there.
210        open_bracket, close_bracket = (
211            ("(", ")") if tok.tokentype in IMPLIED_TOKENTYPES else ("[", "]")
212        )
213        lines.append(f'    {tok.id}{open_bracket}"{_escape_label(label)}"{close_bracket}')
214 
215    warnings = []
216    for tok in tokengraph:
217        if tok.id not in node_ids:
218            continue
219        for related_field, label_field in (
220            ("relatedtoken1", "relationship1"),
221            ("relatedtoken2", "relationship2"),
222        ):
223            related_id = getattr(tok, related_field)
224            label = getattr(tok, label_field)
225            if related_id is None or label is None:
226                continue
227            if related_id == "root":
228                # An independent verb's own unit-verb relation, per
229                # syntax_model.md -- intentionally not a real node, so not
230                # a warning-worthy gap. Just draw no edge for it.
231                continue
232            if related_id not in node_ids:
233                warnings.append(
234                    f"skipped edge {tok.id} -[{label}]-> {related_id}: "
235                    f"target is punctuation or not in tokengraph"
236                )
237                continue
238            lines.append(f'    {tok.id} -->|{_escape_label(label)}| {related_id}')
239
240    if rank_by_depth:
241        depths = compute_aat_depths(tokengraph)
242
243        # Group every verbal-unit anchor node still in the diagram by its
244        # own AAT-graph depth, preserving tokengraph's own (first-
245        # appearance) order within each group. Unlike the old depth-of-
246        # subordination ranking, compute_aat_depths() never leaves an
247        # anchor's depth unresolved -- depths.get() is None here only for a
248        # non-anchor token (it only ever keys its result by anchor id), so
249        # this `is None` check is purely "is this token an anchor at all",
250        # not "did its depth fail to resolve".
251        depth_groups: dict = {}
252        for tok in tokengraph:
253            if tok.id not in node_ids:
254                continue
255            depth = depths.get(tok.id)
256            if depth is None:
257                continue
258            depth_groups.setdefault(depth, []).append(tok.id)
259
260        rank_lines = [
261            "    " + " ~~~ ".join(ids)
262            for depth in sorted(depth_groups)
263            for ids in (depth_groups[depth],)
264            if len(ids) > 1
265        ]
266        if rank_lines:
267            lines.append("")
268            lines.extend(rank_lines)
269
270    if color_by_verbal_unit:
271        assignment = assign_verbal_units(tokengraph)
272        colors, color_warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment)
273        warnings.extend(color_warnings)
274
275        # Implied tokens (models.py's IMPLIED_TOKENTYPES) always get a
276        # dedicated "caution" amber (_IMPLIED_TOKEN_COLOR) instead of
277        # whatever color their own verbal unit would otherwise get --
278        # regardless of which unit they anchor -- so they're excluded from
279        # every per-unit `member_ids` group below and given their own
280        # classDef/class pair instead. See rendering.py's
281        # tokengraph_to_html() docstring for the matching HTML behavior.
282        implied_ids = [
283            tok.id
284            for tok in tokengraph
285            if tok.id in node_ids and tok.tokentype in IMPLIED_TOKENTYPES
286        ]
287
288        if colors or implied_ids:
289            lines.append("")
290            class_names = {}
291            for i, (unit_id, (fill, stroke, text)) in enumerate(colors.items()):
292                class_name = f"vu{i}"
293                class_names[unit_id] = class_name
294                lines.append(
295                    f"    classDef {class_name} fill:{fill},stroke:{stroke},color:{text};"
296                )
297            for unit_id in colors:
298                member_ids = [
299                    tok.id
300                    for tok in tokengraph
301                    if tok.id in node_ids
302                    and assignment.get(tok.id) == unit_id
303                    and tok.id not in implied_ids
304                ]
305                if member_ids:
306                    lines.append(f"    class {','.join(member_ids)} {class_names[unit_id]};")
307            if implied_ids:
308                fill, stroke, text = _IMPLIED_TOKEN_COLOR
309                lines.append(
310                    f"    classDef implied fill:{fill},stroke:{stroke},color:{text};"
311                )
312                lines.append(f"    class {','.join(implied_ids)} implied;")
313 
314    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.

rank_by_depth (default True) makes the diagram's layout respect each verbal expression's own depth in the aat package's Agent-Action- Target model (see verbal_units.compute_aat_depths()) -- the same depth aat_bridge.attgraph() would give that verbal expression's own AAT action node, walking related_node chains to an independent (depth 0) action: every verbal-unit anchor node (any token with verbalunitid set to its own id, implied tokens included) at the SAME depth gets chained together with Mermaid's invisible-link syntax (~~~), e.g. t1 ~~~ t6 ~~~ t9 for three anchors all at depth 2. This draws no visible edge and adds no relation of its own -- it only nudges Mermaid's layout engine to keep same-depth verbal expressions level with each other, the same way independent clauses, the dependent clauses one level below them, and so on, end up visually aligned by rank rather than scattered -- and lines this diagram's layout up with an AAT graph of the same sentence (see compute_aat_depths()'s own docstring for exactly how its numbers relate to arsgrammatica's own, richer subordination-depth notion, which this does NOT use). A depth with only one anchor gets no chain at all (nothing to link); unlike the old depth-of-subordination ranking, no anchor is ever excluded for having an unresolved depth -- compute_aat_depths() never leaves one unresolved (see its own docstring for the one malformed-input case, a relation cycle, where that means an arbitrary rather than a meaningful depth, rather than an excluded one). Pass False to skip this and get the diagram's previous, unranked layout.

Returns (diagram_text, warnings). warnings lists any edges that were skipped because they referenced a punctuation token or an id not present in tokengraph -- worth checking, since it usually means the id came from a validation problem upstream (see latin_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). rank_by_depth itself never adds a warning -- see compute_aat_depths().

def token_label(tok: TokenAnalysis) -> str:
109def token_label(tok: TokenAnalysis) -> str:
110    """The display label for one token: its own surface text (`tok.token`),
111    or -- for an implied/elided token (tokentype in IMPLIED_TOKENTYPES,
112    which has no surface text of its own; `tok.token` is None) -- a
113    placeholder from `_IMPLIED_TOKEN_LABELS` ("elided sum" for "implied
114    sum", "continued discourse" verbatim), falling back to the token's own
115    tokentype string verbatim for any IMPLIED_TOKENTYPES value not listed
116    there (e.g. "implied subject").
117
118    This is the exact label `tokengraph_to_mermaid()` puts in each node's
119    diagram box; it's pulled out as its own function so anything else that
120    needs "the same label the Mermaid diagram uses" for a token --
121    graphs.py's tokengraph_to_networkx(), for one -- can reuse it directly
122    rather than re-deriving (and risking drifting from) the same fallback
123    logic."""
124    return (
125        tok.token
126        if tok.token is not None
127        else _IMPLIED_TOKEN_LABELS.get(tok.tokentype, tok.tokentype)
128    )

The display label for one token: its own surface text (tok.token), or -- for an implied/elided token (tokentype in IMPLIED_TOKENTYPES, which has no surface text of its own; tok.token is None) -- a placeholder from _IMPLIED_TOKEN_LABELS ("elided sum" for "implied sum", "continued discourse" verbatim), falling back to the token's own tokentype string verbatim for any IMPLIED_TOKENTYPES value not listed there (e.g. "implied subject").

This is the exact label tokengraph_to_mermaid() puts in each node's diagram box; it's pulled out as its own function so anything else that needs "the same label the Mermaid diagram uses" for a token -- graphs.py's tokengraph_to_networkx(), for one -- can reuse it directly rather than re-deriving (and risking drifting from) the same fallback logic.

def save_mermaid( tokengraph: List[TokenAnalysis], path: str, orientation: str = 'BT', color_by_verbal_unit: bool = True, rank_by_depth: bool = True) -> List[str]:
317def save_mermaid(
318    tokengraph: List[TokenAnalysis],
319    path: str,
320    orientation: str = "BT",
321    color_by_verbal_unit: bool = True,
322    rank_by_depth: bool = True,
323) -> List[str]:
324    """Write the diagram to `path` (e.g. 'analysis.mmd') and return any
325    warnings from tokengraph_to_mermaid."""
326    diagram, warnings = tokengraph_to_mermaid(
327        tokengraph,
328        orientation=orientation,
329        color_by_verbal_unit=color_by_verbal_unit,
330        rank_by_depth=rank_by_depth,
331    )
332    with open(path, "w", encoding="utf-8") as f:
333        f.write(diagram + "\n")
334    return warnings

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

def tokengraph_to_dot( tokengraph: List[TokenAnalysis], orientation: str = 'BT', color_by_verbal_unit: bool = True, rank_by_depth: bool = True, depth: int | None = None) -> Tuple[str, List[str]]:
212def tokengraph_to_dot(
213    tokengraph: List[TokenAnalysis],
214    orientation: str = "BT",
215    color_by_verbal_unit: bool = True,
216    rank_by_depth: bool = True,
217    depth: Optional[int] = None,
218) -> Tuple[str, List[str]]:
219    """Build a Graphviz DOT `digraph` from a tokengraph -- the same
220    diagram tokengraph_to_mermaid() draws (same nodes, same edges, same
221    coloring), as DOT source instead of Mermaid source. See this module's
222    own docstring for why this exists alongside tokengraph_to_mermaid()
223    and what actually rendering the result requires.
224
225    `orientation` maps directly onto DOT's `rankdir` graph attribute --
226    `BT` (bottom-to-top, the default here, matching
227    tokengraph_to_mermaid()'s own default), `TB`, `LR`, or `RL`. Not
228    validated here, same as tokengraph_to_mermaid()'s `orientation` --  a
229    typo just becomes an attribute value Graphviz itself will reject.
230
231    `color_by_verbal_unit` (default True) colors every node by the verbal
232    unit it belongs to, per verbal_units.assign_verbal_units() -- the
233    exact same colors (and the same >8-verbal-units warning) as
234    tokengraph_to_mermaid(), just written as `fillcolor`/`color`/
235    `fontcolor` attributes directly on each node line instead of Mermaid's
236    separate `classDef`/`class` statements (DOT has no equivalent of a
237    named, reusable class -- inline per-node attributes are the idiomatic
238    way to do this). An implied/elided token (IMPLIED_TOKENTYPES) always
239    gets its own dedicated amber (verbal_units._IMPLIED_TOKEN_COLOR)
240    instead of whatever color its own verbal unit would otherwise get,
241    same as tokengraph_to_mermaid(). Pass False for a plain, uncolored
242    diagram.
243
244    `rank_by_depth` (default True) is the reason this module exists
245    alongside tokengraph_to_mermaid() -- see the module docstring. Every
246    verbal-unit anchor node (any token with `verbalunitid` set to its own
247    id, implied tokens included) at the same depth in
248    verbal_units.compute_aat_depths() gets listed together in one
249    `{rank=same; id1; id2; ...}` subgraph statement, which *forces*
250    Graphviz's layout engine to place them on the same rank -- not a nudge,
251    a hard constraint. A depth with only one anchor gets no `rank=same`
252    statement (nothing to align it WITH; unlike Mermaid's `~~~` chain,
253    which genuinely needs 2+ nodes to have anything to link, a
254    single-member `rank=same` would be harmless here too, just an inert
255    statement -- it's skipped for output cleanliness, not necessity). Pass
256    False to skip this and let Graphviz's own layout heuristics place
257    every node.
258
259    `depth`, if given, caps the diagram to nodes at or within that many
260    edges of a root/independent verbal-unit anchor -- compute_graph_depths()
261    above, a plain GRAPH distance along the same relatedtoken1/
262    relatedtoken2 edges drawn as `->` lines below, NOT
263    verbal_units.compute_subordination_depths() (the CLAUSE-level notion
264    behind tokengraph_to_depth_html()'s own indented-HTML `depth` slider --
265    a whole clause's subject, object, and other ordinary dependents share
266    ONE subordination depth with their verb, but each is its own hop of
267    GRAPH depth) and NOT verbal_units.compute_aat_depths() (`rank_by_depth`
268    above). `depth=0` shows ONLY root anchors -- an independent verb with
269    no dependents at all; `depth=1` adds every token one edge away from a
270    root anchor (its subject, object, adverbials, ...); and so on. A token
271    farther than `depth` is dropped entirely: omitted as a node, exactly as
272    if it had never been in `tokengraph`. Omit `depth` (or pass `None`, the
273    default) to show every node, same as before this parameter existed. A
274    `depth` at or beyond max_graph_depth()'s own return value for this
275    `tokengraph` shows everything too; a negative `depth` raises
276    ValueError.
277
278    Dropping a node can leave a KEPT node's edge pointing at a now-excluded
279    one. Such an edge is skipped, with the same combined warning already
280    used for an edge targeting punctuation or a genuinely absent id (see
281    Returns below) -- `depth` filtering degrades visibly rather than
282    emitting a dangling `->` line Graphviz would reject.
283
284    Returns `(dot_source, warnings)` -- same shape and same warnings as
285    tokengraph_to_mermaid(): an edge skipped because it targets a
286    punctuation token, a token excluded by the `depth` cutoff, or an id not
287    present in `tokengraph` (except the 'root' sentinel, skipped silently,
288    same as there); if `color_by_verbal_unit` is True and the passage has
289    more than 8 verbal units, one warning that colors are repeating.
290    `depth` filtering itself never adds a warning (compute_graph_depths()
291    has no unresolved state -- an unrelated or cyclic token just defaults
292    to depth 0), same as `rank_by_depth` -- see compute_aat_depths().
293    """
294    if depth is not None and depth < 0:
295        raise ValueError(f"depth must be >= 0 (root nodes only), got {depth!r}")
296
297    node_ids = {tok.id for tok in tokengraph if tok.tokentype != "punctuation"}
298
299    warnings: List[str] = []
300    if depth is not None:
301        graph_depths = compute_graph_depths(tokengraph)
302        depth_excluded_ids = {tok_id for tok_id, d in graph_depths.items() if d > depth}
303        node_ids -= depth_excluded_ids
304
305    colors_by_unit = {}
306    implied_ids: set = set()
307    if color_by_verbal_unit:
308        assignment = assign_verbal_units(tokengraph)
309        colors_by_unit, color_warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment)
310        warnings.extend(color_warnings)
311        implied_ids = {
312            tok.id
313            for tok in tokengraph
314            if tok.id in node_ids and tok.tokentype in IMPLIED_TOKENTYPES
315        }
316    else:
317        assignment = {}
318
319    lines = ["digraph tokengraph {", f"    rankdir={orientation};", "    node [shape=box];", ""]
320    for tok in tokengraph:
321        if tok.id not in node_ids:
322            continue
323        color = None
324        if color_by_verbal_unit:
325            if tok.id in implied_ids:
326                color = _IMPLIED_TOKEN_COLOR
327            else:
328                unit_id = assignment.get(tok.id)
329                color = colors_by_unit.get(unit_id) if unit_id is not None else None
330        lines.append(f"    {tok.id} [{_node_attrs(tok, color)}];")
331
332    lines.append("")
333    for tok in tokengraph:
334        if tok.id not in node_ids:
335            continue
336        for related_field, label_field in (
337            ("relatedtoken1", "relationship1"),
338            ("relatedtoken2", "relationship2"),
339        ):
340            related_id = getattr(tok, related_field)
341            label = getattr(tok, label_field)
342            if related_id is None or label is None:
343                continue
344            if related_id == "root":
345                # An independent verb's own unit-verb relation, per
346                # syntax_model.md -- intentionally not a real node, so not
347                # a warning-worthy gap. Just draw no edge for it.
348                continue
349            if related_id not in node_ids:
350                warnings.append(
351                    f"skipped edge {tok.id} -[{label}]-> {related_id}: "
352                    f"target is punctuation, excluded by the depth cutoff, "
353                    f"or not in tokengraph"
354                )
355                continue
356            lines.append(f'    {tok.id} -> {related_id} [label="{_escape_label(label)}"];')
357
358    if rank_by_depth:
359        aat_depths = compute_aat_depths(tokengraph)
360
361        # Same grouping tokengraph_to_mermaid() builds for its `~~~`
362        # chains -- see that function's own comment for why
363        # aat_depths.get() being None here means "not an anchor", never
364        # "unresolved depth" (compute_aat_depths() has no such state).
365        # Named aat_depths (not `depths`, and this loop's own variable not
366        # `depth`) to avoid shadowing the `depth` PARAMETER above -- a
367        # different depth notion entirely, see this function's own
368        # docstring.
369        depth_groups: dict = {}
370        for tok in tokengraph:
371            if tok.id not in node_ids:
372                continue
373            aat_depth = aat_depths.get(tok.id)
374            if aat_depth is None:
375                continue
376            depth_groups.setdefault(aat_depth, []).append(tok.id)
377
378        rank_lines = [
379            "    {rank=same; " + "; ".join(ids) + ";}"
380            for aat_depth in sorted(depth_groups)
381            for ids in (depth_groups[aat_depth],)
382            if len(ids) > 1
383        ]
384        if rank_lines:
385            lines.append("")
386            lines.extend(rank_lines)
387
388    lines.append("}")
389    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() 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, same as tokengraph_to_mermaid(). 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_aat_depths() 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; unlike Mermaid's ~~~ chain, which genuinely needs 2+ nodes to have anything to link, a single-member rank=same would be harmless here too, just an inert statement -- it's skipped for output cleanliness, not necessity). 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() (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) and NOT verbal_units.compute_aat_depths() (rank_by_depth above). 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), same as rank_by_depth -- see compute_aat_depths().

def compute_graph_depths(tokengraph: List[TokenAnalysis]) -> Dict[str, int]:
115def compute_graph_depths(tokengraph: List[TokenAnalysis]) -> Dict[str, int]:
116    """Each non-punctuation token's *graph depth*: the number of edges
117    separating it from the nearest root/independent verbal-unit anchor,
118    following the exact same relatedtoken1/relatedtoken2 edges
119    tokengraph_to_dot() itself draws as `->` lines (dependent -> governor)
120    -- the depth notion behind tokengraph_to_dot()'s own `depth` parameter.
121    See this module's own docstring for how this differs from
122    verbal_units.compute_subordination_depths() (rendering.
123    tokengraph_to_depth_html()'s clause-level notion) and
124    verbal_units.compute_aat_depths() (`rank_by_depth`'s notion).
125
126    A root anchor (relatedtoken1 == 'root') is depth 0. Every other
127    token's depth is one more than its PARENT's -- relatedtoken1, falling
128    back to relatedtoken2 only when relatedtoken1 itself doesn't resolve
129    to a usable parent (None, or an id not in `tokengraph`) -- the SAME
130    "relatedtoken1, fall back to relatedtoken2" preference
131    verbal_units.compute_subordination_depths() already uses to chase a
132    verbal expression's own governor. This matters for a token that plays
133    two roles at once, most notably a relative pronoun: e.g. "qui"
134    pointing at its antecedent via relatedtoken1 ('relative pronoun') AND
135    at the dependent verb it's ALSO the subject of via relatedtoken2
136    ('subject') -- that second edge points forward, toward a token that in
137    turn points back at the pronoun itself (its own 'unit verb' relation),
138    a genuine two-way link the data model allows. Taking the shallower of
139    BOTH edges (rather than preferring relatedtoken1) would let that
140    forward edge "cheat" the pronoun's own depth down to whatever the
141    dependent verb's -- itself only computable FROM the pronoun -- happens
142    to resolve to first, collapsing what should be a deeper chain. Only
143    ever falling back to relatedtoken2, never averaging or taking a
144    minimum over both, avoids that: relatedtoken1 alone already resolves
145    to the antecedent here, so relatedtoken2 is simply never consulted for
146    depth (it's still drawn as its own edge below, same as always -- this
147    only affects which relation DEPTH follows).
148
149    A token whose relatedtoken1 AND any fallback relatedtoken2 both fail
150    to resolve (neither set, or pointing at ids not in `tokengraph`), or
151    which is caught in a relation cycle even after preferring
152    relatedtoken1, defaults to depth 0 -- the same "can't determine,
153    default to root level" fallback verbal_units.compute_subordination_
154    depths() and rendering.tokengraph_to_depth_html() both use for their
155    own unresolved cases, rather than raising.
156
157    Returns `{token id: depth}`, one entry per non-punctuation token in
158    `tokengraph` (punctuation is never part of the diagram, so never
159    included here either).
160    """
161    by_id = {tok.id: tok for tok in tokengraph}
162    depths: Dict[str, int] = {}
163    in_progress: set = set()
164
165    def depth_of(tok_id: str) -> int:
166        if tok_id in depths:
167            return depths[tok_id]
168        tok = by_id[tok_id]
169        if tok.relatedtoken1 == "root":
170            depths[tok_id] = 0
171            return 0
172
173        if tok_id in in_progress:
174            # A relation cycle -- fall back to 0 rather than recursing
175            # forever; NOT cached, so a non-cyclic call further up the
176            # stack still computes (and caches) this token's real depth if
177            # some other path reaches it.
178            return 0
179        in_progress.add(tok_id)
180
181        parent_id = None
182        if tok.relatedtoken1 is not None and tok.relatedtoken1 != "root" and tok.relatedtoken1 in by_id:
183            parent_id = tok.relatedtoken1
184        elif tok.relatedtoken2 is not None and tok.relatedtoken2 in by_id:
185            parent_id = tok.relatedtoken2
186
187        result = 1 + depth_of(parent_id) if parent_id is not None else 0
188
189        in_progress.discard(tok_id)
190        depths[tok_id] = result
191        return result
192
193    for tok in tokengraph:
194        if tok.tokentype == "punctuation":
195            continue
196        depth_of(tok.id)
197
198    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) and verbal_units.compute_aat_depths() (rank_by_depth's notion).

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. "qui" pointing at its antecedent via relatedtoken1 ('relative pronoun') AND at the dependent verb it's ALSO the subject of via relatedtoken2 ('subject') -- 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]) -> int | None:
201def max_graph_depth(tokengraph: List[TokenAnalysis]) -> Optional[int]:
202    """The highest value compute_graph_depths() assigns to any token in
203    `tokengraph` -- the upper end of the meaningful range for
204    tokengraph_to_dot()'s own `depth` parameter, the same role
205    verbal_units.max_subordination_depth() plays for
206    tokengraph_to_depth_html()'s unrelated depth notion. Returns None for
207    an empty tokengraph, or one with only punctuation."""
208    depths = compute_graph_depths(tokengraph)
209    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 save_dot( tokengraph: List[TokenAnalysis], path: str, orientation: str = 'BT', color_by_verbal_unit: bool = True, rank_by_depth: bool = True, depth: int | None = None) -> List[str]:
392def save_dot(
393    tokengraph: List[TokenAnalysis],
394    path: str,
395    orientation: str = "BT",
396    color_by_verbal_unit: bool = True,
397    rank_by_depth: bool = True,
398    depth: Optional[int] = None,
399) -> List[str]:
400    """Write the diagram to `path` (e.g. 'analysis.dot') and return any
401    warnings from tokengraph_to_dot()."""
402    diagram, warnings = tokengraph_to_dot(
403        tokengraph,
404        orientation=orientation,
405        color_by_verbal_unit=color_by_verbal_unit,
406        rank_by_depth=rank_by_depth,
407        depth=depth,
408    )
409    with open(path, "w", encoding="utf-8") as f:
410        f.write(diagram + "\n")
411    return warnings

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

class GraphMetrics(typing.NamedTuple):
110class GraphMetrics(NamedTuple):
111    """Size/complexity/shape metrics for one tokengraph's NetworkX graph
112    (see tokengraph_to_networkx() and this module's own docstring for the
113    edge orientation these assume -- every "dependent"/"leaf" here is
114    about in-degree, not out-degree).
115
116    Size and complexity:
117
118    - `node_count`/`edge_count`: tokens (excluding punctuation) and
119      relations between them.
120    - `cyclomatic_number`: edges beyond a spanning tree
121      (`edge_count - node_count + weakly_connected_components`) -- 0 for
122      a pure tree; each coordinating conjunction, apposition, or similar
123      construction that gives a token a second governor/dependent beyond
124      strict tree shape adds 1. A direct, single-number answer to "how
125      much non-tree structure does this sentence have".
126    - `is_acyclic`/`longest_chain`: whether the graph is a DAG (it always
127      should be for a well-formed analysis; a cycle means something is
128      malformed, mirroring compute_subordination_depths()'s own
129      cycle-detection warning) and, if so, the length in edges of its
130      longest directed path -- the deepest raw token-to-token embedding
131      chain in the sentence. `longest_chain` is None when a cycle makes
132      it undefined, or the graph is empty.
133
134    Shape:
135
136    - `leaf_count`/`leaf_fraction`: tokens with no dependents of their own
137      (in-degree 0) -- terminal tokens in the dependency structure.
138    - `mean_dependents`/`max_dependents`: the in-degree distribution's
139      mean and max -- how many other tokens point at the typical/busiest
140      token. A sentence with a high max relative to its node count reads
141      as "one token governs almost everything"; a low, even mean/max
142      reads as "shallow and bushy" rather than "deep and chainy".
143    - `relationship_counts`: how many edges carry each relationship label
144      (e.g. `{"subject": 2, "direct object": 1, ...}`) -- what KIND of
145      structure the sentence leans on, not just how much of it there is.
146
147    All fields are 0/0.0/empty (not raised) for an empty graph.
148    """
149
150    node_count: int
151    edge_count: int
152    cyclomatic_number: int
153    is_acyclic: bool
154    longest_chain: Optional[int]
155    leaf_count: int
156    leaf_fraction: float
157    mean_dependents: float
158    max_dependents: int
159    relationship_counts: Dict[str, int]

Size/complexity/shape metrics for one tokengraph's NetworkX graph (see tokengraph_to_networkx() and this module's own docstring for the edge orientation these assume -- every "dependent"/"leaf" here is about in-degree, not out-degree).

Size and complexity:

  • node_count/edge_count: tokens (excluding punctuation) and relations between them.
  • cyclomatic_number: edges beyond a spanning tree (edge_count - node_count + weakly_connected_components) -- 0 for a pure tree; each coordinating conjunction, apposition, or similar construction that gives a token a second governor/dependent beyond strict tree shape adds 1. A direct, single-number answer to "how much non-tree structure does this sentence have".
  • is_acyclic/longest_chain: whether the graph is a DAG (it always should be for a well-formed analysis; a cycle means something is malformed, mirroring compute_subordination_depths()'s own cycle-detection warning) and, if so, the length in edges of its longest directed path -- the deepest raw token-to-token embedding chain in the sentence. longest_chain is None when a cycle makes it undefined, or the graph is empty.

Shape:

  • leaf_count/leaf_fraction: tokens with no dependents of their own (in-degree 0) -- terminal tokens in the dependency structure.
  • mean_dependents/max_dependents: the in-degree distribution's mean and max -- how many other tokens point at the typical/busiest token. A sentence with a high max relative to its node count reads as "one token governs almost everything"; a low, even mean/max reads as "shallow and bushy" rather than "deep and chainy".
  • relationship_counts: how many edges carry each relationship label (e.g. {"subject": 2, "direct object": 1, ...}) -- what KIND of structure the sentence leans on, not just how much of it there is.

All fields are 0/0.0/empty (not raised) for an empty graph.

GraphMetrics( node_count: int, edge_count: int, cyclomatic_number: int, is_acyclic: bool, longest_chain: int | None, leaf_count: int, leaf_fraction: float, mean_dependents: float, max_dependents: int, relationship_counts: Dict[str, int])

Create new instance of GraphMetrics(node_count, edge_count, cyclomatic_number, is_acyclic, longest_chain, leaf_count, leaf_fraction, mean_dependents, max_dependents, relationship_counts)

node_count: int

Alias for field number 0

edge_count: int

Alias for field number 1

cyclomatic_number: int

Alias for field number 2

is_acyclic: bool

Alias for field number 3

longest_chain: int | None

Alias for field number 4

leaf_count: int

Alias for field number 5

leaf_fraction: float

Alias for field number 6

mean_dependents: float

Alias for field number 7

max_dependents: int

Alias for field number 8

relationship_counts: Dict[str, int]

Alias for field number 9

def tokengraph_to_networkx( tokengraph: List[TokenAnalysis]) -> Tuple[networkx.classes.multidigraph.MultiDiGraph, List[str]]:
 51def tokengraph_to_networkx(tokengraph: List[TokenAnalysis]) -> Tuple[nx.MultiDiGraph, List[str]]:
 52    """Build a `networkx.MultiDiGraph` from `tokengraph`, using exactly the
 53    same node/edge selection as tokengraph_to_mermaid() (see this module's
 54    own docstring) -- so a NetworkX graph built here has the same nodes,
 55    the same labels, and the same edges as the diagram drawn for the same
 56    tokengraph, just as a graph object for metric computation rather than
 57    Mermaid source text.
 58
 59    Every node carries two attributes: `label` (via mermaid.token_label(),
 60    the same text/placeholder the diagram shows) and `tokentype` (the
 61    token's own tokentype string, e.g. for a label-aware isomorphism check
 62    later that shouldn't have to look anything up in the original
 63    tokengraph again). Every edge carries one attribute, `relationship`
 64    (the relatedtoken1/relatedtoken2 pair's own relationship1/
 65    relationship2 label).
 66
 67    Returns `(graph, warnings)`; `warnings` mirrors
 68    tokengraph_to_mermaid()'s own list -- a relatedtoken*/relationship*
 69    pair pointing at a punctuation token or an id absent from `tokengraph`
 70    is skipped and reported here, exactly as it is (and isn't drawn)
 71    there; the 'root' sentinel is skipped silently, the same non-issue it
 72    is there, since it was never meant to be a node at all.
 73    """
 74    node_ids = {tok.id for tok in tokengraph if tok.tokentype != "punctuation"}
 75
 76    G: nx.MultiDiGraph = nx.MultiDiGraph()
 77    for tok in tokengraph:
 78        if tok.id not in node_ids:
 79            continue
 80        G.add_node(tok.id, label=token_label(tok), tokentype=tok.tokentype)
 81
 82    warnings: List[str] = []
 83    for tok in tokengraph:
 84        if tok.id not in node_ids:
 85            continue
 86        for related_field, label_field in (
 87            ("relatedtoken1", "relationship1"),
 88            ("relatedtoken2", "relationship2"),
 89        ):
 90            related_id = getattr(tok, related_field)
 91            relationship = getattr(tok, label_field)
 92            if related_id is None or relationship is None:
 93                continue
 94            if related_id == "root":
 95                # An independent verb's own unit-verb relation, per
 96                # syntax_model.md -- intentionally not a real node, so not
 97                # a warning-worthy gap. Just add no edge for it.
 98                continue
 99            if related_id not in node_ids:
100                warnings.append(
101                    f"skipped edge {tok.id} -[{relationship}]-> {related_id}: "
102                    f"target is punctuation or not in tokengraph"
103                )
104                continue
105            G.add_edge(tok.id, related_id, relationship=relationship)
106
107    return G, warnings

Build a networkx.MultiDiGraph from tokengraph, using exactly the same node/edge selection as tokengraph_to_mermaid() (see this module's own docstring) -- so a NetworkX graph built here has the same nodes, the same labels, and the same edges as the diagram drawn for the same tokengraph, just as a graph object for metric computation rather than Mermaid source text.

Every node carries two attributes: label (via mermaid.token_label(), the same text/placeholder the diagram shows) and tokentype (the token's own tokentype string, e.g. for a label-aware isomorphism check later that shouldn't have to look anything up in the original tokengraph again). Every edge carries one attribute, relationship (the relatedtoken1/relatedtoken2 pair's own relationship1/ relationship2 label).

Returns (graph, warnings); warnings mirrors tokengraph_to_mermaid()'s own list -- a relatedtoken*/relationship* pair pointing at a punctuation token or an id absent from tokengraph is skipped and reported here, exactly as it is (and isn't drawn) there; the 'root' sentinel is skipped silently, the same non-issue it is there, since it was never meant to be a node at all.

def graph_metrics( G: networkx.classes.multidigraph.MultiDiGraph) -> GraphMetrics:
162def graph_metrics(G: nx.MultiDiGraph) -> GraphMetrics:
163    """Compute GraphMetrics for `G` (as built by tokengraph_to_networkx(),
164    though this only depends on `G` being a networkx graph with an
165    `relationship` edge attribute -- it doesn't otherwise care how `G` was
166    built). See GraphMetrics's own docstring for what each field means and
167    the in-degree-as-branching convention every "dependent"/"leaf" metric
168    here assumes.
169    """
170    node_count = G.number_of_nodes()
171    edge_count = G.number_of_edges()
172    components = nx.number_weakly_connected_components(G)
173    cyclomatic_number = edge_count - node_count + components
174
175    is_acyclic = nx.is_directed_acyclic_graph(G)
176    longest_chain = nx.dag_longest_path_length(G) if is_acyclic and node_count else None
177
178    in_degrees = [degree for _, degree in G.in_degree()]
179    leaf_count = sum(1 for degree in in_degrees if degree == 0)
180    leaf_fraction = leaf_count / node_count if node_count else 0.0
181    mean_dependents = sum(in_degrees) / node_count if node_count else 0.0
182    max_dependents = max(in_degrees) if in_degrees else 0
183
184    relationship_counts: Dict[str, int] = {}
185    for _, _, relationship in G.edges(data="relationship"):
186        relationship_counts[relationship] = relationship_counts.get(relationship, 0) + 1
187
188    return GraphMetrics(
189        node_count=node_count,
190        edge_count=edge_count,
191        cyclomatic_number=cyclomatic_number,
192        is_acyclic=is_acyclic,
193        longest_chain=longest_chain,
194        leaf_count=leaf_count,
195        leaf_fraction=leaf_fraction,
196        mean_dependents=mean_dependents,
197        max_dependents=max_dependents,
198        relationship_counts=relationship_counts,
199    )

Compute GraphMetrics for G (as built by tokengraph_to_networkx(), though this only depends on G being a networkx graph with an relationship edge attribute -- it doesn't otherwise care how G was built). See GraphMetrics's own docstring for what each field means and the in-degree-as-branching convention every "dependent"/"leaf" metric here assumes.

def assign_verbal_units( tokengraph: List[TokenAnalysis]) -> Dict[str, str | None]:
147def assign_verbal_units(tokengraph: List[TokenAnalysis]) -> Dict[str, Optional[str]]:
148    """Return {token id: verbal unit id or None}, one entry per token in
149    `tokengraph` (including punctuation and unrelated tokens, so every id
150    is accounted for -- callers that only care about assigned tokens can
151    filter out the None values themselves).
152 
153    A verbal unit's own anchor token is assigned to itself (its
154    `verbalunitid`). Every other token is assigned to the verbal unit its
155    relations resolve to, per this module's docstring; a token with no
156    resolvable relation (e.g. a bare accusative of place, an enclitic, an
157    emphatic pronoun left unrelated per syntax_model.md's "Incomplete
158    status") gets None.
159
160    A true ablative-absolute noun (its own outgoing relation is "ablative
161    absolute", not some normal clause role) is redirected to the verbal
162    unit of the circumstantial participle it agrees with, rather than to
163    the verb its own relatedtoken1 points at -- see this module's
164    docstring for the full "paucis interiectis diebus ... inscio
165    Collatino ... venit" example. Anything that in turn chains through
166    that noun (an adjective, an appositive) follows it into the
167    participle's unit too, since this redirect happens once, at the noun
168    itself, and every other resolution is unchanged.
169    """
170    by_id = {tok.id: tok for tok in tokengraph}
171
172    # Reverse index: for every token that some OTHER token points at via a
173    # "unit verb" relation, record who points at it. Per syntax_model.md,
174    # a "unit verb" target is always either the literal sentinel 'root'
175    # (from an independent verb -- never a real token) or a subordinating
176    # conjunction/relative pronoun's id (from a dependent verb) -- so a hit
177    # here always means "this token introduces the pointing verb's clause."
178    introduces_clause_for: Dict[str, str] = {}
179    # Reverse index: for every token that some OTHER token points at via a
180    # "circumstantial participle" relation, record who points at it (the
181    # participle -- real or implied -- that agrees with it). Used below to
182    # redirect a TRUE ablative-absolute noun to that participle's own
183    # verbal unit instead of the verb it otherwise points at; a noun a
184    # participle agrees with that fits normally into the clause (its own
185    # outgoing relation isn't "ablative absolute") is left alone and keeps
186    # resolving normally, so this index is consulted but not always used.
187    circumstantial_participle_for: Dict[str, str] = {}
188    for tok in tokengraph:
189        for related_field, label_field in (
190            ("relatedtoken1", "relationship1"),
191            ("relatedtoken2", "relationship2"),
192        ):
193            related = getattr(tok, related_field)
194            label = getattr(tok, label_field)
195            if related is None or related == "root":
196                continue
197            if label == _UNIT_VERB:
198                introduces_clause_for[related] = tok.id
199            elif label == _CIRCUMSTANTIAL_PARTICIPLE:
200                circumstantial_participle_for[related] = tok.id
201
202    resolved: Dict[str, Optional[str]] = {}
203    in_progress: set = set()
204 
205    def resolve(tid: str) -> Optional[str]:
206        if tid in resolved:
207            return resolved[tid]
208        tok = by_id.get(tid)
209        if tok is None:
210            return None
211 
212        if tok.verbalunitid is not None:
213            resolved[tid] = tok.verbalunitid
214            return tok.verbalunitid
215 
216        if tid in in_progress:
217            # A cycle in the relation graph (malformed LM output) -- bail
218            # out on this token rather than recursing forever.
219            return None
220        in_progress.add(tid)
221 
222        result = None
223
224        clause_verb_id = introduces_clause_for.get(tid)
225        if clause_verb_id is not None:
226            result = resolve(clause_verb_id)
227
228        if result is None:
229            participle_id = circumstantial_participle_for.get(tid)
230            is_ablative_absolute = (
231                tok.relationship1 == _ABLATIVE_ABSOLUTE
232                or tok.relationship2 == _ABLATIVE_ABSOLUTE
233            )
234            if participle_id is not None and is_ablative_absolute:
235                result = resolve(participle_id)
236
237        if result is None:
238            for related_field in ("relatedtoken1", "relatedtoken2"):
239                related = getattr(tok, related_field)
240                if related is None or related == "root":
241                    continue
242                result = resolve(related)
243                if result is not None:
244                    break
245 
246        in_progress.discard(tid)
247        resolved[tid] = result
248        return result
249 
250    for tid in by_id:
251        resolve(tid)
252 
253    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 place, an enclitic, an emphatic pronoun left unrelated per syntax_model.md's "Incomplete status") gets None.

A true ablative-absolute noun (its own outgoing relation is "ablative 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 "paucis interiectis diebus ... inscio Collatino ... venit" 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.

def assign_verbal_unit_colors( tokengraph: List[TokenAnalysis], assignment: Dict[str, str | None] | None = None) -> Tuple[Dict[str, Tuple[str, str, str]], List[str]]:
256def assign_verbal_unit_colors(
257    tokengraph: List[TokenAnalysis],
258    assignment: Optional[Dict[str, Optional[str]]] = None,
259) -> Tuple[Dict[str, Tuple[str, str, str]], List[str]]:
260    """Assign each verbal unit found in `tokengraph` a stable (fill, stroke,
261    text) triple from `_VERBAL_UNIT_PALETTE`, using the exact ordering rule
262    `tokengraph_to_mermaid()` uses for its node coloring -- so any other
263    caller wanting "the same colors as the mermaid graph" (currently
264    rendering.py's `tokengraph_to_html()`) gets an identical mapping without
265    re-deriving the rule itself.
266 
267    Order is by first appearance of each verbal unit among tokengraph's
268    *non-punctuation* tokens, since those are the only tokens that become
269    mermaid nodes at all -- a verbal unit whose earliest token happens to be
270    punctuation (it can't be: punctuation tokens aren't assigned to a
271    verbal unit's anchor, but could in principle inherit one from a
272    relation) still gets ordered by its first non-punctuation member.
273 
274    Pass `assignment` (the result of `assign_verbal_units(tokengraph)`) if
275    the caller already computed it, to avoid re-deriving it here; otherwise
276    it's computed internally.
277 
278    Returns `({verbal unit id: (fill, stroke, text)}, warnings)` --
279    `warnings` holds one entry, with the same wording
280    `tokengraph_to_mermaid()` uses, if there are more distinct verbal units
281    than palette slots (colors repeat past the 8th unit). A verbal unit id
282    absent from the returned dict was never assigned to any non-punctuation
283    token -- callers should treat that the same as "no verbal unit" (no
284    coloring), same as `tokengraph_to_mermaid()` does.
285    """
286    if assignment is None:
287        assignment = assign_verbal_units(tokengraph)
288 
289    non_punctuation_ids = {tok.id for tok in tokengraph if tok.tokentype != "punctuation"}
290 
291    unit_order: List[str] = []
292    seen_units = set()
293    for tok in tokengraph:
294        if tok.id not in non_punctuation_ids:
295            continue
296        unit_id = assignment.get(tok.id)
297        if unit_id is not None and unit_id not in seen_units:
298            seen_units.add(unit_id)
299            unit_order.append(unit_id)
300 
301    warnings: List[str] = []
302    if len(unit_order) > len(_VERBAL_UNIT_PALETTE):
303        warnings.append(
304            f"{len(unit_order)} verbal units but only {len(_VERBAL_UNIT_PALETTE)} "
305            "distinct colors -- colors repeat and may be ambiguous between units"
306        )
307 
308    colors = {
309        unit_id: _VERBAL_UNIT_PALETTE[i % len(_VERBAL_UNIT_PALETTE)]
310        for i, unit_id in enumerate(unit_order)
311    }
312    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_aat_depths(tokengraph: List[TokenAnalysis]) -> Dict[str, int]:
505def compute_aat_depths(tokengraph: List[TokenAnalysis]) -> Dict[str, int]:
506    """Compute each verbal expression's depth the way it would come out if
507    you built an `aat` package AATGraph from this same `tokengraph` (via
508    `aat_bridge.attgraph()`) and walked each action node's own
509    `related_node` chain to the top -- an independent action (no governing
510    action) is depth 0, one it governs is depth 1, and so on -- WITHOUT
511    actually building that graph or depending on `aat` being installed at
512    all: `attgraph()` populates every action's `related_node` from this
513    same module's `find_governing_verbal_expression()`, so walking that
514    map directly here reproduces the identical numbers `graph.
515    governing_action()` chains would.
516
517    Returns `{anchor id: depth}` -- one entry per verbal-expression anchor
518    (same set `compute_subordination_depths()` covers), and, unlike that
519    function, EVERY anchor gets a plain `int`, never `None`, and this never
520    returns any warnings. That's not an oversight: an AATNode's
521    `related_node` is either "points at a real governing action" or
522    `None` -- there's no third state for "a governing expression should
523    exist here but the chase couldn't find one" (compute_subordination_
524    depths()'s "unresolved, needs a warning" case). So this function folds
525    that case into the same bucket as a genuinely independent verb (depth
526    0) instead of excluding it, exactly as an AATGraph itself would have
527    no way to tell the two apart. For every WELL-FORMED sentence (which is
528    to say: every one of this codebase's own gold fixtures) the two
529    functions agree exactly, hop for hop -- they're driven by the same
530    underlying chase. They can only diverge on malformed input: an anchor
531    whose own chase never reaches another anchor at all (relatedtoken1 not
532    'root', but nothing resolvable) is `None`/excluded/warned-about from
533    `compute_subordination_depths()`, but depth 0 here.
534
535    A mutual cycle -- two anchors relating directly to each other, so each
536    resolves to "the other" as its own governing expression (see
537    `find_governing_verbal_expression()`'s own docstring for why its local
538    chase can't detect this as a cycle at all) -- is the one case where
539    this function's numbers are not just "collapsed" but genuinely
540    arbitrary: walking the chain here still has to terminate somewhere, so
541    whichever anchor's own resolution happens to be demanded FIRST ends up
542    one level shallower than the other, an artifact of iteration order
543    rather than anything meaningful in the relation graph. This is the
544    same malformed-LM-output scenario `compute_subordination_depths()`
545    detects and warns about explicitly (leaving both anchors' depth
546    `None`) -- a caller that needs to tell "confidently ranked" apart from
547    "arbitrarily broke a tie in a cycle" should use that function instead,
548    or run `find_unanchored_coordinated_verbs()`/`validate()` upstream,
549    since a real cycle like this only comes from malformed relations to
550    begin with.
551
552    Used by `mermaid.tokengraph_to_mermaid()`'s `rank_by_depth` option, so
553    the invisible same-depth layout links in the full syntax diagram line
554    up with the depth an AAT graph of the same sentence would show, rather
555    than arsgrammatica's own (richer, but AAT-incompatible on unresolved
556    anchors) subordination-depth notion -- see that function's own
557    docstring. `compute_subordination_depths()`/`max_subordination_depth()`
558    /`tokengraph_to_depth_html()`'s depth-indented HTML view are unaffected
559    by this function and keep using the original notion, unchanged.
560    """
561    governing = find_governing_verbal_expression(tokengraph)
562
563    depths: Dict[str, int] = {}
564    in_progress: set = set()
565
566    def depth_of(anchor_id: str) -> int:
567        if anchor_id in depths:
568            return depths[anchor_id]
569        if anchor_id in in_progress:
570            # A cycle this function's own local walk can't resolve
571            # meaningfully (see this function's own docstring) -- treat as
572            # "no governing expression found", same as a genuinely
573            # independent action, rather than recursing forever.
574            return 0
575        in_progress.add(anchor_id)
576
577        parent = governing.get(anchor_id)
578        result = 0 if parent is None else depth_of(parent) + 1
579
580        in_progress.discard(anchor_id)
581        depths[anchor_id] = result
582        return result
583
584    for anchor_id in governing:
585        depth_of(anchor_id)
586
587    return depths

Compute each verbal expression's depth the way it would come out if you built an aat package AATGraph from this same tokengraph (via aat_bridge.attgraph()) and walked each action node's own related_node chain to the top -- an independent action (no governing action) is depth 0, one it governs is depth 1, and so on -- WITHOUT actually building that graph or depending on aat being installed at all: attgraph() populates every action's related_node from this same module's find_governing_verbal_expression(), so walking that map directly here reproduces the identical numbers graph. governing_action() chains would.

Returns {anchor id: depth} -- one entry per verbal-expression anchor (same set compute_subordination_depths() covers), and, unlike that function, EVERY anchor gets a plain int, never None, and this never returns any warnings. That's not an oversight: an AATNode's related_node is either "points at a real governing action" or None -- there's no third state for "a governing expression should exist here but the chase couldn't find one" (compute_subordination_ depths()'s "unresolved, needs a warning" case). So this function folds that case into the same bucket as a genuinely independent verb (depth 0) instead of excluding it, exactly as an AATGraph itself would have no way to tell the two apart. For every WELL-FORMED sentence (which is to say: every one of this codebase's own gold fixtures) the two functions agree exactly, hop for hop -- they're driven by the same underlying chase. They can only diverge on malformed input: an anchor whose own chase never reaches another anchor at all (relatedtoken1 not 'root', but nothing resolvable) is None/excluded/warned-about from compute_subordination_depths(), but depth 0 here.

A mutual cycle -- two anchors relating directly to each other, so each resolves to "the other" as its own governing expression (see find_governing_verbal_expression()'s own docstring for why its local chase can't detect this as a cycle at all) -- is the one case where this function's numbers are not just "collapsed" but genuinely arbitrary: walking the chain here still has to terminate somewhere, so whichever anchor's own resolution happens to be demanded FIRST ends up one level shallower than the other, an artifact of iteration order rather than anything meaningful in the relation graph. This is the same malformed-LM-output scenario compute_subordination_depths() detects and warns about explicitly (leaving both anchors' depth None) -- a caller that needs to tell "confidently ranked" apart from "arbitrarily broke a tie in a cycle" should use that function instead, or run find_unanchored_coordinated_verbs()/validate() upstream, since a real cycle like this only comes from malformed relations to begin with.

Used by mermaid.tokengraph_to_mermaid()'s rank_by_depth option, so the invisible same-depth layout links in the full syntax diagram line up with the depth an AAT graph of the same sentence would show, rather than arsgrammatica's own (richer, but AAT-incompatible on unresolved anchors) subordination-depth notion -- see that function's own docstring. compute_subordination_depths()/max_subordination_depth() /tokengraph_to_depth_html()'s depth-indented HTML view are unaffected by this function and keep using the original notion, unchanged.

def compute_subordination_depths( tokengraph: List[TokenAnalysis]) -> Tuple[Dict[str, int | None], List[str]]:
405def compute_subordination_depths(
406    tokengraph: List[TokenAnalysis],
407) -> Tuple[Dict[str, Optional[int]], List[str]]:
408    """Compute each verbal expression's *depth of subordination*: the
409    number of verbal expressions it is removed from an independent ("root")
410    clause. An independent verb is depth 0; a verb it introduces (a
411    dependent clause, a direct quote, an aside) is depth 1; a verbal
412    expression THAT verb in turn introduces (e.g. an indirect statement
413    inside a dependent clause) is depth 2; and so on.
414
415    A "verbal expression" here is any token that anchors one -- i.e. any
416    token with `verbalunitid` set to its own id (the same convention
417    `assign_verbal_units()` relies on). For each anchor, this function
418    finds its *parent* anchor -- the verbal expression it's subordinate to
419    -- by following the anchor's own relatedtoken1 (falling back to
420    relatedtoken2), through as many intermediate non-anchor tokens as
421    necessary, until it lands on another anchor. This one chase handles
422    every documented case uniformly, without needing to special-case by
423    relationship label, because they all eventually resolve to another
424    anchor via forward pointers already in the graph:
425
426    - unit verb (independent): relatedtoken1 == 'root' -> no parent, depth 0.
427    - unit verb (dependent): relatedtoken1 -> a subordinating conjunction or
428      relative pronoun (not itself an anchor) -> ITS relatedtoken1 -> the
429      superior verb (a conjunction) or an antecedent noun (a relative
430      pronoun), the latter requiring one more hop through the noun's own
431      relation to reach the verb it depends on.
432    - direct quote / aside / indirect statement: relatedtoken1 -> the verb
433      of the clause it interrupts, is framed by, or (for an indirect-
434      statement infinitive) governs it, directly (no intermediate token).
435    - circumstantial participle: relatedtoken1 -> the noun/pronoun it
436      agrees with (not itself an anchor) -> that noun's own relation,
437      either its normal role in the surrounding clause (one more hop to a
438      verb) or, for a true ablative absolute, 'ablative absolute' pointing
439      directly at the main verb.
440
441    Returns `({anchor id: depth or None}, warnings)`. A depth of `None`
442    means the chase from that anchor never reached another anchor (a
443    malformed or genuinely disconnected verbal expression -- e.g. an
444    indirect-statement infinitive predating this convention, with no
445    relatedtoken1 of its own at all) or a cycle was detected; `warnings`
446    names which anchor(s) and why, mirroring `tokengraph_to_mermaid()`'s
447    warnings-list convention rather than raising.
448    """
449    by_id = {tok.id: tok for tok in tokengraph}
450    anchor_ids = {tok.id for tok in tokengraph if tok.verbalunitid == tok.id}
451
452    warnings: List[str] = []
453
454    # The chase itself -- following relatedtoken1/relatedtoken2 forward
455    # until another anchor is reached -- now lives in
456    # find_governing_verbal_expression(), shared with aat_bridge.py's
457    # attgraph(). Computed once, up front, for every anchor; this is a
458    # pure function of `tokengraph` with no dependency on `depths`'
459    # memoization state, so precomputing it here for all anchors (instead
460    # of the original code's lazy per-call `parent_of()`) changes nothing
461    # about the result.
462    governing = find_governing_verbal_expression(tokengraph)
463
464    depths: Dict[str, Optional[int]] = {}
465    in_progress: set = set()
466
467    def depth_of(anchor_id: str) -> Optional[int]:
468        if anchor_id in depths:
469            return depths[anchor_id]
470        tok = by_id[anchor_id]
471        if tok.relatedtoken1 == "root":
472            depths[anchor_id] = 0
473            return 0
474
475        if anchor_id in in_progress:
476            warnings.append(
477                f"cycle detected resolving the governing verbal expression "
478                f"for {anchor_id!r} -- leaving its depth (and its parent's) "
479                f"unresolved"
480            )
481            return None
482        in_progress.add(anchor_id)
483
484        parent = governing.get(anchor_id)
485        if parent is None:
486            warnings.append(
487                f"could not find a governing verbal expression for "
488                f"{anchor_id!r} -- leaving its depth unresolved"
489            )
490            result = None
491        else:
492            parent_depth = depth_of(parent)
493            result = None if parent_depth is None else parent_depth + 1
494
495        in_progress.discard(anchor_id)
496        depths[anchor_id] = result
497        return result
498
499    for anchor_id in anchor_ids:
500        depth_of(anchor_id)
501
502    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 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.
  • direct quote / aside / indirect statement: relatedtoken1 -> the verb of the clause it interrupts, is framed by, or (for an indirect- statement infinitive) governs it, directly (no intermediate token).
  • 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) or, for a true ablative absolute, 'ablative absolute' pointing directly at the main verb.

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 -- e.g. an indirect-statement infinitive predating this convention, with no relatedtoken1 of its own at all) or a cycle was detected; warnings names which anchor(s) and why, mirroring tokengraph_to_mermaid()'s warnings-list convention rather than raising.

def find_governing_verbal_expression( tokengraph: List[TokenAnalysis]) -> Dict[str, str | None]:
315def find_governing_verbal_expression(
316    tokengraph: List[TokenAnalysis],
317) -> Dict[str, Optional[str]]:
318    """For every verbal expression anchor in `tokengraph` (any token with
319    `verbalunitid` set to its own id -- the same convention
320    `assign_verbal_units()` relies on), find the *governing* verbal
321    expression it is subordinate to: the anchor id its own relatedtoken1
322    (falling back to relatedtoken2) chain eventually leads to, following
323    through as many intermediate non-anchor tokens as necessary.
324
325    Returns `{anchor id: governing anchor id, or None}`. `None` covers
326    BOTH of two different situations, deliberately not distinguished here:
327    an independent verb (relatedtoken1 == 'root', nothing to chase) and a
328    chase that dead-ends or cycles through non-anchor tokens before ever
329    reaching another anchor (a malformed or genuinely disconnected verbal
330    expression) -- either way, "no governing verbal expression" is the
331    right answer for a caller that just wants "is this subordinate to
332    something, and if so what" (e.g. aat_bridge.py's `attgraph()`,
333    building an AAT action node's `related_node`, where both cases alike
334    mean `related_node = None`). A caller that needs to tell those two
335    apart, or wants a warning when the chase genuinely fails, should use
336    `compute_subordination_depths()` instead -- it consumes this same
337    chase (via this function) but adds exactly that distinction, plus
338    warnings, on top.
339
340    One malformed-input case this function does NOT resolve to None:
341    two anchors whose own relatedtoken1/2 point directly at EACH OTHER
342    (rather than through intermediate tokens). The chase from either one
343    hits the OTHER anchor immediately -- an anchor is a hit the moment
344    it's reached, before its own further relations are ever followed --
345    so each resolves to "the other" as its governing expression, a
346    locally self-consistent but globally nonsensical mutual cycle. This
347    is unchanged from the original private helper this function was
348    extracted from; catching it requires the joint, cross-anchor
349    resolution `compute_subordination_depths()`'s own `in_progress`
350    bookkeeping does (see its "cycle detected" warning), which a single
351    anchor's local chase has no way to see on its own. A caller building
352    an AATGraph from a relation graph with this specific defect (an
353    actual LM error, not a normal input) would get a graph with two
354    actions each listing the other as its own governing action --
355    referentially valid (aat.core.validate.validate() has no cycle
356    check either) but logically circular.
357
358    The chase itself handles every documented case uniformly, without
359    needing to special-case by relationship label, because they all
360    eventually resolve to another anchor via forward pointers already in
361    the graph -- see `compute_subordination_depths()`'s own docstring for
362    the full worked-out case list (unit verb, direct quote/aside/indirect
363    statement, circumstantial participle).
364    """
365    by_id = {tok.id: tok for tok in tokengraph}
366    anchor_ids = {tok.id for tok in tokengraph if tok.verbalunitid == tok.id}
367
368    def chase(token_id: str, visited: set) -> Optional[str]:
369        """Follow relatedtoken1 (then relatedtoken2) forward from
370        `token_id`, returning the first anchor id reached, or None if the
371        chain dead-ends or cycles before reaching one. `token_id` itself
372        counts as a hit if it's already an anchor (the direct-link cases:
373        direct quote, aside, indirect statement)."""
374        if token_id in visited:
375            return None
376        visited.add(token_id)
377        if token_id in anchor_ids:
378            return token_id
379        tok = by_id.get(token_id)
380        if tok is None:
381            return None
382        for field in ("relatedtoken1", "relatedtoken2"):
383            target = getattr(tok, field)
384            if target is None or target == "root":
385                continue
386            result = chase(target, visited)
387            if result is not None:
388                return result
389        return None
390
391    def parent_of(anchor_id: str) -> Optional[str]:
392        tok = by_id[anchor_id]
393        for field in ("relatedtoken1", "relatedtoken2"):
394            target = getattr(tok, field)
395            if target is None or target == "root":
396                continue
397            result = chase(target, visited=set())
398            if result is not None and result != anchor_id:
399                return result
400        return None
401
402    return {anchor_id: parent_of(anchor_id) for anchor_id in anchor_ids}

For every verbal expression anchor in tokengraph (any token with verbalunitid set to its own id -- the same convention assign_verbal_units() relies on), find the governing verbal expression it is subordinate to: the anchor id its own relatedtoken1 (falling back to relatedtoken2) chain eventually leads to, following through as many intermediate non-anchor tokens as necessary.

Returns {anchor id: governing anchor id, or None}. None covers BOTH of two different situations, deliberately not distinguished here: an independent verb (relatedtoken1 == 'root', nothing to chase) and a chase that dead-ends or cycles through non-anchor tokens before ever reaching another anchor (a malformed or genuinely disconnected verbal expression) -- either way, "no governing verbal expression" is the right answer for a caller that just wants "is this subordinate to something, and if so what" (e.g. aat_bridge.py's attgraph(), building an AAT action node's related_node, where both cases alike mean related_node = None). A caller that needs to tell those two apart, or wants a warning when the chase genuinely fails, should use compute_subordination_depths() instead -- it consumes this same chase (via this function) but adds exactly that distinction, plus warnings, on top.

One malformed-input case this function does NOT resolve to None: two anchors whose own relatedtoken1/2 point directly at EACH OTHER (rather than through intermediate tokens). The chase from either one hits the OTHER anchor immediately -- an anchor is a hit the moment it's reached, before its own further relations are ever followed -- so each resolves to "the other" as its governing expression, a locally self-consistent but globally nonsensical mutual cycle. This is unchanged from the original private helper this function was extracted from; catching it requires the joint, cross-anchor resolution compute_subordination_depths()'s own in_progress bookkeeping does (see its "cycle detected" warning), which a single anchor's local chase has no way to see on its own. A caller building an AATGraph from a relation graph with this specific defect (an actual LM error, not a normal input) would get a graph with two actions each listing the other as its own governing action -- referentially valid (aat.core.validate.validate() has no cycle check either) but logically circular.

The chase itself 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 -- see compute_subordination_depths()'s own docstring for the full worked-out case list (unit verb, direct quote/aside/indirect statement, circumstantial participle).

def max_subordination_depth( tokengraph: List[TokenAnalysis], depths: Dict[str, int | None] | None = None) -> int | None:
590def max_subordination_depth(
591    tokengraph: List[TokenAnalysis],
592    depths: Optional[Dict[str, Optional[int]]] = None,
593) -> Optional[int]:
594    """Return the deepest level of subordination reached anywhere in
595    `tokengraph` -- the highest value `compute_subordination_depths()`
596    assigns to any verbal expression. Root/independent clauses are depth
597    0, so this is also the upper end of the valid `depth` range for
598    `rendering.tokengraph_to_depth_html()`'s own `depth` parameter (whose
599    valid range is 0, root clauses only, through this function's return
600    value, everything).
601
602    Pass `depths` (the first element of `compute_subordination_depths()`'s
603    return value) if the caller already computed it, to avoid re-deriving
604    it here; otherwise it's computed internally (any resolution warnings
605    are silently dropped in that case -- call
606    `compute_subordination_depths()` directly first if the caller also
607    needs those).
608
609    Returns `None` if `tokengraph` has no verbal expressions at all (an
610    empty passage, or one with none of the three constructions
611    syntax_model.md counts as one), or if every anchor's own depth came
612    back unresolved (see `compute_subordination_depths()`'s own
613    warnings for why an anchor might be unresolved -- a relation cycle, or
614    a governing verbal expression that couldn't be found). Otherwise
615    returns the maximum of every RESOLVED anchor's depth, ignoring
616    unresolved ones rather than letting a single bad anchor blank out the
617    whole result.
618    """
619    if depths is None:
620        depths, _warnings = compute_subordination_depths(tokengraph)
621
622    resolved = [d for d in depths.values() if d is not None]
623    if not resolved:
624        return None
625    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 three 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]:
628def find_unanchored_coordinated_verbs(tokengraph: List[TokenAnalysis]) -> List[str]:
629    """Heuristic sanity check for a specific, observed live-LM mistake: a
630    coordinating conjunction that pairs two verbal expressions (see
631    latin_syntax_dspy.py's docstring) is supposed to leave BOTH conjuncts
632    anchoring their own verbal unit -- each with its own `verbalunitid`
633    (and its own `verbalunits` entry). In practice, the LM sometimes drops
634    this for the second conjunct, especially when that verb also governs
635    further subordinate structure of its own (a dependent clause, an
636    indirect statement) -- see gold_examples.py's
637    coordinating_conjunction_dedit_et_dixit_esse fixture for a real example
638    (dixit, coordinated with dedit via "et" AND governing its own indirect
639    statement, came back from a live model with no verbalunitid at all).
640
641    This is NOT the same kind of check as validate() (referential id
642    integrity) or compute_subordination_depths()'s warnings (a resolvable-
643    but-broken relation graph) -- both of those only catch a problem if
644    the tokengraph is already self-inconsistent. This function catches a
645    tokengraph that's perfectly well-formed and internally consistent, but
646    still probably WRONG, by looking for an asymmetry a correct analysis
647    should never produce.
648
649    The heuristic: find every "coordinating conjunction" token that uses
650    BOTH relatedtoken1 and relatedtoken2 (the two-conjunct pairing case --
651    see that relation's own note about the one-sided, sentence-initial
652    exception, which this deliberately ignores since there's only one
653    conjunct to check there). For each such pair, if EXACTLY ONE of the
654    two joined tokens is a recognized verbal-unit anchor (`verbalunitid`
655    set to its own id) and the other is not, that asymmetry is flagged: if
656    the conjunction is genuinely pairing two nouns/adjectives/
657    prepositional phrases, NEITHER side would be an anchor; if it's
658    correctly pairing two verbal expressions, BOTH sides would be. Only
659    the lopsided case -- one anchored, one not -- is unusual enough to be
660    worth a human look.
661
662    A repeated connector coordinating a series of three or more items
663    (polysyndeton, e.g. 'et...et...et' -- see latin_syntax_dspy.py's
664    docstring) fits this same pairwise shape naturally: a connector
665    strictly between two items relates directly to its flanking items via
666    relatedtoken1/relatedtoken2, exactly like an ordinary two-item pair, so
667    it's checked the same way. Only the introductory connector before the
668    very first item ("et A et B et C") is different -- it sets
669    relatedtoken1 alone, with no relatedtoken2 -- and that shape is already
670    excluded by this function's own requirement (above) that both
671    relatedtoken1 and relatedtoken2 be present, so no special-case is
672    needed for it here.
673
674    Returns a list of warning strings (empty if nothing looks suspicious),
675    the same "degrade visibly, don't raise" convention every other
676    warnings-returning function in this codebase uses. This is a
677    heuristic, not a guarantee: it can only flag the asymmetry itself, not
678    confirm the unanchored side really was meant to be a verb, so a clean
679    result here isn't a substitute for validate() or a human read of the
680    analysis -- and a flagged result deserves a look rather than an
681    automatic "fix," since guessing the right verbalunitid/relation back
682    in could just as easily paper over a different, unrelated mistake.
683    """
684    by_id = {tok.id: tok for tok in tokengraph}
685    anchor_ids = {tok.id for tok in tokengraph if tok.verbalunitid == tok.id}
686
687    warnings: List[str] = []
688    seen_pairs = set()
689
690    for tok in tokengraph:
691        if not (
692            tok.relatedtoken1 is not None
693            and tok.relatedtoken1 != "root"
694            and tok.relationship1 == "coordinating conjunction"
695            and tok.relatedtoken2 is not None
696            and tok.relatedtoken2 != "root"
697            and tok.relationship2 == "coordinating conjunction"
698        ):
699            continue
700
701        pair = (tok.relatedtoken1, tok.relatedtoken2)
702        if pair in seen_pairs:
703            continue
704        seen_pairs.add(pair)
705
706        first_id, second_id = pair
707        first_anchored = first_id in anchor_ids
708        second_anchored = second_id in anchor_ids
709        if first_anchored == second_anchored:
710            # Both anchored (a correctly paired pair of verbs) or neither
711            # (almost certainly a noun/adjective/prepositional-phrase
712            # pair) -- either way, not the asymmetry this check looks for.
713            continue
714
715        anchored_id, unanchored_id = (
716            (first_id, second_id) if first_anchored else (second_id, first_id)
717        )
718        anchored_text = by_id[anchored_id].token if anchored_id in by_id else anchored_id
719        unanchored_text = by_id[unanchored_id].token if unanchored_id in by_id else unanchored_id
720        warnings.append(
721            f"{tok.id} ({tok.token!r}) coordinates {anchored_id} "
722            f"({anchored_text!r}), which anchors its own verbal unit, with "
723            f"{unanchored_id} ({unanchored_text!r}), which does not -- if "
724            "this conjunction is meant to join two verbal expressions "
725            "(rather than a noun/adjective/prepositional-phrase pair), "
726            f"{unanchored_id} is likely missing its own verbalunitid and "
727            "'unit verb'/'root' (or dependent-clause) relation."
728        )
729
730    return warnings

Heuristic sanity check for a specific, observed live-LM mistake: a coordinating conjunction that pairs two verbal expressions (see latin_syntax_dspy.py's docstring) is supposed to leave BOTH conjuncts anchoring their own verbal unit -- each with its own verbalunitid (and its own verbalunits entry). In practice, the LM sometimes drops this for the second conjunct, especially when that verb also governs further subordinate structure of its own (a dependent clause, an indirect statement) -- see gold_examples.py's coordinating_conjunction_dedit_et_dixit_esse fixture for a real example (dixit, coordinated with dedit via "et" AND governing its own indirect statement, came back from a live model with no verbalunitid at all).

This is NOT the same kind of check as validate() (referential id integrity) or compute_subordination_depths()'s warnings (a resolvable- but-broken relation graph) -- both of those only catch a problem if the tokengraph is already self-inconsistent. This function catches a tokengraph that's perfectly well-formed and internally consistent, but still probably WRONG, by looking for an asymmetry a correct analysis should never produce.

The heuristic: find every "coordinating conjunction" token that uses BOTH relatedtoken1 and relatedtoken2 (the two-conjunct pairing case -- see that relation's own note about the one-sided, sentence-initial exception, which this deliberately ignores since there's only one conjunct to check there). For each such pair, if EXACTLY ONE of the two joined tokens is a recognized verbal-unit anchor (verbalunitid set to its own id) and the other is not, that asymmetry is flagged: if the conjunction is genuinely pairing two nouns/adjectives/ prepositional phrases, NEITHER side would be an anchor; if it's correctly pairing two verbal expressions, BOTH sides would be. Only the lopsided case -- one anchored, one not -- is unusual enough to be worth a human look.

A repeated connector coordinating a series of three or more items (polysyndeton, e.g. 'et...et...et' -- see latin_syntax_dspy.py's docstring) fits this same pairwise shape naturally: a connector strictly between two items relates directly to its flanking items via relatedtoken1/relatedtoken2, exactly like an ordinary two-item pair, so it's checked the same way. Only the introductory connector before the very first item ("et A et B et C") is different -- it sets relatedtoken1 alone, with no relatedtoken2 -- and that shape is already excluded by this function's own requirement (above) that both relatedtoken1 and relatedtoken2 be present, so no special-case is needed for it here.

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: it can only flag the asymmetry itself, not confirm the unanchored side really was meant to be a verb, so 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," since guessing the right verbalunitid/relation back in could just as easily paper over a different, unrelated mistake.

def tokengraph_to_text(tokengraph: List[TokenAnalysis]) -> str:
111def tokengraph_to_text(tokengraph: List[TokenAnalysis]) -> str:
112    """Join `tokengraph`'s tokens into one continuous plain-text string,
113    per this module's docstring. Tokens are read in list order (the same
114    order tokengraph_to_mermaid() and validate() assume)."""
115    quote_counts: Dict[str, int] = {}
116    pieces: List[str] = []
117    previous_class = None
118
119    for tok in tokengraph:
120        if tok.tokentype in IMPLIED_TOKENTYPES:
121            # An implied/elided token (models.py's IMPLIED_TOKENTYPES) has
122            # no surface realization at all -- skip it entirely, exactly as
123            # if it weren't in the list, rather than trying to render
124            # `None`. previous_class is deliberately left untouched, so the
125            # next real token's spacing is decided as if this one weren't
126            # here.
127            continue
128        cls = _classify(tok, quote_counts)
129        text = tok.token
130
131        if not pieces:
132            # Nothing precedes the first token -- never prepend a space,
133            # regardless of this token's own classification.
134            pieces.append(text)
135        elif cls in (_LEFT, _ENCLITIC):
136            pieces.append(text)
137        elif cls == _RIGHT:
138            pieces.append(" " + text)
139        else:  # _NORMAL
140            if previous_class == _RIGHT:
141                pieces.append(text)
142            else:
143                pieces.append(" " + text)
144 
145        previous_class = cls
146 
147    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:
150def tokengraph_to_html(tokengraph: List[TokenAnalysis]) -> str:
151    """Render `tokengraph` as an HTML string: the same continuous text
152    `tokengraph_to_text()` produces -- identical spacing rules, and the same
153    punctuation/enclitic/quote-pair handling -- except every **lexical**
154    token, every **praenomen** token, every **numeral** token, and every
155    **coordinating conjunction** (any token with relationship1 or
156    relationship2 == "coordinating conjunction", lexical or not), has its
157    text wrapped in a `<span style="...">` colored by the verbal unit it
158    belongs to. Colors
159    come from `verbal_units.assign_verbal_units()` /
160    `assign_verbal_unit_colors()` -- the same assignment and the same
161    first-appearance palette ordering `tokengraph_to_mermaid()` uses for its
162    node coloring -- so a passage rendered here and the same passage's
163    Mermaid diagram color each verbal unit identically.
164
165    The coordinating-conjunction carve-out exists because a conjunction
166    like "-que" or "-ve" is typically tokentype "enclitic", not "lexical",
167    but `assign_verbal_units()` still resolves it to one of the units it
168    coordinates (see that module's docstring) -- e.g. in "arma virumque
169    cano.", "que" resolves to the same unit as "cano", same as "arma" and
170    "virum" do. Leaving it unwrapped would visually hide that assignment
171    even though it's a real one, unlike the other non-lexical tokentypes
172    below. A subordinating conjunction (e.g. "cum", "ut") doesn't need this
173    carve-out: it's always tokentype "lexical" (a full word, never
174    enclitic), so it's already wrapped.
175
176    The praenomen carve-out is the same idea for a different reason:
177    syntax_model.md's "Praenomina" section gives every `tokentype`=
178    "praenomen" token (e.g. "Sex.") its own real relation -- relatedtoken1
179    -> the lexical name it precedes, relationship1 = "praenomen" -- so
180    `assign_verbal_units()` resolves it to that name's own verbal unit
181    exactly like any other token in the clause (e.g. "Sex." lands in the
182    same unit as "Tarquinius", which is "venit"'s). Unlike the
183    coordinating-conjunction case, this is keyed on `tokentype` directly
184    rather than on the relationship label, matching how the "lexical" half
185    of this check works -- every praenomen, by convention, is eligible for
186    this treatment, not just ones that happen to already carry the
187    relation (a praenomen with nothing to relate to, e.g. "L." in the
188    genitive filiation formula "L. f.", simply has no verbal-unit
189    assignment and so renders unwrapped anyway, same as an unrelated
190    lexical token would).
191
192    The numeral carve-out is for the same reason again: syntax_model.md's
193    tokenization section restricts `tokentype`="numeral" to a number
194    written NUMERICALLY (Roman or Arabic) -- a number spelled out as an
195    ordinary word (e.g. "decem") is "lexical" instead -- but a numeral is
196    otherwise an ordinary participant in the clause, able to carry a real
197    relation like any noun or adjective (e.g. "XII" modifying "filii" via
198    "adjectival", the same relation "decem" would use if spelled out).
199    `assign_verbal_units()` resolves that relation exactly like any other,
200    so a numeral belonging to a verbal unit is wrapped the same way a
201    lexical token would be -- unlike punctuation, a non-conjunction
202    enclitic, or an abbreviation, none of which carry that kind of
203    ordinary syntactic relation under the current scheme.
204
205    Every other non-lexical, non-praenomen, non-numeral token --
206    punctuation, a non-conjunction enclitic (e.g. the interrogative "-ne"),
207    and abbreviations -- is still emitted as plain (escaped) text even
208    though `assign_verbal_units()` assigns every token, including
209    punctuation, to whichever unit its relations resolve to; this function
210    just doesn't turn that assignment into a span for anything else. A
211    lexical, praenomen, numeral, or coordinating-conjunction token
212    belonging to no verbal unit (assignment is `None`, e.g. a bare
213    accusative of place) is left unwrapped too, as is one whose unit
214    happens to have no non-punctuation member at all and so never got a
215    color slot from `assign_verbal_unit_colors()` (should not occur in
216    practice for a lexical, praenomen, or numeral token, since it's always
217    a non-punctuation member of its own unit, but handled defensively
218    rather than assumed).
219
220    An **implied/elided token** (models.py's IMPLIED_TOKENTYPES: "implied
221    sum", "continued discourse", "implied subject") is omitted entirely -- same as
222    tokengraph_to_text() -- rather than rendered with any span: it has no
223    surface text (`tok.token` is always `None`), and unlike
224    `tokengraph_to_mermaid()`'s diagram (which DOES show these, as their
225    own specially-colored, specially-labeled node -- see that module's own
226    docstring), inserting placeholder text into the middle of reconstructed
227    prose here would misrepresent what the passage actually says. The
228    Mermaid diagram is the one place an implied token's presence is worth
229    seeing at all.
230
231    Every token's text is HTML-escaped (`&`, `<`, `>`, and quote characters)
232    before being emitted, spans or not -- real Latin text can contain a
233    literal `"` or `'` (see the quote-pair handling below), which would
234    otherwise be indistinguishable from markup to anything that re-parses
235    this output.
236
237    The span's inline style sets both `background-color` (the verbal unit's
238    palette `fill`, the same value used as a Mermaid node's `fill`) and
239    `color` (the palette's `text` value, currently black for every slot) --
240    the latter so the token reads correctly regardless of whatever text
241    color the surrounding page has set, matching the explicit black
242    `color:` every Mermaid node in that unit also gets.
243    """
244    assignment = assign_verbal_units(tokengraph)
245    colors, _warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment)
246    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 praenomen token, every numeral token, and every coordinating conjunction (any token with relationship1 or relationship2 == "coordinating conjunction", 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 coordinating-conjunction carve-out exists because a conjunction like "-que" or "-ve" is typically tokentype "enclitic", not "lexical", but assign_verbal_units() still resolves it to one of the units it coordinates (see that module's docstring) -- e.g. in "arma virumque cano.", "que" resolves to the same unit as "cano", same as "arma" and "virum" do. Leaving it unwrapped would visually hide that assignment even though it's a real one, unlike the other non-lexical tokentypes below. A subordinating conjunction (e.g. "cum", "ut") doesn't need this carve-out: it's always tokentype "lexical" (a full word, never enclitic), so it's already wrapped.

The praenomen carve-out is the same idea for a different reason: syntax_model.md's "Praenomina" section gives every tokentype= "praenomen" token (e.g. "Sex.") its own real relation -- relatedtoken1 -> the lexical name it precedes, relationship1 = "praenomen" -- so assign_verbal_units() resolves it to that name's own verbal unit exactly like any other token in the clause (e.g. "Sex." lands in the same unit as "Tarquinius", which is "venit"'s). Unlike the coordinating-conjunction case, this is keyed on tokentype directly rather than on the relationship label, matching how the "lexical" half of this check works -- every praenomen, by convention, is eligible for this treatment, not just ones that happen to already carry the relation (a praenomen with nothing to relate to, e.g. "L." in the genitive filiation formula "L. f.", simply has no verbal-unit assignment and so renders unwrapped anyway, same as an unrelated lexical token would).

The numeral carve-out is for the same reason again: syntax_model.md's tokenization section restricts tokentype="numeral" to a number written NUMERICALLY (Roman or Arabic) -- a number spelled out as an ordinary word (e.g. "decem") is "lexical" instead -- but a numeral is otherwise an ordinary participant in the clause, able to carry a real relation like any noun or adjective (e.g. "XII" modifying "filii" via "adjectival", the same relation "decem" would use if spelled out). 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, a non-conjunction enclitic, or an abbreviation, none of which carry that kind of ordinary syntactic relation under the current scheme.

Every other non-lexical, non-praenomen, non-numeral token -- punctuation, a non-conjunction enclitic (e.g. the interrogative "-ne"), and abbreviations -- 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, praenomen, numeral, or coordinating-conjunction token belonging to no verbal unit (assignment is None, e.g. a bare accusative of place) 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, praenomen, or numeral token, since it's always a non-punctuation member of its own unit, but handled defensively rather than assumed).

An implied/elided token (models.py's IMPLIED_TOKENTYPES: "implied sum", "continued discourse", "implied subject") 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 Latin text can contain a literal " or ' (see the quote-pair handling below), 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: int | None = None) -> Tuple[str, List[str]]:
328def tokengraph_to_depth_html(
329    tokengraph: List[TokenAnalysis],
330    indent_em: float = _DEFAULT_DEPTH_INDENT_EM,
331    depth: Optional[int] = None,
332) -> Tuple[str, List[str]]:
333    """Render `tokengraph` as HTML illustrating each verbal expression's
334    *depth of subordination* (see verbal_units.compute_subordination_
335    depths()): tokens are assembled sequentially exactly as
336    tokengraph_to_html() does -- same spacing, escaping, and verbal-unit
337    color highlighting -- but grouped into consecutive-run "blocks" by
338    which verbal unit each token belongs to (per assign_verbal_units()),
339    each rendered as its own <div> indented by a CSS margin-left of
340    `depth * indent_em` em -- 0 for an independent clause, 1 for a clause
341    it introduces (a dependent clause, a direct quote, an aside, a
342    circumstantial participle/ablative absolute, or -- now that indirect-
343    statement infinitives carry their own governing-verb relation -- an
344    indirect statement too), 2 for a verbal expression THAT one in turn
345    introduces, and so on. All layout is CSS (margin-left/margin-bottom on
346    each block's <div>) -- no table or nested-list structure is used to
347    produce the indentation.
348
349    `depth`, if given, caps how deep the rendering goes: ONLY blocks whose
350    own depth of subordination is <= `depth` are included in the output --
351    a block deeper than that is dropped entirely, not rendered empty or
352    grayed out. `depth=0` shows root/independent clauses only (and direct
353    quotes, asides, and any other depth-0 construction); omit `depth` (or
354    pass `None`, the default) to show every block, same as before this
355    parameter existed. Valid values run from 0 up to
356    verbal_units.max_subordination_depth()'s own return value for this
357    `tokengraph` (that function exists specifically to help a caller pick
358    a sensible value here); a negative `depth` raises ValueError, since
359    there's no clause shallower than root. A `depth` larger than the
360    passage's actual maximum is accepted, not an error -- it just means
361    "show everything," identical to leaving `depth` unset.
362
363    Block boundaries follow assign_verbal_units()'s token-to-unit
364    assignment, with one adjustment: an **enclitic** token never starts a
365    new block, even when its own assignment differs from the block
366    currently open (see tests/test_rendering.py's coordinating-conjunction
367    word-order-mismatch case for exactly this -- an enclitic coordinating
368    conjunction like "-que" can resolve to a DIFFERENT verbal unit than the
369    word it's orthographically glued to, e.g. in "Hermionenque", and
370    starting a new block there would split one Latin word across two
371    <div>s). A token with no verbal-unit assignment at all (None --
372    typically punctuation, or a token syntax_model.md doesn't document a
373    relation for) likewise never starts a new block; it folds into
374    whichever block is currently open, so a stray comma or postpositive
375    particle doesn't fragment the layout. Leading tokens before the first
376    resolvable verbal-unit token (rare) default to depth 0.
377
378    Note that a circumstantial-participle/ablative-absolute noun (e.g.
379    "Anco" in "Anco regnante...", or "eum" in "Eum advenientem...")
380    resolves, per assign_verbal_units()'s own established convention, to
381    whatever unit ITS OWN relation reaches -- typically the outer clause --
382    while the participle itself is its own singleton unit; this means a
383    circumstantial-participle phrase renders as the noun staying in the
384    outer clause's block and the bare participle as its own one-word
385    indented block, rather than the whole phrase indenting together. That
386    follows directly from the noun's own documented relation (it fits into
387    the surrounding clause, or points at the main verb as an ablative
388    absolute) and isn't specific to this function.
389
390    A verbal expression whose depth couldn't be resolved (see
391    compute_subordination_depths()) renders at depth 0 rather than
392    raising, with a warning explaining why -- the same "degrade visibly,
393    don't crash" convention tokengraph_to_mermaid() uses.
394
395    Returns (html, warnings), combining assign_verbal_unit_colors()'s
396    warnings (colors repeating past 8 verbal units) and
397    compute_subordination_depths()'s (an unresolved governing verbal
398    expression) -- computed the same way, and returned in full, regardless
399    of whether `depth` filters some blocks out of the rendered `html`
400    itself.
401    """
402    if depth is not None and depth < 0:
403        raise ValueError(f"depth must be >= 0 (root clauses only), got {depth!r}")
404
405    assignment = assign_verbal_units(tokengraph)
406    colors, color_warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment)
407    depths, depth_warnings = compute_subordination_depths(tokengraph)
408    warnings = color_warnings + depth_warnings
409
410    blocks = []
411    for tok in tokengraph:
412        unit_id = assignment.get(tok.id)
413        starts_new_block = (
414            unit_id is not None
415            and tok.tokentype != "enclitic"
416            and (not blocks or blocks[-1][0] != unit_id)
417        )
418        if starts_new_block:
419            blocks.append((unit_id, []))
420        elif not blocks:
421            # Leading token(s) with no verbal-unit assignment yet (or a
422            # leading enclitic, in principle) -- open a placeholder block
423            # rather than crashing on an empty blocks list below.
424            blocks.append((None, []))
425        blocks[-1][1].append(tok)
426
427    lines = []
428    for unit_id, block_tokens in blocks:
429        block_depth = depths.get(unit_id) if unit_id is not None else 0
430        if block_depth is None:
431            block_depth = 0
432        if depth is not None and block_depth > depth:
433            # This whole block is deeper than the requested cutoff --
434            # drop it entirely rather than rendering an empty/grayed-out
435            # placeholder for it.
436            continue
437        block_html = _tokens_to_html(block_tokens, assignment, colors)
438        margin_left = block_depth * indent_em
439        lines.append(
440            f'<div style="margin-left: {margin_left}em; margin-bottom: 0.35em;">'
441            f"{block_html}</div>"
442        )
443
444    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/ablative absolute, or -- now that indirect- statement infinitives carry their own governing-verb relation -- an indirect statement too), 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 coordinating-conjunction word-order-mismatch case for exactly this -- an enclitic coordinating conjunction like "-que" can resolve to a DIFFERENT verbal unit than the word it's orthographically glued to, e.g. in "Hermionenque", and starting a new block there would split one Latin 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/ablative-absolute noun (e.g. "Anco" in "Anco regnante...", or "eum" in "Eum advenientem...") 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, or points at the main verb as an ablative 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 SentenceAnalysis(dspy.signatures.signature.Signature):
 33class SentenceAnalysis(dspy.Signature):
 34    """Analyze the syntax of a passage of Latin according to a two-part scheme:
 35 
 36    (1) a list of verbal expressions. Three constructions count as a verbal
 37        expression: finite verbs, infinitives, and participles.
 38 
 39        - A finite verb (including compound perfect/pluperfect passive forms
 40          of participle + a form of 'sum') is always a verbal expression.
 41          Classify its syntactic type as 'independent' (main/principal),
 42          'dependent' (subordinate, introduced by a subordinating word),
 43          'direct quote' (occurring in directly quoted speech framed by
 44          another verb, e.g. "est" in `"Tuum est," inquit, "Servi regnum."`),
 45          or 'aside' (a verbal expression that interrupts the surrounding
 46          syntax, e.g. "dixerim" in "pace dixerim deum" interrupting "eos...
 47          spero").
 48        - An infinitive is a verbal expression only when part of an indirect
 49          statement; its syntactic type is always 'indirect statement'. In a
 50          compound future-infinitive form (participle + a form of 'sum',
 51          e.g. "facturum...fuisse"), the participle/infinitive itself
 52          anchors the verbal expression, same as a compound passive.
 53        - A participle is a verbal expression only when it has a *predicate*
 54          sense (e.g. an ablative-absolute-like "Anco regnante Lucumo...",
 55          'while Ancus was reigning') rather than a purely *attributive*
 56          sense (modifying a noun like an ordinary adjective, e.g.
 57          "consentiens laus", 'universal praise' -- NOT a verbal expression
 58          at all). Use 'dependent' as its syntactic type. When it's
 59          genuinely uncertain whether a given participle is attributive or
 60          predicate/circumstantial, PREFER the circumstantial reading --
 61          treat it as its own verbal expression rather than folding it into
 62          an attributive relation. Example: in "ille moriens, cum sciret
 63          sagittas hydrae Lernaeae felle tinctas quantam uim haberent
 64          ueneni, sanguinem suum exceptum Deianirae dedit", both "moriens"
 65          (agreeing with "ille") and "tinctas" (agreeing with "sagittas")
 66          are treated as circumstantial participles, each anchoring its own
 67          verbal expression, rather than as ordinary attributive
 68          adjectives.
 69 
 70        Classify each verbal expression's semantic type too (transitive
 71        active/transitive passive/intransitive/linking verb).
 72 
 73    (2) a token-by-token dependency graph. For each token, record up to two
 74        relations to other tokens (by id), using only these relation labels:
 75 
 76        - unit verb (independent): every INDEPENDENT verb has relatedtoken1
 77          = the special sentinel string 'root' -- never an actual token id;
 78          no real token may be assigned the id 'root' -- and relationship1 =
 79          'unit verb'.
 80        - unit verb (dependent) / subordinating conjunction / relative
 81          pronoun: the verb of a DEPENDENT clause has relatedtoken1 -> the
 82          id of its subordinating conjunction or relative pronoun,
 83          relationship1 = 'unit verb'. That conjunction or pronoun in turn
 84          has relatedtoken1 -> the id of the verb of the clause it is
 85          subordinate to, with relationship1 = 'subordinating conjunction'
 86          for a conjunction, or relatedtoken1 -> its antecedent's id with
 87          relationship1 = 'relative pronoun' for a relative pronoun.
 88          Indirect questions are treated as a kind of dependent clause: the
 89          interrogative word introducing one (e.g. "quanta" in "Theseus
 90          audit quanta calamitate ciuitas afficeretur") is treated the same
 91          way as a subordinating conjunction -- it has relatedtoken1 -> the
 92          id of the verb it introduces (here "audit"), relationship1 =
 93          'subordinating conjunction' (no separate label for this case) --
 94          while the dependent verb itself ("afficeretur") has relatedtoken1
 95          -> the interrogative word's id ("quanta"), relationship1 = 'unit
 96          verb', exactly like any other dependent clause.
 97        - indirect statement (governing verb): an infinitive anchoring an
 98          indirect-statement verbal expression ALSO has relatedtoken1 ->
 99          the id of the verb that governs the indirect statement (the verb
100          of saying/thinking/perceiving it depends on), relationship1 =
101          'indirect statement' -- matching its own syntactic type, the same
102          convention 'direct quote' and 'aside' verbal expressions use
103          below. There's no separate subordinating-word token to point at
104          first (a Latin accusative-and-infinitive construction has no
105          equivalent of English 'that'), so the infinitive points directly
106          at its governing verb, rather than via a conjunction/pronoun
107          intermediary the way a dependent finite verb's 'unit verb'
108          relation does. In a compound future-infinitive form (participle +
109          a form of 'sum'), this relation belongs on the participle/
110          infinitive itself, since IT anchors the verbal expression (see
111          'auxiliary' below) -- the form of 'sum' takes no relation of its
112          own into the governing verb.
113        - complementary infinitive: an infinitive that completes the sense
114          of a governing verb like 'volo', 'incipio', 'audeo', 'licet', or
115          'decet' (rather than reporting indirect speech) has relatedtoken1
116          -> the id of that governing verb, relationship1 = 'complementary
117          infinitive'. Unlike an indirect-statement infinitive, this does
118          NOT make the infinitive its own verbal expression -- it gets no
119          `verbalunits` entry of its own; the governing verb is still the
120          only verbal expression here. Example: in "Amphion...cum templum
121          Apollinis expugnare vellet...", "expugnare" completes "vellet"
122          (relatedtoken1 -> "vellet", relationship1 = 'complementary
123          infinitive'); "templum" is still "expugnare"'s own direct object,
124          exactly as if "expugnare" were a finite verb.
125        - infinitive used as a noun: an infinitive can also function as an
126          ordinary noun -- most often a verb's subject or object -- rather
127          than anchoring an indirect statement or completing another verb.
128          Treat it exactly like any other noun in that role: relatedtoken1
129          -> the verb it's the subject/object of, relationship1 = 'subject'
130          or 'direct object' as appropriate (no dedicated label, and again
131          no `verbalunits` entry of its own). Example: in "dolere malum
132          est", "dolere" has relatedtoken1 -> "est", relationship1 =
133          'subject'. Like any verbal form, an infinitive used this way can
134          still take its own object or adverb, related to it the same way
135          they'd relate to a finite verb.
136        - gerunds and gerundives: a gerundive is simply an adjective --
137          treat it exactly like one (relatedtoken1 -> the noun it agrees
138          with, relationship1 = 'adjectival'; see 'adjectival' below).
139          Example: in "...ad sacrum faciendum", "faciendum" (the gerundive)
140          has relatedtoken1 -> "sacrum", relationship1 = 'adjectival'. A
141          gerund is a noun -- the oblique-case form a verb takes where an
142          infinitive would be needed in the nominative -- so relate it like
143          any other noun (most often 'genitive'); it can still take its own
144          object or adverb, related to it the same way they'd relate to a
145          finite verb or infinitive. Example: in "ars bene disserendi",
146          "disserendi" (the gerund) has relatedtoken1 -> "ars",
147          relationship1 = 'genitive', and "bene" (the adverb modifying it)
148          has relatedtoken1 -> "disserendi", relationship1 = 'adverbial'.
149          Neither a gerund nor a gerundive is a verbal expression in its
150          own right -- no dedicated label, no `verbalunits` entry.
151        - coordinating conjunction: when a coordinating conjunction (e.g.
152          'et', '-que') joins a pair of adjectives, nouns, or prepositional
153          phrases, it has relatedtoken1 -> the id of the first joined
154          token, relatedtoken2 -> the id of the second, with BOTH
155          relationship1 and relationship2 = 'coordinating conjunction' (not
156          an overflow slot here -- this is the one relation that genuinely
157          uses relatedtoken1 and relatedtoken2 for two ends of the same
158          relation at once). When it joins two verbal expressions instead,
159          relatedtoken1/relatedtoken2 are the ids of the two verbs (or, for
160          an infinitive/participle-anchored verbal expression, the id that
161          anchors it) rather than of nearby nouns -- go by which verbal
162          expression the conjunction functionally introduces, NOT by which
163          token it happens to be adjacent to or (for an enclitic like
164          '-que') physically attached to; those can differ (e.g. an
165          enclitic conjunction attached to the second clause's direct
166          object still relates the two VERBS, not the object). If the
167          conjunction opens an entirely new sentence with no explicit verb
168          to its left to pair with, set only relatedtoken1/relationship1 (->
169          the verb it introduces); do not invent a link to an implied
170          preceding clause. 'et' specifically can also function as a plain
171          adverb ('even', 'also') rather than a conjunction -- when it
172          does, treat it like any other adverb: relatedtoken1 -> the verb
173          or (if there is none, e.g. a verbless exclamation) the nearest
174          token it emphasizes, relationship1 = 'adverbial', not
175          'coordinating conjunction'. IMPORTANT: when the conjunction joins
176          two independent verbs, BOTH still get their own `verbalunits`
177          entry and their own relatedtoken1 = 'root'/relationship1 = 'unit
178          verb' -- this doesn't change just because one of them (usually
179          the second) also governs further subordinate structure of its
180          own (a dependent clause, an indirect statement, etc). A verb
181          that governs an indirect statement or introduces a further
182          clause is NOT thereby demoted to a mere "framing verb" for what
183          follows -- it is still, independently, one of the two
184          coordinated root verbs, and needs its own entry exactly like the
185          first one. Example: in "...dedit et id philtrum esse dixit.",
186          dedit and dixit are both independent verbs coordinated by et;
187          dixit ALSO governs the indirect statement anchored at esse
188          ('id philtrum esse'), but that does not exempt dixit itself from
189          getting relatedtoken1 = 'root', relationship1 = 'unit verb', and
190          its own entry in `verbalunits` -- exactly as if it stood alone.
191        - coordinating conjunction, repeated as a series: a conjunction
192          like 'et' or 'aut' can also be repeated to coordinate a series
193          of three or more items (polysyndeton, e.g. 'et...et...et'), not
194          just used once between a pair. Two shapes occur, and they're
195          annotated differently:
196            - "A et B et C" (a conjunction only BETWEEN items, none before
197              the first): annotate each connector exactly like the simple
198              pairwise case above -- relatedtoken1 -> the item immediately
199              BEFORE it, relatedtoken2 -> the item immediately AFTER it,
200              both relationship1/relationship2 = 'coordinating
201              conjunction'. The first connector (between A and B) has
202              relatedtoken1 -> A, relatedtoken2 -> B; the second (between
203              B and C) has relatedtoken1 -> B, relatedtoken2 -> C; and so
204              on for a longer series. Every connector always relates
205              directly to the two real items on either side of it --
206              NEVER to another connector.
207            - "et A et B et C" (an introductory connector before the
208              FIRST item too, one connector per item): the introductory
209              connector (before A) has ONLY relatedtoken1 -> A,
210              relationship1 = 'coordinating conjunction' -- there is no
211              preceding item for it to pair with, so relatedtoken2 is left
212              unset, same as the sentence-initial one-sided case above.
213              Every connector AFTER the introductory one is annotated
214              exactly like the "A et B et C" case: relatedtoken1 -> the
215              PRECEDING item, relatedtoken2 -> the FOLLOWING item, both
216              relationship labels 'coordinating conjunction'. So the
217              second connector (before B) has relatedtoken1 -> A,
218              relatedtoken2 -> B; the third (before C) has relatedtoken1
219              -> B, relatedtoken2 -> C.
220          Either way, every connector in a series relates directly to the
221          two real coordinated items flanking it (or, for an introductory
222          connector, the one item following it) -- never to a neighboring
223          connector. Each connected item ALSO keeps its own ordinary
224          relation to the rest of the sentence (subject, ablative, direct
225          object, or whatever fits), completely independent of this
226          chain -- the coordinating-conjunction relation only links a
227          connector to its flanking item(s); it never substitutes for
228          that item's own relation to whatever governs it. Example: in
229          "Tarquinius et assiduitate et varietate et magnificentia omnes
230          antecessit" ("et A et B et C" shape -- an et before every one of
231          the three ablatives, including the first), the first et
232          (introductory) has ONLY relatedtoken1 -> assiduitate; the
233          second et has relatedtoken1 -> assiduitate, relatedtoken2 ->
234          varietate; the third et has relatedtoken1 -> varietate,
235          relatedtoken2 -> magnificentia. assiduitate, varietate, and
236          magnificentia each ALSO have their own relatedtoken1 ->
237          antecessit, relationship1 = 'ablative', same as any other
238          ablative -- unaffected by which connector introduces them.
239        - direct quote / aside: a verbal expression of syntactic type
240          'direct quote' or 'aside' has relatedtoken1 -> the id of the verb
241          of the clause it interrupts or is framed by, relationship1 =
242          'direct quote' or 'aside' respectively (matching its syntactic
243          type).
244        - circumstantial participle / ablative absolute: a participial
245          verbal expression's own relatedtoken1 -> the id of the noun or
246          pronoun it agrees with, relationship1 = 'circumstantial
247          participle'. That noun in turn: if it also fits a normal role in
248          the surrounding clause (e.g. it's already the main verb's direct
249          object), it takes THAT normal relation instead (nothing extra to
250          add). If it's an ablative with no other syntactic connection to
251          the sentence (a true ablative absolute), it instead has
252          relatedtoken1 -> the id of the main verb, relationship1 =
253          'ablative absolute'. Sometimes there is no noun or pronoun at
254          all for the participle to agree with -- most often when it
255          agrees with a governing verb's own unexpressed subject. Do NOT
256          leave the participle's relatedtoken1 pointing at the verb
257          directly in that case (that would misrepresent the participle as
258          relating straight to the verb, the way an ablative-absolute noun
259          does): instead add a new tokentype='implied subject' token (see
260          (3) below) standing in for the missing noun, give IT the normal
261          'subject' relation into the verb, and have the participle relate
262          to THIS new token via 'circumstantial participle' exactly as it
263          would to a real one.
264        - auxiliary: in a compound perfect/pluperfect passive, or compound
265          future-infinitive, verb form (participle + a form of 'sum'), the
266          participle or infinitive itself anchors the verbal expression and
267          is the target of every relation into it (subject, direct object,
268          agent, etc); the accompanying form of 'sum' instead has
269          relatedtoken1 -> the id of that participle/infinitive,
270          relationship1 = 'auxiliary'. The same pattern applies to an
271          impersonal passive of an intransitive verb (e.g. "ventum erat",
272          'there had been a coming'): the form of 'sum' still relates to
273          the participle as its auxiliary, even with no subject.
274        - agent: the preposition 'a'/'ab' introducing the agent of a passive
275          verb has relatedtoken1 -> the passive verb's id (the id of the
276          participle, for a compound form -- NOT the accompanying form of
277          'sum'), relationship1 = 'agent'. The noun/pronoun governed by that
278          'a'/'ab' has relatedtoken1 -> the id of 'a'/'ab', relationship1 =
279          'object of preposition'.
280        - subject / direct object / predicate: a noun or pronoun serving as
281          subject or direct object has relatedtoken1 -> the id of the verb
282          (the id of the participle or infinitive, NOT the accompanying
283          form of 'sum', for a compound passive or future-infinitive form),
284          relationship1 = 'subject' or 'direct object'. This applies to the
285          accusative subject of an infinitive in indirect statement too. A
286          noun or pronoun serving as the predicate complement of a LINKING
287          verb uses relationship1 = 'predicate' instead, same relatedtoken1
288          target. If the token is a relative pronoun already using
289          relatedtoken1/relationship1 for its antecedent link, put this
290          relation in relatedtoken2/relationship2 instead.
291        - adjectival: an adjective (or an attributive participle) modifying
292          a noun has relatedtoken1 -> the noun's id, relationship1 =
293          'adjectival'. An adjective used as a substantive (standing in for
294          a noun) is treated as a noun/pronoun instead, not as adjectival.
295        - genitive / dative / ablative / accusative: a noun in the
296          genitive, dative, ablative, or accusative case that depends on a
297          verb or another noun -- and isn't already covered by a more
298          specific relation above (subject, direct object, object of
299          preposition, ablative absolute, etc) -- has relatedtoken1 -> the
300          id of the verb or noun it depends on, relationship1 = the
301          matching case name ('genitive', 'dative', 'ablative', or
302          'accusative'). These are purely syntactic (case-function) labels,
303          not semantic ones -- don't distinguish e.g. possessive vs.
304          partitive genitive. 'accusative' specifically covers an
305          accusative relation that ISN'T a direct object: a bare
306          accusative of place to which (e.g. "Romam" in "Romam venit",
307          relatedtoken1 -> "venit", even though "venit" is intransitive)
308          or an accusative of extent that modifies another NOUN rather
309          than a verb (e.g. "milia" in "duo milia passuum iter fecerunt",
310          relatedtoken1 -> "iter", the noun it qualifies, not "fecerunt").
311          The idiomatic construction 'opus est' + an ablative is a special
312          case worth noting for 'ablative': the ablative token relates to
313          "opus" itself, not to "est" -- e.g. in "Collatinus negat verbis
314          opus esse", "verbis" has relatedtoken1 -> "opus", relationship1 =
315          'ablative'.
316        - vocative: a noun in the vocative case (direct address) has
317          relatedtoken1 -> the id of the verb of the clause it's addressed
318          within, relationship1 = 'vocative'. Unlike 'genitive'/'dative'/
319          'ablative'/'accusative' above, a vocative relates to a verb
320          only, never to another noun. Example: in "Non est ita, domine,
321          sed servi tui venerunt ut emerent cibos.", "domine" has
322          relatedtoken1 -> "est", relationship1 = 'vocative'.
323        - apposition: when one noun stands in apposition to another, the
324          appositive has relatedtoken1 -> the id of the first (the noun it
325          restates or further identifies), relationship1 = 'apposition'. A
326          genitive depending on either noun still gets its own ordinary
327          'genitive' relation, pointing at whichever noun it actually
328          depends on -- apposition doesn't change that. Example: in
329          "Neptunus et Aegeus Pandionis filius...cum Aethra Pitthei
330          filia...", "filius" is in apposition to "Aegeus" (relatedtoken1
331          -> "Aegeus", relationship1 = 'apposition'), and "Pandionis" (the
332          genitive depending on "filius") has relatedtoken1 -> "filius",
333          relationship1 = 'genitive' -- and likewise "filia" is in
334          apposition to "Aethra", with "Pitthei" as a genitive depending on
335          "filia".
336        - praenomen: a token of tokentype 'praenomen' (an abbreviated Roman
337          first name, e.g. "M." or "Sex.") has relatedtoken1 -> the id of
338          the LEXICAL token spelling out the individual's own name that it
339          abbreviates/precedes, relationship1 = 'praenomen'. Example: in
340          "Sex. Tarquinius inscio Collatino...venit", "Sex." has
341          relatedtoken1 -> "Tarquinius", relationship1 = 'praenomen'. If
342          there is no such lexical name token to relate to -- e.g. the
343          genitive filiation formula "L. f." ("Lucii filius", 'son of
344          Lucius'), where "L." precedes only the abbreviation "f." rather
345          than a lexical name -- leave it unrelated, same as any other
346          token with no relation of these kinds.
347        - prepositional phrases: the preposition has relatedtoken1 -> the id
348          of the verb (adverbial) or noun (attributive) it modifies,
349          relationship1 = 'adverbial' or 'attributive'. The noun/pronoun it
350          governs has relatedtoken1 -> the id of the preposition,
351          relationship1 = 'object of preposition' (or relatedtoken2/
352          relationship2 if relatedtoken1 is already used for a
353          relative-pronoun link).
354        - adverbial (bare adverb): an adverb modifying a verb has
355          relatedtoken1 -> the verb's id, relationship1 = 'adverbial' --
356          the same relationship1 value as a preposition modifying a verb,
357          just with no object-of-preposition token on the other end.
358
359        Only assign relations described above. Leave relatedtoken/
360        relationship fields unset for tokens with no relation of these
361        kinds -- not every token will have one (e.g. a bare accusative of
362        place isn't covered). Use only the token ids given in
363        the input `tokens` list, the sentinel 'root', or a NEW id you
364        create for an implied token (see below), in your output; never
365        invent an id for anything else.
366
367    (3) implied/elided tokens. `arsgrammatica` recognizes three DIFFERENT
368        situations where something exists grammatically but has no surface
369        realization in the passage at all -- rather than skip these, add a
370        NEW entry to `tokengraph` with: a brand-new id, not used by any
371        entry in `tokens` or elsewhere in your own output (see the naming
372        rule below); the matching tokentype below; and no `token` value
373        (leave it unset/None) -- these go together, and 'implied sum',
374        'continued discourse', and 'implied subject' are the ONLY three
375        tokentype values whose id isn't one of `tokens`' own ids and whose
376        `token` is empty. `continued discourse` always stands in for a
377        missing VERBAL expression and so always needs a matching new entry
378        in `verbalunits`, exactly like any other verbal expression.
379        `implied sum` stands in for a missing verbal expression in TWO of
380        its three sub-cases (and needs a `verbalunits` entry there too),
381        but NOT in its third -- the compound-passive-with-omitted-auxiliary
382        sub-case, where a real, already-present participle anchors the
383        verbal expression instead and the implied token merely relates to
384        it as 'auxiliary' (see below for which is which). `implied subject`
385        stands in for a missing NOUN or pronoun instead, and never gets a
386        `verbalunits` entry of its own.
387
388        - tokentype 'implied sum': an elided present of 'sum' ('to be').
389          Three sub-cases, all using this same tokentype:
390            - a bare predicate construction (subject + predicate noun/
391              adjective, no verb at all): the implied token anchors a
392              verbal expression classified 'independent' (or 'dependent',
393              if the elided-'sum' clause is itself subordinate) and
394              'linking verb'; the subject and predicate each relate to it
395              exactly as they would to any linking verb ('subject' /
396              'predicate'). Example: "omnia praeclara rara" ('all splendid
397              things [are] rare') has an implied token anchoring an
398              'independent'/'linking verb' expression, with "omnia" (its
399              own 'praeclara' adjectival) as 'subject' and "rara" as
400              'predicate'.
401            - a compound perfect/pluperfect passive (or impersonal
402              passive) with its auxiliary omitted (e.g. "consules facti"
403              for "consules facti sunt"): UNLIKE the other two sub-cases,
404              the implied token here does NOT anchor the verbal
405              expression -- the participle itself ("facti") is already a
406              real, present token, so IT anchors the verbal expression
407              (its own `verbalunits` entry, relatedtoken1/relationship1 =
408              'root'/'unit verb' if independent, exactly as if it were an
409              ordinary one-word verb) and every relation that would
410              normally target the written-out auxiliary (subject, direct
411              object, predicate, agent, adverbs, etc.) targets the
412              participle instead. The implied token stands in only for the
413              omitted form of 'sum' itself: it gets relatedtoken1 -> the
414              participle's id, relationship1 = 'auxiliary' (exactly as a
415              written-out auxiliary would relate to the participle -- see
416              'auxiliary' above), and it does NOT get its own `verbalunits`
417              entry, since the participle already supplies the verbal
418              expression.
419            - the present participle of 'sum' does not exist in Latin at
420              all, so an ablative-absolute-style predicate construction
421              built on it (e.g. "Agrippa Menenio P. Postumio consulibus",
422              '[when] Agrippa Menenius [and] Publius Postumius [were]
423              consuls') is ALWAYS implied, never optional. Classify the
424              implied token's verbal expression 'dependent' (this
425              codebase's circumstantial-participle convention -- see
426              VerbalExpression's own docstring) and 'linking verb'; relate
427              it to its noun via 'circumstantial participle' exactly like
428              any other circumstantial participle.
429        - tokentype 'continued discourse': continuation of indirect
430          discourse -- a long run of indirect statements can share one
431          governing verb of speaking/thinking stated once, then omitted
432          across several further coordinate statements. Add ONE implied
433          token (tokentype 'continued discourse') for that omitted
434          governing verb (syntactic type 'independent' unless the whole
435          passage is itself subordinate, semantic type 'transitive active'
436          unless context says otherwise), and give EACH of the governed
437          infinitives its normal 'indirect statement' relation into it,
438          exactly as if the verb had been repeated for each one.
439        - tokentype 'implied subject': a participle's own antecedent (the
440          noun/pronoun it agrees with, related via 'circumstantial
441          participle' -- see that relation's own note above) can itself go
442          unexpressed, most often when the participle agrees with a
443          governing verb's own unexpressed subject. Rather than leaving
444          the participle with no antecedent to point at (or, worse,
445          pointing it straight at the verb, which would misrepresent it as
446          an ablative absolute), add ONE implied token (tokentype 'implied
447          subject') to stand in for the missing noun/pronoun. This implied
448          token is NOT a verbal expression itself -- it gets no
449          `verbalunits` entry -- it simply takes a normal 'subject'
450          relation into the governing verb (relatedtoken1 -> the verb's
451          id, relationship1 = 'subject'), exactly as if that subject had
452          been written out as a real word. The participle then relates to
453          THIS new token via 'circumstantial participle', exactly as it
454          would to a real antecedent. Example: in "Recordatusque
455          somniorum ait ad eos: Exploratores estis.", the participle
456          "Recordatus" agrees with the unexpressed subject of "ait" -- add
457          an implied token (tokentype 'implied subject') with relatedtoken1
458          -> "ait"'s id, relationship1 = 'subject'; "Recordatus" then has
459          relatedtoken1 -> that new implied token's id, relationship1 =
460          'circumstantial participle' (and, since "Recordatus" is itself a
461          predicate-sense participle, it ALSO anchors its own
462          `verbalunits` entry, syntactic_type 'dependent', semantic_type
463          matching its own transitivity -- unaffected by its antecedent
464          being implied rather than real).
465
466        Naming an implied token's id (all three tokentypes): append
467        '_implied' to the id of the LAST real token in `tokens` that
468        precedes where the elided word would have stood (or, if the elided
469        word would come before every real token in the sentence, the FIRST
470        real token's id instead). If more than one implied token is ever
471        needed at the same position, append '2', '3', ... after '_implied'
472        to keep them unique (e.g. 't5_implied', 't5_implied2'). Place the
473        new `tokengraph` entry at the list position where the elided word
474        would have appeared, among the tokens of its own clause -- this
475        keeps it grouped with the rest of its verbal expression (or, for
476        'implied subject', the clause of the verb it's the subject of) for
477        anything that reads `tokengraph` in order.
478    """
479 
480    passage: str = dspy.InputField(desc="The Latin passage to analyze, exactly as written.")
481    tokens: List[Token] = dspy.InputField(
482        desc="Pre-segmented tokens of the passage, in order, with fixed ids. Reference these ids in your output; do not create new ones."
483    )
484    verbalunits: List[VerbalExpression] = dspy.OutputField(
485        desc="One entry per verbal expression (finite verb; infinitive used in indirect speech; or predicate-sense participle) in the passage."
486    )
487    tokengraph: List[TokenAnalysis] = dspy.OutputField(
488        desc=(
489            "One entry per token in `tokens`, in the same order, with its "
490            "type and any relations -- PLUS one additional entry for each "
491            "implied/elided token you add (see this signature's docstring), "
492            "positioned where that token's clause falls in reading order."
493        )
494    )

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

(1) a list of verbal expressions. Three constructions count as a verbal expression: finite verbs, infinitives, and participles.

- A finite verb (including compound perfect/pluperfect passive forms
  of participle + a form of 'sum') 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. "est" in `"Tuum est," inquit, "Servi regnum."`),
  or 'aside' (a verbal expression that interrupts the surrounding
  syntax, e.g. "dixerim" in "pace dixerim deum" interrupting "eos...
  spero").
- An infinitive is a verbal expression only when part of an indirect
  statement; its syntactic type is always 'indirect statement'. In a
  compound future-infinitive form (participle + a form of 'sum',
  e.g. "facturum...fuisse"), the participle/infinitive itself
  anchors the verbal expression, same as a compound passive.
- A participle is a verbal expression only when it has a *predicate*
  sense (e.g. an ablative-absolute-like "Anco regnante Lucumo...",
  'while Ancus was reigning') rather than a purely *attributive*
  sense (modifying a noun like an ordinary adjective, e.g.
  "consentiens laus", 'universal praise' -- NOT a verbal expression
  at all). Use 'dependent' as its syntactic type. When it's
  genuinely uncertain whether a given participle is attributive or
  predicate/circumstantial, PREFER the circumstantial reading --
  treat it as its own verbal expression rather than folding it into
  an attributive relation. Example: in "ille moriens, cum sciret
  sagittas hydrae Lernaeae felle tinctas quantam uim haberent
  ueneni, sanguinem suum exceptum Deianirae dedit", both "moriens"
  (agreeing with "ille") and "tinctas" (agreeing with "sagittas")
  are treated as circumstantial participles, each anchoring its own
  verbal expression, rather than as ordinary attributive
  adjectives.

Classify each verbal expression's semantic type too (transitive
active/transitive passive/intransitive/linking verb).

(2) a token-by-token dependency graph. For each token, record up to two relations to other tokens (by id), using only these relation labels:

- unit verb (independent): every INDEPENDENT verb 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'.
- unit verb (dependent) / subordinating conjunction / relative
  pronoun: the verb of a DEPENDENT clause has relatedtoken1 -> the
  id of its subordinating conjunction or relative 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.
  Indirect questions are treated as a kind of dependent clause: the
  interrogative word introducing one (e.g. "quanta" in "Theseus
  audit quanta calamitate ciuitas afficeretur") is treated the same
  way as a subordinating conjunction -- it has relatedtoken1 -> the
  id of the verb it introduces (here "audit"), relationship1 =
  'subordinating conjunction' (no separate label for this case) --
  while the dependent verb itself ("afficeretur") has relatedtoken1
  -> the interrogative word's id ("quanta"), relationship1 = 'unit
  verb', exactly like any other dependent clause.
- indirect statement (governing verb): an infinitive 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 (a Latin accusative-and-infinitive construction has no
  equivalent of English 'that'), so the infinitive points directly
  at its governing verb, rather than via a conjunction/pronoun
  intermediary the way a dependent finite verb's 'unit verb'
  relation does. In a compound future-infinitive form (participle +
  a form of 'sum'), this relation belongs on the participle/
  infinitive itself, since IT anchors the verbal expression (see
  'auxiliary' below) -- the form of 'sum' takes no relation of its
  own into the governing verb.
- complementary infinitive: an infinitive that completes the sense
  of a governing verb like 'volo', 'incipio', 'audeo', 'licet', or
  'decet' (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 "Amphion...cum templum
  Apollinis expugnare vellet...", "expugnare" completes "vellet"
  (relatedtoken1 -> "vellet", relationship1 = 'complementary
  infinitive'); "templum" is still "expugnare"'s own direct object,
  exactly as if "expugnare" were a finite verb.
- 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). Example: in "dolere malum
  est", "dolere" has relatedtoken1 -> "est", relationship1 =
  'subject'. 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.
- gerunds and gerundives: a gerundive is simply an adjective --
  treat it exactly like one (relatedtoken1 -> the noun it agrees
  with, relationship1 = 'adjectival'; see 'adjectival' below).
  Example: in "...ad sacrum faciendum", "faciendum" (the gerundive)
  has relatedtoken1 -> "sacrum", relationship1 = 'adjectival'. A
  gerund is a noun -- the oblique-case form a verb takes where an
  infinitive would be needed in the nominative -- so relate it like
  any other noun (most often 'genitive'); it can still take its own
  object or adverb, related to it the same way they'd relate to a
  finite verb or infinitive. Example: in "ars bene disserendi",
  "disserendi" (the gerund) has relatedtoken1 -> "ars",
  relationship1 = 'genitive', and "bene" (the adverb modifying it)
  has relatedtoken1 -> "disserendi", relationship1 = 'adverbial'.
  Neither a gerund nor a gerundive is a verbal expression in its
  own right -- no dedicated label, no `verbalunits` entry.
- coordinating conjunction: when a coordinating conjunction (e.g.
  'et', '-que') joins a pair of adjectives, nouns, or prepositional
  phrases, it has relatedtoken1 -> the id of the first joined
  token, relatedtoken2 -> the id of the second, with BOTH
  relationship1 and relationship2 = 'coordinating conjunction' (not
  an overflow slot here -- this is the one relation that genuinely
  uses relatedtoken1 and relatedtoken2 for two ends of the same
  relation at once). When it joins two verbal expressions instead,
  relatedtoken1/relatedtoken2 are the ids of the two verbs (or, for
  an infinitive/participle-anchored verbal expression, the id that
  anchors it) rather than of nearby nouns -- go by which verbal
  expression the conjunction functionally introduces, NOT by which
  token it happens to be adjacent to or (for an enclitic like
  '-que') physically attached to; those can differ (e.g. an
  enclitic conjunction attached to the second clause's direct
  object still relates the two VERBS, not the object). If the
  conjunction opens an entirely new sentence with no explicit verb
  to its left to pair with, set only relatedtoken1/relationship1 (->
  the verb it introduces); do not invent a link to an implied
  preceding clause. 'et' specifically can also function as a plain
  adverb ('even', 'also') rather than a conjunction -- when it
  does, treat it like any other adverb: relatedtoken1 -> the verb
  or (if there is none, e.g. a verbless exclamation) the nearest
  token it emphasizes, relationship1 = 'adverbial', not
  'coordinating conjunction'. IMPORTANT: when the conjunction joins
  two independent verbs, BOTH still get their own `verbalunits`
  entry and their own relatedtoken1 = 'root'/relationship1 = 'unit
  verb' -- this doesn't change just because one of them (usually
  the second) also governs further subordinate structure of its
  own (a dependent clause, an indirect statement, etc). A verb
  that governs an indirect statement or introduces a further
  clause is NOT thereby demoted to a mere "framing verb" for what
  follows -- it is still, independently, one of the two
  coordinated root verbs, and needs its own entry exactly like the
  first one. Example: in "...dedit et id philtrum esse dixit.",
  dedit and dixit are both independent verbs coordinated by et;
  dixit ALSO governs the indirect statement anchored at esse
  ('id philtrum esse'), but that does not exempt dixit itself from
  getting relatedtoken1 = 'root', relationship1 = 'unit verb', and
  its own entry in `verbalunits` -- exactly as if it stood alone.
- coordinating conjunction, repeated as a series: a conjunction
  like 'et' or 'aut' can also be repeated to coordinate a series
  of three or more items (polysyndeton, e.g. 'et...et...et'), not
  just used once between a pair. Two shapes occur, and they're
  annotated differently:
    - "A et B et C" (a conjunction only BETWEEN items, none before
      the first): annotate each connector exactly like the simple
      pairwise case above -- relatedtoken1 -> the item immediately
      BEFORE it, relatedtoken2 -> the item immediately AFTER it,
      both relationship1/relationship2 = 'coordinating
      conjunction'. The first connector (between A and B) has
      relatedtoken1 -> A, relatedtoken2 -> B; the second (between
      B and C) has relatedtoken1 -> B, relatedtoken2 -> C; and so
      on for a longer series. Every connector always relates
      directly to the two real items on either side of it --
      NEVER to another connector.
    - "et A et B et C" (an introductory connector before the
      FIRST item too, one connector per item): the introductory
      connector (before A) has ONLY relatedtoken1 -> A,
      relationship1 = 'coordinating conjunction' -- there is no
      preceding item for it to pair with, so relatedtoken2 is left
      unset, same as the sentence-initial one-sided case above.
      Every connector AFTER the introductory one is annotated
      exactly like the "A et B et C" case: relatedtoken1 -> the
      PRECEDING item, relatedtoken2 -> the FOLLOWING item, both
      relationship labels 'coordinating conjunction'. So the
      second connector (before B) has relatedtoken1 -> A,
      relatedtoken2 -> B; the third (before C) has relatedtoken1
      -> B, relatedtoken2 -> C.
  Either way, every connector in a series relates directly to the
  two real coordinated items flanking it (or, for an introductory
  connector, the one item following it) -- never to a neighboring
  connector. Each connected item ALSO keeps its own ordinary
  relation to the rest of the sentence (subject, ablative, direct
  object, or whatever fits), completely independent of this
  chain -- the coordinating-conjunction relation only links a
  connector to its flanking item(s); it never substitutes for
  that item's own relation to whatever governs it. Example: in
  "Tarquinius et assiduitate et varietate et magnificentia omnes
  antecessit" ("et A et B et C" shape -- an et before every one of
  the three ablatives, including the first), the first et
  (introductory) has ONLY relatedtoken1 -> assiduitate; the
  second et has relatedtoken1 -> assiduitate, relatedtoken2 ->
  varietate; the third et has relatedtoken1 -> varietate,
  relatedtoken2 -> magnificentia. assiduitate, varietate, and
  magnificentia each ALSO have their own relatedtoken1 ->
  antecessit, relationship1 = 'ablative', same as any other
  ablative -- unaffected by which connector introduces them.
- 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).
- circumstantial participle / ablative absolute: a 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 direct
  object), it takes THAT normal relation instead (nothing extra to
  add). If it's an ablative with no other syntactic connection to
  the sentence (a true ablative absolute), it instead has
  relatedtoken1 -> the id of the main verb, relationship1 =
  'ablative absolute'. Sometimes there is no noun or pronoun at
  all for the participle to agree with -- most often when it
  agrees with a governing verb's own unexpressed subject. Do NOT
  leave the participle's relatedtoken1 pointing at the verb
  directly in that case (that would misrepresent the participle as
  relating straight to the verb, the way an ablative-absolute noun
  does): instead add a new tokentype='implied subject' token (see
  (3) below) standing in for the missing noun, give IT the normal
  'subject' relation into the verb, and have the participle relate
  to THIS new token via 'circumstantial participle' exactly as it
  would to a real one.
- auxiliary: in a compound perfect/pluperfect passive, or compound
  future-infinitive, verb form (participle + a form of 'sum'), the
  participle or infinitive itself anchors the verbal expression and
  is the target of every relation into it (subject, direct object,
  agent, etc); the accompanying form of 'sum' instead has
  relatedtoken1 -> the id of that participle/infinitive,
  relationship1 = 'auxiliary'. The same pattern applies to an
  impersonal passive of an intransitive verb (e.g. "ventum erat",
  'there had been a coming'): the form of 'sum' still relates to
  the participle as its auxiliary, even with no subject.
- agent: the preposition 'a'/'ab' introducing the agent of a passive
  verb has relatedtoken1 -> the passive verb's id (the id of the
  participle, for a compound form -- NOT the accompanying form of
  'sum'), relationship1 = 'agent'. The noun/pronoun governed by that
  'a'/'ab' has relatedtoken1 -> the id of 'a'/'ab', 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 participle or infinitive, NOT the accompanying
  form of 'sum', for a compound passive or future-infinitive form),
  relationship1 = 'subject' or 'direct object'. This applies to the
  accusative subject of an infinitive in indirect statement too. A
  noun or pronoun serving as the predicate complement of a LINKING
  verb uses relationship1 = 'predicate' instead, same relatedtoken1
  target. If the token is a relative pronoun already using
  relatedtoken1/relationship1 for its antecedent link, put this
  relation in relatedtoken2/relationship2 instead.
- adjectival: an adjective (or an attributive participle) modifying
  a noun has relatedtoken1 -> the noun's id, relationship1 =
  'adjectival'. An adjective used as a substantive (standing in for
  a noun) is treated as a noun/pronoun instead, not as adjectival.
- genitive / dative / ablative / accusative: a noun in the
  genitive, dative, ablative, 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, ablative absolute, etc) -- has relatedtoken1 -> the
  id of the verb or noun it depends on, relationship1 = the
  matching case name ('genitive', 'dative', 'ablative', or
  'accusative'). These are purely syntactic (case-function) labels,
  not semantic ones -- don't distinguish e.g. possessive vs.
  partitive genitive. 'accusative' specifically covers an
  accusative relation that ISN'T a direct object: a bare
  accusative of place to which (e.g. "Romam" in "Romam venit",
  relatedtoken1 -> "venit", even though "venit" is intransitive)
  or an accusative of extent that modifies another NOUN rather
  than a verb (e.g. "milia" in "duo milia passuum iter fecerunt",
  relatedtoken1 -> "iter", the noun it qualifies, not "fecerunt").
  The idiomatic construction 'opus est' + an ablative is a special
  case worth noting for 'ablative': the ablative token relates to
  "opus" itself, not to "est" -- e.g. in "Collatinus negat verbis
  opus esse", "verbis" has relatedtoken1 -> "opus", relationship1 =
  'ablative'.
- 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'/
  'ablative'/'accusative' above, a vocative relates to a verb
  only, never to another noun. Example: in "Non est ita, domine,
  sed servi tui venerunt ut emerent cibos.", "domine" has
  relatedtoken1 -> "est", 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. Example: in
  "Neptunus et Aegeus Pandionis filius...cum Aethra Pitthei
  filia...", "filius" is in apposition to "Aegeus" (relatedtoken1
  -> "Aegeus", relationship1 = 'apposition'), and "Pandionis" (the
  genitive depending on "filius") has relatedtoken1 -> "filius",
  relationship1 = 'genitive' -- and likewise "filia" is in
  apposition to "Aethra", with "Pitthei" as a genitive depending on
  "filia".
- praenomen: a token of tokentype 'praenomen' (an abbreviated Roman
  first name, e.g. "M." or "Sex.") has relatedtoken1 -> the id of
  the LEXICAL token spelling out the individual's own name that it
  abbreviates/precedes, relationship1 = 'praenomen'. Example: in
  "Sex. Tarquinius inscio Collatino...venit", "Sex." has
  relatedtoken1 -> "Tarquinius", relationship1 = 'praenomen'. If
  there is no such lexical name token to relate to -- e.g. the
  genitive filiation formula "L. f." ("Lucii filius", 'son of
  Lucius'), where "L." precedes only the abbreviation "f." rather
  than a lexical name -- leave it unrelated, same as any other
  token with no relation of these kinds.
- 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).
- adverbial (bare adverb): an adverb modifying a verb has
  relatedtoken1 -> the verb's id, relationship1 = 'adverbial' --
  the same relationship1 value as a preposition modifying a verb,
  just with no object-of-preposition token on the other end.

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
place isn't covered). 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. arsgrammatica recognizes three DIFFERENT situations where something exists grammatically but has no surface realization in the passage at all -- rather than skip these, add a NEW entry to tokengraph with: a brand-new id, not used by any entry in tokens or elsewhere in your own output (see the naming rule below); the matching tokentype below; and no token value (leave it unset/None) -- these go together, and 'implied sum', 'continued discourse', and 'implied subject' are the ONLY three tokentype values whose id isn't one of tokens' own ids and whose token is empty. continued discourse always stands in for a missing VERBAL expression and so always needs a matching new entry in verbalunits, exactly like any other verbal expression. implied sum stands in for a missing verbal expression in TWO of its three sub-cases (and needs a verbalunits entry there too), but NOT in its third -- the compound-passive-with-omitted-auxiliary sub-case, where a real, already-present participle anchors the verbal expression instead and the implied token merely relates to it as 'auxiliary' (see below for which is which). implied subject stands in for a missing NOUN or pronoun instead, and never gets a verbalunits entry of its own.

- tokentype 'implied sum': an elided present of 'sum' ('to be').
  Three sub-cases, all using this same tokentype:
    - a bare predicate construction (subject + predicate noun/
      adjective, no verb at all): the implied token anchors a
      verbal expression classified 'independent' (or 'dependent',
      if the elided-'sum' clause is itself subordinate) and
      'linking verb'; the subject and predicate each relate to it
      exactly as they would to any linking verb ('subject' /
      'predicate'). Example: "omnia praeclara rara" ('all splendid
      things [are] rare') has an implied token anchoring an
      'independent'/'linking verb' expression, with "omnia" (its
      own 'praeclara' adjectival) as 'subject' and "rara" as
      'predicate'.
    - a compound perfect/pluperfect passive (or impersonal
      passive) with its auxiliary omitted (e.g. "consules facti"
      for "consules facti sunt"): UNLIKE the other two sub-cases,
      the implied token here does NOT anchor the verbal
      expression -- the participle itself ("facti") is already a
      real, present token, so IT anchors the verbal expression
      (its own `verbalunits` entry, relatedtoken1/relationship1 =
      'root'/'unit verb' if independent, exactly as if it were an
      ordinary one-word verb) and every relation that would
      normally target the written-out auxiliary (subject, direct
      object, predicate, agent, adverbs, etc.) targets the
      participle instead. The implied token stands in only for the
      omitted form of 'sum' itself: it gets relatedtoken1 -> the
      participle's id, relationship1 = 'auxiliary' (exactly as a
      written-out auxiliary would relate to the participle -- see
      'auxiliary' above), and it does NOT get its own `verbalunits`
      entry, since the participle already supplies the verbal
      expression.
    - the present participle of 'sum' does not exist in Latin at
      all, so an ablative-absolute-style predicate construction
      built on it (e.g. "Agrippa Menenio P. Postumio consulibus",
      '[when] Agrippa Menenius [and] Publius Postumius [were]
      consuls') is ALWAYS implied, never optional. Classify the
      implied token's verbal expression 'dependent' (this
      codebase's circumstantial-participle convention -- see
      VerbalExpression's own docstring) and 'linking verb'; relate
      it to its noun via 'circumstantial participle' exactly like
      any other circumstantial participle.
- tokentype 'continued discourse': continuation of indirect
  discourse -- a long run of indirect statements can share one
  governing verb of speaking/thinking stated once, then omitted
  across several further coordinate statements. Add ONE implied
  token (tokentype 'continued discourse') for that omitted
  governing verb (syntactic type 'independent' unless the whole
  passage is itself subordinate, semantic type 'transitive active'
  unless context says otherwise), and give EACH of the governed
  infinitives its normal 'indirect statement' relation into it,
  exactly as if the verb had been repeated for each one.
- tokentype 'implied subject': a participle's own antecedent (the
  noun/pronoun it agrees with, related via 'circumstantial
  participle' -- see that relation's own note above) can itself go
  unexpressed, most often when the participle agrees with a
  governing verb's own unexpressed subject. Rather than leaving
  the participle with no antecedent to point at (or, worse,
  pointing it straight at the verb, which would misrepresent it as
  an ablative absolute), add ONE implied token (tokentype 'implied
  subject') to stand in for the missing noun/pronoun. This implied
  token is NOT a verbal expression itself -- it gets no
  `verbalunits` entry -- it simply takes a normal 'subject'
  relation into the governing verb (relatedtoken1 -> the verb's
  id, relationship1 = 'subject'), exactly as if that subject had
  been written out as a real word. The participle then relates to
  THIS new token via 'circumstantial participle', exactly as it
  would to a real antecedent. Example: in "Recordatusque
  somniorum ait ad eos: Exploratores estis.", the participle
  "Recordatus" agrees with the unexpressed subject of "ait" -- add
  an implied token (tokentype 'implied subject') with relatedtoken1
  -> "ait"'s id, relationship1 = 'subject'; "Recordatus" then has
  relatedtoken1 -> that new implied token's id, relationship1 =
  'circumstantial participle' (and, since "Recordatus" is itself a
  predicate-sense participle, it ALSO anchors its own
  `verbalunits` entry, syntactic_type 'dependent', semantic_type
  matching its own transitivity -- unaffected by its antecedent
  being implied rather than real).

Naming an implied token's id (all three 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 (or, for
'implied subject', the clause of the verb it's the subject of) 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 Latin according to a two-part scheme:\n\n(1) a list of verbal expressions. Three constructions count as a verbal\n expression: finite verbs, infinitives, and participles.\n\n - A finite verb (including compound perfect/pluperfect passive forms\n of participle + a form of \'sum\') is always a verbal expression.\n Classify its syntactic type as \'independent\' (main/principal),\n \'dependent\' (subordinate, introduced by a subordinating word),\n \'direct quote\' (occurring in directly quoted speech framed by\n another verb, e.g. "est" in `"Tuum est," inquit, "Servi regnum."`),\n or \'aside\' (a verbal expression that interrupts the surrounding\n syntax, e.g. "dixerim" in "pace dixerim deum" interrupting "eos...\n spero").\n - An infinitive is a verbal expression only when part of an indirect\n statement; its syntactic type is always \'indirect statement\'. In a\n compound future-infinitive form (participle + a form of \'sum\',\n e.g. "facturum...fuisse"), the participle/infinitive itself\n anchors the verbal expression, same as a compound passive.\n - A participle is a verbal expression only when it has a *predicate*\n sense (e.g. an ablative-absolute-like "Anco regnante Lucumo...",\n \'while Ancus was reigning\') rather than a purely *attributive*\n sense (modifying a noun like an ordinary adjective, e.g.\n "consentiens laus", \'universal praise\' -- NOT a verbal expression\n at all). Use \'dependent\' as its syntactic type. When it\'s\n genuinely uncertain whether a given participle is attributive or\n predicate/circumstantial, PREFER the circumstantial reading --\n treat it as its own verbal expression rather than folding it into\n an attributive relation. Example: in "ille moriens, cum sciret\n sagittas hydrae Lernaeae felle tinctas quantam uim haberent\n ueneni, sanguinem suum exceptum Deianirae dedit", both "moriens"\n (agreeing with "ille") and "tinctas" (agreeing with "sagittas")\n are treated as circumstantial participles, each anchoring its own\n verbal expression, rather than as ordinary attributive\n adjectives.\n\n Classify each verbal expression\'s semantic type too (transitive\n active/transitive passive/intransitive/linking verb).\n\n(2) a token-by-token dependency graph. For each token, record up to two\n relations to other tokens (by id), using only these relation 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\'.\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 pronoun,\n relationship1 = \'unit verb\'. That conjunction or pronoun in turn\n has relatedtoken1 -> the id of the verb of the clause it is\n subordinate to, with relationship1 = \'subordinating conjunction\'\n for a conjunction, or relatedtoken1 -> its antecedent\'s id with\n relationship1 = \'relative pronoun\' for a relative pronoun.\n Indirect questions are treated as a kind of dependent clause: the\n interrogative word introducing one (e.g. "quanta" in "Theseus\n audit quanta calamitate ciuitas afficeretur") is treated the same\n way as a subordinating conjunction -- it has relatedtoken1 -> the\n id of the verb it introduces (here "audit"), relationship1 =\n \'subordinating conjunction\' (no separate label for this case) --\n while the dependent verb itself ("afficeretur") has relatedtoken1\n -> the interrogative word\'s id ("quanta"), relationship1 = \'unit\n verb\', exactly like any other dependent clause.\n - indirect statement (governing verb): an infinitive anchoring an\n indirect-statement verbal expression ALSO has relatedtoken1 ->\n the id of the verb that governs the indirect statement (the verb\n of saying/thinking/perceiving it depends on), relationship1 =\n \'indirect statement\' -- matching its own syntactic type, the same\n convention \'direct quote\' and \'aside\' verbal expressions use\n below. There\'s no separate subordinating-word token to point at\n first (a Latin accusative-and-infinitive construction has no\n equivalent of English \'that\'), so the infinitive points directly\n at its governing verb, rather than via a conjunction/pronoun\n intermediary the way a dependent finite verb\'s \'unit verb\'\n relation does. In a compound future-infinitive form (participle +\n a form of \'sum\'), this relation belongs on the participle/\n infinitive itself, since IT anchors the verbal expression (see\n \'auxiliary\' below) -- the form of \'sum\' takes no relation of its\n own into the governing verb.\n - complementary infinitive: an infinitive that completes the sense\n of a governing verb like \'volo\', \'incipio\', \'audeo\', \'licet\', or\n \'decet\' (rather than reporting indirect speech) has relatedtoken1\n -> the id of that governing verb, relationship1 = \'complementary\n infinitive\'. Unlike an indirect-statement infinitive, this does\n NOT make the infinitive its own verbal expression -- it gets no\n `verbalunits` entry of its own; the governing verb is still the\n only verbal expression here. Example: in "Amphion...cum templum\n Apollinis expugnare vellet...", "expugnare" completes "vellet"\n (relatedtoken1 -> "vellet", relationship1 = \'complementary\n infinitive\'); "templum" is still "expugnare"\'s own direct object,\n exactly as if "expugnare" were a finite verb.\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 = \'subject\'\n or \'direct object\' as appropriate (no dedicated label, and again\n no `verbalunits` entry of its own). Example: in "dolere malum\n est", "dolere" has relatedtoken1 -> "est", relationship1 =\n \'subject\'. Like any verbal form, an infinitive used this way can\n still take its own object or adverb, related to it the same way\n they\'d relate to a finite verb.\n - gerunds and gerundives: a gerundive is simply an adjective --\n treat it exactly like one (relatedtoken1 -> the noun it agrees\n with, relationship1 = \'adjectival\'; see \'adjectival\' below).\n Example: in "...ad sacrum faciendum", "faciendum" (the gerundive)\n has relatedtoken1 -> "sacrum", relationship1 = \'adjectival\'. A\n gerund is a noun -- the oblique-case form a verb takes where an\n infinitive would be needed in the nominative -- so relate it like\n any other noun (most often \'genitive\'); it can still take its own\n object or adverb, related to it the same way they\'d relate to a\n finite verb or infinitive. Example: in "ars bene disserendi",\n "disserendi" (the gerund) has relatedtoken1 -> "ars",\n relationship1 = \'genitive\', and "bene" (the adverb modifying it)\n has relatedtoken1 -> "disserendi", relationship1 = \'adverbial\'.\n Neither a gerund nor a gerundive is a verbal expression in its\n own right -- no dedicated label, no `verbalunits` entry.\n - coordinating conjunction: when a coordinating conjunction (e.g.\n \'et\', \'-que\') joins a pair of adjectives, nouns, or prepositional\n phrases, it has relatedtoken1 -> the id of the first joined\n token, relatedtoken2 -> the id of the second, with BOTH\n relationship1 and relationship2 = \'coordinating conjunction\' (not\n an overflow slot here -- this is the one relation that genuinely\n uses relatedtoken1 and relatedtoken2 for two ends of the same\n relation at once). When it joins two verbal expressions instead,\n relatedtoken1/relatedtoken2 are the ids of the two verbs (or, for\n an infinitive/participle-anchored verbal expression, the id that\n anchors it) rather than of nearby nouns -- go by which verbal\n expression the conjunction functionally introduces, NOT by which\n token it happens to be adjacent to or (for an enclitic like\n \'-que\') physically attached to; those can differ (e.g. an\n enclitic conjunction attached to the second clause\'s direct\n object still relates the two VERBS, not the object). If the\n conjunction opens an entirely new sentence with no explicit verb\n to its left to pair with, set only relatedtoken1/relationship1 (->\n the verb it introduces); do not invent a link to an implied\n preceding clause. \'et\' specifically can also function as a plain\n adverb (\'even\', \'also\') rather than a conjunction -- when it\n does, treat it like any other adverb: relatedtoken1 -> the verb\n or (if there is none, e.g. a verbless exclamation) the nearest\n token it emphasizes, relationship1 = \'adverbial\', not\n \'coordinating conjunction\'. IMPORTANT: when the conjunction joins\n two independent verbs, BOTH still get their own `verbalunits`\n entry and their own relatedtoken1 = \'root\'/relationship1 = \'unit\n verb\' -- this doesn\'t change just because one of them (usually\n the second) also governs further subordinate structure of its\n own (a dependent clause, an indirect statement, etc). A verb\n that governs an indirect statement or introduces a further\n clause is NOT thereby demoted to a mere "framing verb" for what\n follows -- it is still, independently, one of the two\n coordinated root verbs, and needs its own entry exactly like the\n first one. Example: in "...dedit et id philtrum esse dixit.",\n dedit and dixit are both independent verbs coordinated by et;\n dixit ALSO governs the indirect statement anchored at esse\n (\'id philtrum esse\'), but that does not exempt dixit itself from\n getting relatedtoken1 = \'root\', relationship1 = \'unit verb\', and\n its own entry in `verbalunits` -- exactly as if it stood alone.\n - coordinating conjunction, repeated as a series: a conjunction\n like \'et\' or \'aut\' can also be repeated to coordinate a series\n of three or more items (polysyndeton, e.g. \'et...et...et\'), not\n just used once between a pair. Two shapes occur, and they\'re\n annotated differently:\n - "A et B et C" (a conjunction only BETWEEN items, none before\n the first): annotate each connector exactly like the simple\n pairwise case above -- relatedtoken1 -> the item immediately\n BEFORE it, relatedtoken2 -> the item immediately AFTER it,\n both relationship1/relationship2 = \'coordinating\n conjunction\'. The first connector (between A and B) has\n relatedtoken1 -> A, relatedtoken2 -> B; the second (between\n B and C) has relatedtoken1 -> B, relatedtoken2 -> C; and so\n on for a longer series. Every connector always relates\n directly to the two real items on either side of it --\n NEVER to another connector.\n - "et A et B et C" (an introductory connector before the\n FIRST item too, one connector per item): the introductory\n connector (before A) has ONLY relatedtoken1 -> A,\n relationship1 = \'coordinating conjunction\' -- there is no\n preceding item for it to pair with, so relatedtoken2 is left\n unset, same as the sentence-initial one-sided case above.\n Every connector AFTER the introductory one is annotated\n exactly like the "A et B et C" case: relatedtoken1 -> the\n PRECEDING item, relatedtoken2 -> the FOLLOWING item, both\n relationship labels \'coordinating conjunction\'. So the\n second connector (before B) has relatedtoken1 -> A,\n relatedtoken2 -> B; the third (before C) has relatedtoken1\n -> B, relatedtoken2 -> C.\n Either way, every connector in a series relates directly to the\n two real coordinated items flanking it (or, for an introductory\n connector, the one item following it) -- never to a neighboring\n connector. Each connected item ALSO keeps its own ordinary\n relation to the rest of the sentence (subject, ablative, direct\n object, or whatever fits), completely independent of this\n chain -- the coordinating-conjunction relation only links a\n connector to its flanking item(s); it never substitutes for\n that item\'s own relation to whatever governs it. Example: in\n "Tarquinius et assiduitate et varietate et magnificentia omnes\n antecessit" ("et A et B et C" shape -- an et before every one of\n the three ablatives, including the first), the first et\n (introductory) has ONLY relatedtoken1 -> assiduitate; the\n second et has relatedtoken1 -> assiduitate, relatedtoken2 ->\n varietate; the third et has relatedtoken1 -> varietate,\n relatedtoken2 -> magnificentia. assiduitate, varietate, and\n magnificentia each ALSO have their own relatedtoken1 ->\n antecessit, relationship1 = \'ablative\', same as any other\n ablative -- unaffected by which connector introduces them.\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).\n - circumstantial participle / ablative absolute: a participial\n verbal expression\'s own relatedtoken1 -> the id of the noun or\n pronoun it agrees with, relationship1 = \'circumstantial\n participle\'. That noun in turn: if it also fits a normal role in\n the surrounding clause (e.g. it\'s already the main verb\'s direct\n object), it takes THAT normal relation instead (nothing extra to\n add). If it\'s an ablative with no other syntactic connection to\n the sentence (a true ablative absolute), it instead has\n relatedtoken1 -> the id of the main verb, relationship1 =\n \'ablative absolute\'. Sometimes there is no noun or pronoun at\n all for the participle to agree with -- most often when it\n agrees with a governing verb\'s own unexpressed subject. Do NOT\n leave the participle\'s relatedtoken1 pointing at the verb\n directly in that case (that would misrepresent the participle as\n relating straight to the verb, the way an ablative-absolute noun\n does): instead add a new tokentype=\'implied subject\' token (see\n (3) below) standing in for the missing noun, give IT the normal\n \'subject\' relation into the verb, and have the participle relate\n to THIS new token via \'circumstantial participle\' exactly as it\n would to a real one.\n - auxiliary: in a compound perfect/pluperfect passive, or compound\n future-infinitive, verb form (participle + a form of \'sum\'), the\n participle or infinitive itself anchors the verbal expression and\n is the target of every relation into it (subject, direct object,\n agent, etc); the accompanying form of \'sum\' instead has\n relatedtoken1 -> the id of that participle/infinitive,\n relationship1 = \'auxiliary\'. The same pattern applies to an\n impersonal passive of an intransitive verb (e.g. "ventum erat",\n \'there had been a coming\'): the form of \'sum\' still relates to\n the participle as its auxiliary, even with no subject.\n - agent: the preposition \'a\'/\'ab\' introducing the agent of a passive\n verb has relatedtoken1 -> the passive verb\'s id (the id of the\n participle, for a compound form -- NOT the accompanying form of\n \'sum\'), relationship1 = \'agent\'. The noun/pronoun governed by that\n \'a\'/\'ab\' has relatedtoken1 -> the id of \'a\'/\'ab\', relationship1 =\n \'object of preposition\'.\n - subject / direct object / predicate: a noun or pronoun serving as\n subject or direct object has relatedtoken1 -> the id of the verb\n (the id of the participle or infinitive, NOT the accompanying\n form of \'sum\', for a compound passive or future-infinitive form),\n relationship1 = \'subject\' or \'direct object\'. This applies to the\n accusative subject of an infinitive in indirect statement too. A\n noun or pronoun serving as the predicate complement of a LINKING\n verb uses relationship1 = \'predicate\' instead, same relatedtoken1\n target. If the token is a relative pronoun already using\n relatedtoken1/relationship1 for its antecedent link, put this\n relation in relatedtoken2/relationship2 instead.\n - adjectival: an adjective (or an attributive participle) modifying\n a noun has relatedtoken1 -> the noun\'s id, relationship1 =\n \'adjectival\'. An adjective used as a substantive (standing in for\n a noun) is treated as a noun/pronoun instead, not as adjectival.\n - genitive / dative / ablative / accusative: a noun in the\n genitive, dative, ablative, or accusative case that depends on a\n verb or another noun -- and isn\'t already covered by a more\n specific relation above (subject, direct object, object of\n preposition, ablative absolute, etc) -- has relatedtoken1 -> the\n id of the verb or noun it depends on, relationship1 = the\n matching case name (\'genitive\', \'dative\', \'ablative\', or\n \'accusative\'). These are purely syntactic (case-function) labels,\n not semantic ones -- don\'t distinguish e.g. possessive vs.\n partitive genitive. \'accusative\' specifically covers an\n accusative relation that ISN\'T a direct object: a bare\n accusative of place to which (e.g. "Romam" in "Romam venit",\n relatedtoken1 -> "venit", even though "venit" is intransitive)\n or an accusative of extent that modifies another NOUN rather\n than a verb (e.g. "milia" in "duo milia passuum iter fecerunt",\n relatedtoken1 -> "iter", the noun it qualifies, not "fecerunt").\n The idiomatic construction \'opus est\' + an ablative is a special\n case worth noting for \'ablative\': the ablative token relates to\n "opus" itself, not to "est" -- e.g. in "Collatinus negat verbis\n opus esse", "verbis" has relatedtoken1 -> "opus", relationship1 =\n \'ablative\'.\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 \'ablative\'/\'accusative\' above, a vocative relates to a verb\n only, never to another noun. Example: in "Non est ita, domine,\n sed servi tui venerunt ut emerent cibos.", "domine" has\n relatedtoken1 -> "est", 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. Example: in\n "Neptunus et Aegeus Pandionis filius...cum Aethra Pitthei\n filia...", "filius" is in apposition to "Aegeus" (relatedtoken1\n -> "Aegeus", relationship1 = \'apposition\'), and "Pandionis" (the\n genitive depending on "filius") has relatedtoken1 -> "filius",\n relationship1 = \'genitive\' -- and likewise "filia" is in\n apposition to "Aethra", with "Pitthei" as a genitive depending on\n "filia".\n - praenomen: a token of tokentype \'praenomen\' (an abbreviated Roman\n first name, e.g. "M." or "Sex.") has relatedtoken1 -> the id of\n the LEXICAL token spelling out the individual\'s own name that it\n abbreviates/precedes, relationship1 = \'praenomen\'. Example: in\n "Sex. Tarquinius inscio Collatino...venit", "Sex." has\n relatedtoken1 -> "Tarquinius", relationship1 = \'praenomen\'. If\n there is no such lexical name token to relate to -- e.g. the\n genitive filiation formula "L. f." ("Lucii filius", \'son of\n Lucius\'), where "L." precedes only the abbreviation "f." rather\n than a lexical name -- leave it unrelated, same as any other\n token with no relation of these kinds.\n - prepositional phrases: the preposition has relatedtoken1 -> the id\n of the verb (adverbial) or noun (attributive) it modifies,\n relationship1 = \'adverbial\' or \'attributive\'. The noun/pronoun it\n 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).\n - adverbial (bare adverb): an adverb modifying a verb has\n relatedtoken1 -> the verb\'s id, relationship1 = \'adverbial\' --\n the same relationship1 value as a preposition modifying a verb,\n just with no object-of-preposition token on the other end.\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 place isn\'t covered). Use only the token ids given in\n the input `tokens` list, the sentinel \'root\', or a NEW id you\n create for an implied token (see below), in your output; never\n invent an id for anything else.\n\n(3) implied/elided tokens. `arsgrammatica` recognizes three DIFFERENT\n situations where something exists grammatically but has no surface\n realization in the passage at all -- rather than skip these, add a\n NEW entry to `tokengraph` with: a brand-new id, not used by any\n entry in `tokens` or elsewhere in your own output (see the naming\n rule below); the matching tokentype below; and no `token` value\n (leave it unset/None) -- these go together, and \'implied sum\',\n \'continued discourse\', and \'implied subject\' are the ONLY three\n tokentype values whose id isn\'t one of `tokens`\' own ids and whose\n `token` is empty. `continued discourse` always stands in for a\n missing VERBAL expression and so always needs a matching new entry\n in `verbalunits`, exactly like any other verbal expression.\n `implied sum` stands in for a missing verbal expression in TWO of\n its three sub-cases (and needs a `verbalunits` entry there too),\n but NOT in its third -- the compound-passive-with-omitted-auxiliary\n sub-case, where a real, already-present participle anchors the\n verbal expression instead and the implied token merely relates to\n it as \'auxiliary\' (see below for which is which). `implied subject`\n stands in for a missing NOUN or pronoun instead, and never gets a\n `verbalunits` entry of its own.\n\n - tokentype \'implied sum\': an elided present of \'sum\' (\'to be\').\n Three sub-cases, all using this same tokentype:\n - a bare predicate construction (subject + predicate noun/\n adjective, no verb at all): the implied token anchors a\n verbal expression classified \'independent\' (or \'dependent\',\n if the elided-\'sum\' clause is itself subordinate) and\n \'linking verb\'; the subject and predicate each relate to it\n exactly as they would to any linking verb (\'subject\' /\n \'predicate\'). Example: "omnia praeclara rara" (\'all splendid\n things [are] rare\') has an implied token anchoring an\n \'independent\'/\'linking verb\' expression, with "omnia" (its\n own \'praeclara\' adjectival) as \'subject\' and "rara" as\n \'predicate\'.\n - a compound perfect/pluperfect passive (or impersonal\n passive) with its auxiliary omitted (e.g. "consules facti"\n for "consules facti sunt"): UNLIKE the other two sub-cases,\n the implied token here does NOT anchor the verbal\n expression -- the participle itself ("facti") is already a\n real, present token, so IT anchors the verbal expression\n (its own `verbalunits` entry, relatedtoken1/relationship1 =\n \'root\'/\'unit verb\' if independent, exactly as if it were an\n ordinary one-word verb) and every relation that would\n normally target the written-out auxiliary (subject, direct\n object, predicate, agent, adverbs, etc.) targets the\n participle instead. The implied token stands in only for the\n omitted form of \'sum\' itself: it gets relatedtoken1 -> the\n participle\'s id, relationship1 = \'auxiliary\' (exactly as a\n written-out auxiliary would relate to the participle -- see\n \'auxiliary\' above), and it does NOT get its own `verbalunits`\n entry, since the participle already supplies the verbal\n expression.\n - the present participle of \'sum\' does not exist in Latin at\n all, so an ablative-absolute-style predicate construction\n built on it (e.g. "Agrippa Menenio P. Postumio consulibus",\n \'[when] Agrippa Menenius [and] Publius Postumius [were]\n consuls\') is ALWAYS implied, never optional. Classify the\n implied token\'s verbal expression \'dependent\' (this\n codebase\'s circumstantial-participle convention -- see\n VerbalExpression\'s own docstring) and \'linking verb\'; relate\n it to its noun via \'circumstantial participle\' exactly like\n any other circumstantial participle.\n - tokentype \'continued discourse\': continuation of indirect\n discourse -- a long run of indirect statements can share one\n governing verb of speaking/thinking stated once, then omitted\n across several further coordinate statements. Add ONE implied\n token (tokentype \'continued discourse\') for that omitted\n governing verb (syntactic type \'independent\' unless the whole\n passage is itself subordinate, semantic type \'transitive active\'\n unless context says otherwise), and give EACH of the governed\n infinitives its normal \'indirect statement\' relation into it,\n exactly as if the verb had been repeated for each one.\n - tokentype \'implied subject\': a participle\'s own antecedent (the\n noun/pronoun it agrees with, related via \'circumstantial\n participle\' -- see that relation\'s own note above) can itself go\n unexpressed, most often when the participle agrees with a\n governing verb\'s own unexpressed subject. Rather than leaving\n the participle with no antecedent to point at (or, worse,\n pointing it straight at the verb, which would misrepresent it as\n an ablative absolute), add ONE implied token (tokentype \'implied\n subject\') to stand in for the missing noun/pronoun. This implied\n token is NOT a verbal expression itself -- it gets no\n `verbalunits` entry -- it simply takes a normal \'subject\'\n relation into the governing verb (relatedtoken1 -> the verb\'s\n id, relationship1 = \'subject\'), exactly as if that subject had\n been written out as a real word. The participle then relates to\n THIS new token via \'circumstantial participle\', exactly as it\n would to a real antecedent. Example: in "Recordatusque\n somniorum ait ad eos: Exploratores estis.", the participle\n "Recordatus" agrees with the unexpressed subject of "ait" -- add\n an implied token (tokentype \'implied subject\') with relatedtoken1\n -> "ait"\'s id, relationship1 = \'subject\'; "Recordatus" then has\n relatedtoken1 -> that new implied token\'s id, relationship1 =\n \'circumstantial participle\' (and, since "Recordatus" is itself a\n predicate-sense participle, it ALSO anchors its own\n `verbalunits` entry, syntactic_type \'dependent\', semantic_type\n matching its own transitivity -- unaffected by its antecedent\n being implied rather than real).\n\n Naming an implied token\'s id (all three tokentypes): append\n \'_implied\' to the id of the LAST real token in `tokens` that\n precedes where the elided word would have stood (or, if the elided\n word would come before every real token in the sentence, the FIRST\n real 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 (or, for\n \'implied subject\', the clause of the verb it\'s the subject of) for\n anything that reads `tokengraph` in order.' passage = Field(annotation=str required=True json_schema_extra={'desc': 'The Latin 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 used in indirect speech; or predicate-sense participle) in the passage.', '__dspy_field_type': 'output', 'prefix': 'Verbalunits:'}) tokengraph = Field(annotation=List[TokenAnalysis] required=True json_schema_extra={'desc': "One entry per token in `tokens`, in the same order, with its type and any relations -- PLUS one additional entry for each implied/elided token you add (see this signature's docstring), positioned where that token's clause falls in reading order.", '__dspy_field_type': 'output', 'prefix': 'Tokengraph:'}) ))
def analyze_string( passage: str, citation: str = '') -> Tuple[List[Sentence], list]:
 80def analyze_string(passage: str, citation: str = "") -> Tuple[List[Sentence], list]:
 81    """Convenience wrapper for the common case of a single string rather
 82    than a list of citation-labeled CitedText sources -- kept here so
 83    existing callers (syntaxer_main.py, the marimo notebook) have a
 84    one-string entry point rather than needing to build a CitedText list
 85    themselves for the ordinary case of one passage from one source.
 86 
 87    Wraps `passage` as one CitedText (using `citation` if given, else an
 88    empty string -- fine for callers that don't track citations) and runs
 89    it through analyze_sources(). Returns (sentences, results) -- the exact
 90    same shape analyze_sources() returns, one entry per sentence
 91    segmentation finds in `passage`, in order.
 92 
 93    `passage` may contain any number of sentences: each is segmented and
 94    analyzed successively, same as if you'd called analyze_sources() with
 95    one CitedText yourself. (An earlier version of this function raised
 96    ValueError on multi-sentence input and returned a single (tokens,
 97    result) pair for exactly one sentence; callers written against that
 98    contract need to change to unpack (sentences, results) and iterate.)
 99    """
100    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.)

def validate(tokens: List[Token], result) -> List[str]:
504def validate(tokens: List[Token], result) -> List[str]:
505    """Check that every id the LM produced actually exists among `tokens`
506    -- OR is a legitimately new implied token (tokentype in
507    IMPLIED_TOKENTYPES -- 'implied sum', 'continued discourse', or
508    'implied subject'; see SentenceAnalysis's docstring) -- and that implied
509    tokens themselves are well-formed. Returns a list of human-readable
510    problem descriptions (empty if clean).
511
512    'root' is a special sentinel value for an independent verb's own
513    relatedtoken1 (see SentenceAnalysis's docstring) -- it is never treated as
514    an unknown id, but syntax_model.md also requires that no actual token
515    ever be assigned the id 'root', so that's checked here too.
516
517    Implied tokens get their own, narrower checks: a tokengraph entry
518    claiming an IMPLIED_TOKENTYPES value must use a genuinely NEW id (not
519    one already in `tokens`) and must leave `token` unset (None) -- getting
520    either wrong is exactly the kind of malformed output this function
521    exists to catch, not a legitimate implied token. A non-implied entry,
522    conversely, must use one of `tokens`' own ids and must NOT have
523    `token=None` -- only an IMPLIED_TOKENTYPES tokentype may omit real
524    surface text. This check is purely structural either way (new id,
525    empty text) -- it does NOT also require an 'implied sum'/'continued
526    discourse' token to have a matching `verbalunits` entry, or an
527    'implied subject' token to lack one; that distinction is documented
528    behavior (see SentenceAnalysis's docstring), not something this function
529    enforces."""
530    valid_ids = {t.id for t in tokens}
531    problems = []
532
533    if "root" in valid_ids:
534        problems.append(
535            "token id 'root' is reserved as the sentinel relatedtoken1 "
536            "value for independent verbs and must not be assigned to an "
537            "actual token"
538        )
539
540    implied_ids = {tok.id for tok in result.tokengraph if tok.tokentype in IMPLIED_TOKENTYPES}
541    known_ids = valid_ids | implied_ids
542
543    for tok in result.tokengraph:
544        if tok.tokentype in IMPLIED_TOKENTYPES:
545            if tok.id in valid_ids:
546                problems.append(
547                    f"tokengraph entry {tok.id!r} is tokentype={tok.tokentype!r} but "
548                    "reuses an id already in the input `tokens` list -- an "
549                    "implied token must use a new id"
550                )
551            if tok.token is not None:
552                problems.append(
553                    f"tokengraph entry {tok.id!r} is tokentype={tok.tokentype!r} but "
554                    f"has a non-None token value {tok.token!r} -- an implied "
555                    "token's text must be left unset"
556                )
557        else:
558            if tok.id not in valid_ids:
559                problems.append(f"tokengraph entry has unknown id {tok.id!r}")
560            if tok.token is None:
561                allowed = "/".join(repr(t) for t in sorted(IMPLIED_TOKENTYPES))
562                problems.append(
563                    f"tokengraph entry {tok.id!r} has token=None but "
564                    f"tokentype={tok.tokentype!r} -- only {allowed} may "
565                    "omit surface text"
566                )
567        for field in ("relatedtoken1", "relatedtoken2"):
568            val = getattr(tok, field)
569            if val is not None and val != "root" and val not in known_ids:
570                problems.append(f"token {tok.id!r} {field}={val!r} is not a known token id")
571
572    for vu in result.verbalunits:
573        if vu.id not in known_ids:
574            problems.append(f"verbal expression id {vu.id!r} is not a known token id")
575
576    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 sum', 'continued discourse', or 'implied subject'; see SentenceAnalysis'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 SentenceAnalysis'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 an IMPLIED_TOKENTYPES tokentype may omit real surface text. This check is purely structural either way (new id, empty text) -- it does NOT also require an 'implied sum'/'continued discourse' token to have a matching verbalunits entry, or an 'implied subject' token to lack one; that distinction is documented behavior (see SentenceAnalysis's docstring), not something this function enforces.

class SegmentPassage(dspy.signatures.signature.Signature):
34class SegmentPassage(dspy.Signature):
35    """Segment a sequence of citation-labeled Latin source units into
36    sentences, and each sentence into tokens, following syntax_model.md's
37    tokenization scheme.
38 
39    `sources` is given in reading order; treat its units' text as one
40    continuous passage for sentence-splitting purposes -- a sentence may
41    start in one unit's text and finish in the next one's, and often will
42    in continuous verse or prose. Every token you produce must carry the
43    `citation` of whichever `sources` unit its surface text came from, even
44    for a sentence that spans more than one unit.
45 
46    - Split into sentences at sentence-ending punctuation (. ? !). A period
47      after a praenomen (e.g. "M.") or another abbreviation (e.g. "f.",
48      "cos.") is NOT a sentence boundary.
49 
50    - Within each sentence, segment tokens as: lexical, enclitic,
51      punctuation, numeral (Arabic or Roman), praenomen (a letter plus its
52      period), or other abbreviation (letters plus a period, e.g. "f.",
53      "cos."). Praenomina and other abbreviations are each a single token
54      including their period -- never split the letters from the period the
55      way ordinary sentence-final punctuation is separated.
56 
57    - Enclitic splitting (-que, -ve, -ne) must consider context, not just
58      the trailing letters. Only split off an enclitic when the remainder
59      is itself a real word AND context supports that reading. Ordinary
60      words that happen to end in "que"/"ve"/"ne" (e.g. "sine", "bene")
61      are never split -- their whole spelling is the word, full stop.
62 
63      For "-ne" specifically: only read it as the interrogative particle
64      when the sentence is a yes/no question AND its token is that
65      question's first word. A sentence ending in "?" is a yes/no
66      question -- that is your signal, use it directly rather than
67      guessing from meaning alone. For example:
68        - "aequa ratione imperat." does not end in "?", so it is not a
69          question. ratione stays one token (ablative of ratio),
70          regardless of its position in the sentence.
71        - "ratione docet?" ends in "?": a yes/no question. ratione is
72          that question's first word, so it splits into ratio
73          (nominative) + the interrogative enclitic -ne.
74      If a sentence does not end in "?", never split off an interrogative
75      "-ne" -- not even from a sentence-initial word ending in "-ne".
76 
77    - Assign token ids sequentially across the WHOLE input, in reading
78      order: t0, t1, t2, .... Do not restart numbering at each sentence or
79      at each source unit. Every token, across every sentence and every
80      source unit, has a unique id, and running this on the same `sources`
81      again must produce the same ids for the same tokens.
82    """
83 
84    sources: List[CitedText] = dspy.InputField(
85        desc="Citation-labeled source units, in reading order, to segment as one continuous passage."
86    )
87    sentences: List[Sentence] = dspy.OutputField(
88        desc="The sentences found across all of `sources`, in order. Token ids are global (see instructions); each token's `citation` names the source unit it came from."
89    )

Segment a sequence of citation-labeled Latin source units into sentences, and each sentence into tokens, following syntax_model.md's tokenization scheme.

sources is given in reading order; treat its units' text as one continuous passage for sentence-splitting purposes -- a sentence may start in one unit's text and finish in the next one's, and often will in continuous verse or prose. Every token you produce must carry the citation of whichever sources unit its surface text came from, even for a sentence that spans more than one unit.

  • Split into sentences at sentence-ending punctuation (. ? !). A period after a praenomen (e.g. "M.") or another abbreviation (e.g. "f.", "cos.") is NOT a sentence boundary.

  • Within each sentence, segment tokens as: lexical, enclitic, punctuation, numeral (Arabic or Roman), praenomen (a letter plus its period), or other abbreviation (letters plus a period, e.g. "f.", "cos."). Praenomina and other abbreviations are each a single token including their period -- never split the letters from the period the way ordinary sentence-final punctuation is separated.

  • Enclitic splitting (-que, -ve, -ne) must consider context, not just the trailing letters. Only split off an enclitic when the remainder is itself a real word AND context supports that reading. Ordinary words that happen to end in "que"/"ve"/"ne" (e.g. "sine", "bene") are never split -- their whole spelling is the word, full stop.

    For "-ne" specifically: only read it as the interrogative particle when the sentence is a yes/no question AND its token is that question's first word. A sentence ending in "?" is a yes/no question -- that is your signal, use it directly rather than guessing from meaning alone. For example:

    • "aequa ratione imperat." does not end in "?", so it is not a question. ratione stays one token (ablative of ratio), regardless of its position in the sentence.
    • "ratione docet?" ends in "?": a yes/no question. ratione is that question's first word, so it splits into ratio (nominative) + the interrogative enclitic -ne. If a sentence does not end in "?", never split off an interrogative "-ne" -- not even from a sentence-initial word ending in "-ne".
  • Assign token ids sequentially across the WHOLE input, in reading order: t0, t1, t2, .... Do not restart numbering at each sentence or at each source unit. Every token, across every sentence and every source unit, has a unique id, and running this on the same sources again must produce the same ids for the same tokens.

sources: List[CitedText] = PydanticUndefined
sentences: List[Sentence] = PydanticUndefined
def segment_sources( sources: List[CitedText]) -> List[Sentence]:
95def segment_sources(sources: List[CitedText]) -> List[Sentence]:
96    """Run the segmentation stage and return its sentences."""
97    result = segment(sources=sources)
98    return result.sentences

Run the segmentation stage and return its sentences.

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

Segment sources into citation-aware sentences, run each sentence's tokens through SentenceAnalysis, and validate each result.

Returns (sentences, results): results[i] is the SentenceAnalysis result for sentences[i], same order, one entry per sentence.

Each sentence's SentenceAnalysis 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 analyze_selected_passages( passage_ids: List[str], cited_texts: List[CitedText]) -> Tuple[List[Sentence], list]:
103def analyze_selected_passages(
104    passage_ids: List[str], cited_texts: List[CitedText]
105) -> Tuple[List[Sentence], list]:
106    """Select the entries of `cited_texts` whose `citation` is in
107    `passage_ids`, then run exactly those through analyze_sources() --
108    e.g. after read_ctsdata() has loaded a whole source file but only some
109    of its passages are wanted for this run.
110
111    Selected passages are analyzed in `cited_texts`' OWN order, not
112    `passage_ids`' order -- the same convention
113    marimo/latin_syntaxer_ctsdata.py's own `selected_rows` cell already
114    uses, and for the same reason: segment_sources() (inside
115    analyze_sources()) treats consecutive sources as potentially sharing a
116    sentence, so an out-of-file-order source list could segment
117    incorrectly, or produce citations in a confusing order.
118    `passage_ids` therefore acts purely as a filter -- which passages to
119    include -- never as a sort key.
120
121    Raises ValueError, naming every missing id at once, if any entry of
122    `passage_ids` doesn't match any `cited_texts` citation -- a typo'd or
123    stale passage id fails loudly here rather than silently analyzing
124    fewer passages than asked for.
125
126    Returns (sentences, results) -- the exact same shape analyze_sources()
127    returns, spanning only the selected passages.
128    """
129    wanted = set(passage_ids)
130    selected = [ct for ct in cited_texts if ct.citation in wanted]
131
132    found = {ct.citation for ct in selected}
133    missing = sorted(pid for pid in wanted if pid not in found)
134    if missing:
135        raise ValueError(
136            f"passage id(s) not found in cited_texts: {missing!r}"
137        )
138
139    return analyze_sources(selected)

Select the entries of cited_texts whose citation is in passage_ids, then run exactly those through analyze_sources() -- e.g. after read_ctsdata() has loaded a whole source file but only some of its passages are wanted for this run.

Selected passages are analyzed in cited_texts' OWN order, not passage_ids' order -- the same convention marimo/latin_syntaxer_ctsdata.py's own selected_rows cell already uses, and for the same reason: segment_sources() (inside analyze_sources()) treats consecutive sources as potentially sharing a sentence, so an out-of-file-order source list could segment incorrectly, or produce citations in a confusing order. passage_ids therefore acts purely as a filter -- which passages to include -- never as a sort key.

Raises ValueError, naming every missing id at once, if any entry of passage_ids doesn't match any cited_texts citation -- a typo'd or stale passage id fails loudly here rather than silently analyzing fewer passages than asked for.

Returns (sentences, results) -- the exact same shape analyze_sources() returns, spanning only the selected passages.

def analyze_ctsdata( path: str, delimiter: str = '|') -> Tuple[List[Sentence], list]:
142def analyze_ctsdata(path: str, delimiter: str = "|") -> Tuple[List[Sentence], list]:
143    """Convenience wrapper for the common case of a whole `#!ctsdata` (CEX)
144    source file on disk, rather than an in-memory `List[CitedText]` --
145    reads `path` with `read_ctsdata()` (ctsdata.py) and runs the result
146    straight through `analyze_sources()`, the same "read a CEX corpus,
147    then analyze it" pair every entry point in this codebase that starts
148    from a CEX file already does by hand (`utilities/tokenize_ctsdata.py`,
149    `utilities/analyze_ctsdata_to_files.py`, `utilities/
150    group_ctsdata_by_sentence.py`, each of the marimo ctsdata notebooks).
151
152    `delimiter` is passed straight through to `read_ctsdata()` -- it's the
153    SOURCE file's own column delimiter ('|' by default, matching every
154    other serialized format in this codebase), not related to anything
155    `analyze_sources()` itself does.
156
157    Returns `(sentences, results)` -- the exact same shape
158    `analyze_sources()` returns, spanning every passage in `path`, in the
159    file's own order. Every passage in the file is analyzed; use
160    `analyze_selected_passages()` instead if only some of them are wanted.
161
162    Propagates `read_ctsdata()`'s own `ValueError`/`OSError` as-is for a
163    missing file or a malformed `#!ctsdata` block (see that function's own
164    docstring for exactly what's checked) -- raised before any LM call is
165    made, same as every CLI entry point that reads a CEX file up front for
166    the same reason.
167    """
168    cited_texts = read_ctsdata(path, delimiter=delimiter)
169    return analyze_sources(cited_texts)

Convenience wrapper for the common case of a whole #!ctsdata (CEX) source file on disk, rather than an in-memory List[CitedText] -- reads path with read_ctsdata() (ctsdata.py) and runs the result straight through analyze_sources(), the same "read a CEX corpus, then analyze it" pair every entry point in this codebase that starts from a CEX file already does by hand (utilities/tokenize_ctsdata.py, utilities/analyze_ctsdata_to_files.py, utilities/ group_ctsdata_by_sentence.py, each of the marimo ctsdata notebooks).

delimiter is passed straight through to read_ctsdata() -- it's the SOURCE file's own column delimiter ('|' by default, matching every other serialized format in this codebase), not related to anything analyze_sources() itself does.

Returns (sentences, results) -- the exact same shape analyze_sources() returns, spanning every passage in path, in the file's own order. Every passage in the file is analyzed; use analyze_selected_passages() instead if only some of them are wanted.

Propagates read_ctsdata()'s own ValueError/OSError as-is for a missing file or a malformed #!ctsdata block (see that function's own docstring for exactly what's checked) -- raised before any LM call is made, same as every CLI entry point that reads a CEX file up front for the same reason.

def combined_tokengraph(results) -> list:
69def combined_tokengraph(results) -> list:
70    """Concatenate every sentence result's tokengraph, in order, into one
71    flat list spanning the whole input -- since token ids are global,
72    tokengraph_to_mermaid() (mermaid.py) needs no changes at all to render
73    this as one diagram for a multi-sentence, multi-citation passage."""
74    combined = []
75    for result in results:
76        combined.extend(result.tokengraph)
77    return combined

Concatenate every sentence result's tokengraph, in order, into one flat list spanning the whole input -- since token ids are global, tokengraph_to_mermaid() (mermaid.py) needs no changes at all to render this as one diagram for a multi-sentence, multi-citation passage.

def group_passages_by_sentence_boundary( cited_texts: List[CitedText]) -> Tuple[List[List[str]], List[str]]:
 56def group_passages_by_sentence_boundary(
 57    cited_texts: List[CitedText],
 58) -> Tuple[List[List[str]], List[str]]:
 59    """Group `cited_texts` (in their own given order) into the SMALLEST
 60    possible runs of consecutive passages that each begin and end on a
 61    sentence boundary, per this module's own text-ending heuristic (see
 62    module docstring) -- NOT segmentation_dspy.py's LM-driven segmentation.
 63
 64    A passage whose own text ends at a sentence boundary closes the group
 65    it's in (which may be just itself); a passage that doesn't ends up
 66    grouped together with however many following passages it takes to reach
 67    one that does. Two worked examples (the ones this function was
 68    specified against):
 69
 70    - every passage in a text has exactly one complete sentence (each one's
 71      own text ends in terminal punctuation) -> one singleton group per
 72      passage, e.g. [[id0], [id1], [id2]].
 73    - three lines of poetry contain two sentences that begin in line 1 and
 74      end at the end of line 3, with a sentence end/beginning in the middle
 75      of line 2 (so neither line 1's nor line 2's own text ends in terminal
 76      punctuation -- only line 3's does) -> a single group of all three:
 77      [[id1, id2, id3]].
 78
 79    Returns (groups, warnings): `groups` is a list of passage-id lists, one
 80    list per group, in `cited_texts`' own order, using each CitedText's
 81    `citation` as its id -- covering every passage in `cited_texts` exactly
 82    once. `warnings` names the one situation this function flags rather
 83    than silently guessing: if the LAST group's own final passage doesn't
 84    end at a sentence boundary (there was nothing left to close it), that
 85    group is still returned (there's nowhere else to put those ids), but a
 86    warning is added noting it may be an incomplete sentence -- e.g. because
 87    `cited_texts` itself is a truncated excerpt of a longer text. An empty
 88    `cited_texts` returns ([], []).
 89    """
 90    groups: List[List[str]] = []
 91    current: List[str] = []
 92    warnings: List[str] = []
 93
 94    for cited_text in cited_texts:
 95        current.append(cited_text.citation)
 96        if _ends_at_sentence_boundary(cited_text.text):
 97            groups.append(current)
 98            current = []
 99
100    if current:
101        warnings.append(
102            f"the final group {current!r} doesn't end at a sentence "
103            "boundary -- its last passage's text has no closing "
104            "sentence-ending punctuation, so the sentence it ends with "
105            "may be incomplete"
106        )
107        groups.append(current)
108
109    return groups, warnings

Group cited_texts (in their own given order) into the SMALLEST possible runs of consecutive passages that each begin and end on a sentence boundary, per this module's own text-ending heuristic (see module docstring) -- NOT segmentation_dspy.py's LM-driven segmentation.

A passage whose own text ends at a sentence boundary closes the group it's in (which may be just itself); a passage that doesn't ends up grouped together with however many following passages it takes to reach one that does. Two worked examples (the ones this function was specified against):

  • every passage in a text has exactly one complete sentence (each one's own text ends in terminal punctuation) -> one singleton group per passage, e.g. [[id0], [id1], [id2]].
  • three lines of poetry contain two sentences that begin in line 1 and end at the end of line 3, with a sentence end/beginning in the middle of line 2 (so neither line 1's nor line 2's own text ends in terminal punctuation -- only line 3's does) -> a single group of all three: [[id1, id2, id3]].

Returns (groups, warnings): groups is a list of passage-id lists, one list per group, in cited_texts' own order, using each CitedText's citation as its id -- covering every passage in cited_texts exactly once. warnings names the one situation this function flags rather than silently guessing: if the LAST group's own final passage doesn't end at a sentence boundary (there was nothing left to close it), that group is still returned (there's nowhere else to put those ids), but a warning is added noting it may be an incomplete sentence -- e.g. because cited_texts itself is a truncated excerpt of a longer text. An empty cited_texts returns ([], []).

class LMInfo(typing.NamedTuple):
214class LMInfo(NamedTuple):
215    """One sentence's '#!LM' entry (see the module docstring) -- which
216    model produced that sentence's analysis, a sentence-style identifier
217    for what it was given to analyze ('CONTEXT1.ID1-CONTEXT2.ID2', its
218    first and last token's own citation and id -- see
219    `_sentence_context_identifier()`), and its own reasoning. Any of the
220    three may be None, the same as an empty column elsewhere in this
221    format (e.g. `model` is None whenever serialize_analyses()/
222    write_analyses() were called without a `model` argument)."""
223
224    model: Optional[str]
225    context: Optional[str]
226    reasoning: Optional[str]

One sentence's '#!LM' entry (see the module docstring) -- which model produced that sentence's analysis, a sentence-style identifier for what it was given to analyze ('CONTEXT1.ID1-CONTEXT2.ID2', its first and last token's own citation and id -- see _sentence_context_identifier()), and its own reasoning. Any of the three may be None, the same as an empty column elsewhere in this format (e.g. model is None whenever serialize_analyses()/ write_analyses() were called without a model argument).

LMInfo(model: str | None, context: str | None, reasoning: str | None)

Create new instance of LMInfo(model, context, reasoning)

model: str | None

Alias for field number 0

context: str | None

Alias for field number 1

reasoning: str | None

Alias for field number 2

def serialize_analyses( sentences: List[Sentence], verbalunits: List[VerbalExpression], tokengraph: List[TokenAnalysis], *, model: str | None = None, reasoning: List[str | None] | None = None) -> Tuple[str, List[str]]:
304def serialize_analyses(
305    sentences: List[Sentence],
306    verbalunits: List[VerbalExpression],
307    tokengraph: List[TokenAnalysis],
308    *,
309    model: Optional[str] = None,
310    reasoning: Optional[List[Optional[str]]] = None,
311) -> Tuple[str, List[str]]:
312    """Build the exact text write_analyses() would write to a file, and
313    return it directly as `(content, warnings)` instead of writing it
314    anywhere -- see the module docstring for why this exists alongside
315    write_analyses(). All three positional lists are flat and span however
316    many sentences/citation sources were analyzed -- the same shape
317    analyze_sources() (for `sentences`) and combined_tokengraph() (for
318    `tokengraph`; `verbalunits` needs the analogous concatenation, which
319    this function does not do for you) already produce.
320
321    `model`/`reasoning` are optional and control the '#!LM' block (see the
322    module docstring's "The #!LM block"): omit both (the default) to skip
323    '#!LM' entirely, exactly reproducing this function's pre-'#!LM'
324    output. Passing `reasoning` -- one entry per sentence, in the same
325    order as `sentences`, each either that sentence's own reasoning text
326    or None -- turns it on; `model` is then written as every entry's own
327    MODEL= value (typically `os.environ["MODEL"]`, but this module has no
328    opinion on where it comes from). `reasoning` must have exactly one
329    entry per sentence; a mismatched length raises ValueError immediately,
330    before anything is written.
331
332    `content` is the complete file body, including its trailing newline,
333    exactly as write_analyses() would have written it. `warnings` is a
334    list of warning strings (empty if nothing looks wrong), matching this
335    codebase's "degrade visibly, don't raise" convention for warnings
336    distinct from hard errors:
337
338    - a tokengraph or verbalunits entry whose id isn't found among any
339      given sentence's tokens (so no citation is known for it -- an empty
340      context is written, same as a token that legitimately has no
341      citation at all, but this case specifically means the id wasn't
342      found anywhere in `sentences` -- EXCEPT for an implied token
343      (tokentype in IMPLIED_TOKENTYPES), which never appears in any sentence's own
344      `tokens` by design, so this warning is suppressed for those
345      specifically rather than flagged as an anomaly);
346    - a sentence whose own tokens don't form a contiguous, matching-order
347      run in `tokengraph`'s given order -- see the module docstring for
348      why this matters for read_analyses() to recover sentence boundaries
349      correctly.
350
351    Raises ValueError for a sentence with no tokens at all (nothing to
352    derive first_token/last_token from, or -- when `reasoning` is given --
353    nothing to derive '#!LM's CONTEXT= from either), if any field value
354    contains '|' or a newline (see `_field`), or if `reasoning` is given
355    with a different number of entries than `sentences`.
356    """
357    warnings: List[str] = []
358
359    if reasoning is not None and len(reasoning) != len(sentences):
360        raise ValueError(
361            f"`reasoning` has {len(reasoning)} entr"
362            f"{'y' if len(reasoning) == 1 else 'ies'}, but there "
363            f"{'is' if len(sentences) == 1 else 'are'} {len(sentences)} "
364            "sentence(s) -- '#!LM' needs exactly one reasoning entry per "
365            "sentence"
366        )
367
368    id_to_citation: Dict[str, Optional[str]] = {}
369    for sentence in sentences:
370        for tok in sentence.tokens:
371            id_to_citation[tok.id] = tok.citation
372
373    # Implied tokens (tokentype in IMPLIED_TOKENTYPES) never appear in any sentence's
374    # own `tokens` list by design (see the module docstring's note above)
375    # -- so having no recorded citation is expected and correct for them,
376    # not the kind of anomaly the "not found among the given sentences'
377    # tokens" warning below exists to flag.
378    implied_ids = {tok.id for tok in tokengraph if tok.tokentype in IMPLIED_TOKENTYPES}
379
380    tg_index = {tok.id: i for i, tok in enumerate(tokengraph)}
381
382    lines: List[str] = []
383
384    if reasoning is not None:
385        lines.append(LM_LABEL)
386        for s_idx, sentence in enumerate(sentences):
387            if not sentence.tokens:
388                raise ValueError(
389                    f"sentence at index {s_idx} has no tokens -- cannot "
390                    "derive the '#!LM' block's CONTEXT= for an empty "
391                    "sentence"
392                )
393            where = f"'#!LM' entry for sentence {s_idx}"
394            context_value = _sentence_context_identifier(sentence)
395            reasoning_value = reasoning[s_idx]
396            collapsed_reasoning = (
397                _collapse_to_single_line(reasoning_value) if reasoning_value is not None else None
398            )
399            lines.append(_LM_MODEL_PREFIX + _lm_field(model, where=where))
400            lines.append(_LM_CONTEXT_PREFIX + _lm_field(context_value, where=where))
401            lines.append(_LM_REASONING_PREFIX + _lm_field(collapsed_reasoning, where=where))
402        lines.append("")
403
404    lines.append(SENTENCES_LABEL)
405    lines.append(SENTENCES_HEADER)
406    for s_idx, sentence in enumerate(sentences):
407        if not sentence.tokens:
408            raise ValueError(
409                f"sentence at index {s_idx} has no tokens -- cannot derive "
410                "first_token/last_token for an empty sentence"
411            )
412        first_tok = sentence.tokens[0]
413        last_tok = sentence.tokens[-1]
414
415        first_pos = tg_index.get(first_tok.id)
416        last_pos = tg_index.get(last_tok.id)
417        if first_pos is None or last_pos is None:
418            warnings.append(
419                f"sentence at index {s_idx} (tokens {first_tok.id!r}.."
420                f"{last_tok.id!r}) has a boundary token not present in the "
421                "given tokengraph -- reading this file back may not "
422                "reconstruct this sentence's tokens correctly"
423            )
424        else:
425            expected_ids = [t.id for t in sentence.tokens]
426            # Implied tokens (tokentype in IMPLIED_TOKENTYPES) were never part of the
427            # original per-sentence `tokens` list -- they're synthesized by
428            # analysis itself -- so exclude them here before comparing, or
429            # every sentence containing one would spuriously warn.
430            actual_ids = [
431                tok.id
432                for tok in tokengraph[first_pos : last_pos + 1]
433                if tok.tokentype not in IMPLIED_TOKENTYPES
434            ]
435            if actual_ids != expected_ids:
436                warnings.append(
437                    f"sentence at index {s_idx} (tokens {first_tok.id!r}.."
438                    f"{last_tok.id!r}) is not a contiguous, matching-order "
439                    "run in the given tokengraph -- reading this file back "
440                    "may not reconstruct this sentence's tokens correctly"
441                )
442
443        where = f"#!sentences row for sentence {s_idx}"
444        lines.append(
445            "|".join(
446                [
447                    _field(first_tok.citation, where=where),
448                    _field(first_tok.id, where=where),
449                    _field(last_tok.citation, where=where),
450                    _field(last_tok.id, where=where),
451                ]
452            )
453        )
454
455    lines.append("")
456    lines.append(VERBAL_UNITS_LABEL)
457    lines.append(VERBAL_UNITS_HEADER)
458    for vu in verbalunits:
459        if vu.id not in id_to_citation and vu.id not in implied_ids:
460            warnings.append(
461                f"verbal expression {vu.id!r} not found among the given "
462                "sentences' tokens -- writing an empty context for it"
463            )
464        where = f"#!verbal_units row for {vu.id}"
465        lines.append(
466            "|".join(
467                [
468                    _field(id_to_citation.get(vu.id), where=where),
469                    _field(vu.id, where=where),
470                    _field(vu.syntactic_type, where=where),
471                    _field(vu.semantic_type, where=where),
472                ]
473            )
474        )
475
476    lines.append("")
477    lines.append(TOKENS_LABEL)
478    lines.append(TOKENS_HEADER)
479    for tok in tokengraph:
480        if tok.id not in id_to_citation and tok.id not in implied_ids:
481            warnings.append(
482                f"token {tok.id!r} not found among the given sentences' "
483                "tokens -- writing an empty context for it"
484            )
485        where = f"#!tokens row for {tok.id}"
486        lines.append(
487            "|".join(
488                [
489                    _field(id_to_citation.get(tok.id), where=where),
490                    _field(tok.id, where=where),
491                    _field(tok.tokentype, where=where),
492                    _field(tok.token, where=where),
493                    _field(tok.lemma, where=where),
494                    _field(tok.verbalunitid, where=where),
495                    _field(tok.relatedtoken1, where=where),
496                    _field(tok.relationship1, where=where),
497                    _field(tok.relatedtoken2, where=where),
498                    _field(tok.relationship2, where=where),
499                ]
500            )
501        )
502
503    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 positional 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.

model/reasoning are optional and control the '#!LM' block (see the module docstring's "The #!LM block"): omit both (the default) to skip '#!LM' entirely, exactly reproducing this function's pre-'#!LM' output. Passing reasoning -- one entry per sentence, in the same order as sentences, each either that sentence's own reasoning text or None -- turns it on; model is then written as every entry's own MODEL= value (typically os.environ["MODEL"], but this module has no opinion on where it comes from). reasoning must have exactly one entry per sentence; a mismatched length raises ValueError immediately, before anything is written.

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 -- when reasoning is given -- nothing to derive '#!LM's CONTEXT= from either), if any field value contains '|' or a newline (see _field), or if reasoning is given with a different number of entries than sentences.

def write_analyses( sentences: List[Sentence], verbalunits: List[VerbalExpression], tokengraph: List[TokenAnalysis], path: str, *, model: str | None = None, reasoning: List[str | None] | None = None) -> List[str]:
506def write_analyses(
507    sentences: List[Sentence],
508    verbalunits: List[VerbalExpression],
509    tokengraph: List[TokenAnalysis],
510    path: str,
511    *,
512    model: Optional[str] = None,
513    reasoning: Optional[List[Optional[str]]] = None,
514) -> List[str]:
515    """Write `sentences`/`verbalunits`/`tokengraph` to `path` in the format
516    this module's docstring describes -- see serialize_analyses() (which
517    this is a thin wrapper around) for what's actually written and for the
518    full list of warnings this can return. `model`/`reasoning` are passed
519    straight through to serialize_analyses() and control the optional
520    '#!LM' block exactly as described there -- omit both to skip it.
521
522    Returns a list of warning strings (empty if nothing looks wrong); see
523    serialize_analyses()'s docstring for what each one means. Raises
524    ValueError for a sentence with no tokens at all (nothing to derive
525    first_token/last_token from), if any field value contains '|' or a
526    newline (see `_field`), or if `reasoning` is given with a different
527    number of entries than `sentences` -- all raised by serialize_analyses()
528    before this function ever opens `path`.
529    """
530    content, warnings = serialize_analyses(
531        sentences, verbalunits, tokengraph, model=model, reasoning=reasoning
532    )
533    with open(path, "w", encoding="utf-8") as f:
534        f.write(content)
535    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. model/reasoning are passed straight through to serialize_analyses() and control the optional '#!LM' block exactly as described there -- omit both to skip it.

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), or if reasoning is given with a different number of entries than sentences -- all raised by serialize_analyses() before this function ever opens path.

def read_analyses( path: str) -> Tuple[List[TokenAnalysis], List[VerbalExpression], List[Sentence], List[LMInfo]]:
538def read_analyses(
539    path: str,
540) -> Tuple[List[TokenAnalysis], List[VerbalExpression], List[Sentence], List[LMInfo]]:
541    """Read `path` (as written by write_analyses()/serialize_analyses()) and
542    reconstruct `(tokengraph, verbalunits, sentences, lm_infos)` -- in that
543    order, matching the order these types are usually discussed in this
544    codebase (the token-level graph, then the verbal-expression table,
545    then the sentence/citation structure that supplies context for both,
546    then the optional per-sentence '#!LM' record of what produced them).
547
548    Each of the three required block labels may appear more than once in
549    `path` (see the module docstring) -- every instance contributes its
550    own rows, in file order, to that label's combined row list, as if the
551    file were the concatenation of however many separate write_analyses()/
552    serialize_analyses() outputs it actually is. '#!LM' may repeat the
553    same way.
554
555    `lm_infos` is `[]` if `path` has no '#!LM' block at all (every file
556    written before this block existed, or any file written without
557    passing `reasoning` to write_analyses()/serialize_analyses(), reads
558    back exactly as before) -- otherwise it's one `LMInfo` per sentence,
559    aligned with `sentences` by position, same as `tokengraph`/
560    `verbalunits` are conceptually aligned with `sentences` via token ids.
561
562    Raises ValueError, naming the offending line and problem, for anything
563    that isn't a faithful, internally-consistent file written by
564    write_analyses() -- see this module's own docstring for exactly what's
565    checked, including '#!LM's own narrower checks (a line count that
566    isn't a multiple of 3, a line missing its expected prefix, or a number
567    of entries that doesn't match the number of sentences). This function
568    does not accept a file with warnings-worthy inconsistencies silently
569    patched over; if write_analyses() returned warnings when the file was
570    written, fix the input and re-write it rather than expecting
571    read_analyses() to compensate.
572    """
573    with open(path, "r", encoding="utf-8") as f:
574        raw_lines = f.read().splitlines()
575
576    # blocks[label] accumulates (line_no, line) data rows across every
577    # instance of that label found in the file, in file order. A label
578    # line always starts a new instance and must be immediately followed
579    # by that label's header line (`awaiting_header` tracks this) before
580    # any more data rows can be appended to it -- this holds per instance,
581    # not just for the label's first appearance, so every repeated block
582    # must repeat its own header line too. '#!LM' is the one exception: it
583    # has no header line at all (see the module docstring), so a '#!LM'
584    # label line goes straight to accepting data rows -- `awaiting_header`
585    # is never set for it.
586    blocks: Dict[str, List[Tuple[int, str]]] = {label: [] for label in _EXPECTED_HEADERS}
587    blocks[LM_LABEL] = []
588    seen_labels = set()
589    current_label: Optional[str] = None
590    awaiting_header = False
591
592    for line_no, line in enumerate(raw_lines, start=1):
593        if line.strip() == "":
594            continue
595
596        if line == LM_LABEL or line in _EXPECTED_HEADERS:
597            if awaiting_header:
598                raise ValueError(
599                    f"line {line_no}: block {current_label!r} has a label "
600                    "line but no header line before the next block starts"
601                )
602            current_label = line
603            seen_labels.add(line)
604            awaiting_header = line != LM_LABEL
605            continue
606
607        if current_label is None:
608            raise ValueError(
609                f"line {line_no}: data line {line!r} appears before any "
610                "'#!' block label"
611            )
612
613        if awaiting_header:
614            expected = _EXPECTED_HEADERS[current_label]
615            if line != expected:
616                raise ValueError(
617                    f"line {line_no}: expected header {expected!r} for "
618                    f"block {current_label!r}, got {line!r}"
619                )
620            awaiting_header = False
621            continue
622
623        blocks[current_label].append((line_no, line))
624
625    missing = sorted(set(_EXPECTED_HEADERS) - seen_labels)
626    if missing:
627        raise ValueError(f"file is missing required block(s): {missing}")
628    if awaiting_header:
629        raise ValueError(
630            f"block {current_label!r} has a label line but no header line "
631            "(and no data) -- the file ends too early"
632        )
633
634    # --- #!tokens: build the TokenAnalysis list, the id->citation map,
635    # and the row-order index sentence reconstruction relies on. ---
636    tokengraph: List[TokenAnalysis] = []
637    id_to_citation: Dict[str, Optional[str]] = {}
638    row_order: List[str] = []
639
640    for line_no, line in blocks[TOKENS_LABEL]:
641        parts = line.split("|")
642        if len(parts) != 10:
643            raise ValueError(
644                f"line {line_no}: #!tokens row has {len(parts)} columns, "
645                f"expected 10: {line!r}"
646            )
647        (
648            context,
649            tok_id,
650            tokentype,
651            text,
652            lemma,
653            verbalunit,
654            related1,
655            relationship1,
656            related2,
657            relationship2,
658        ) = parts
659        if tok_id == "":
660            raise ValueError(f"line {line_no}: #!tokens row has an empty id")
661        if tok_id in id_to_citation:
662            raise ValueError(f"line {line_no}: duplicate token id {tok_id!r} in #!tokens")
663
664        tokengraph.append(
665            TokenAnalysis(
666                id=tok_id,
667                token=_parse_optional(text),
668                tokentype=tokentype,
669                lemma=_parse_optional(lemma),
670                verbalunitid=_parse_optional(verbalunit),
671                relatedtoken1=_parse_optional(related1),
672                relationship1=_parse_optional(relationship1),
673                relatedtoken2=_parse_optional(related2),
674                relationship2=_parse_optional(relationship2),
675            )
676        )
677        id_to_citation[tok_id] = _parse_optional(context)
678        row_order.append(tok_id)
679
680    id_position = {tid: i for i, tid in enumerate(row_order)}
681
682    # --- #!verbal_units ---
683    verbalunits: List[VerbalExpression] = []
684    for line_no, line in blocks[VERBAL_UNITS_LABEL]:
685        parts = line.split("|")
686        if len(parts) != 4:
687            raise ValueError(
688                f"line {line_no}: #!verbal_units row has {len(parts)} "
689                f"columns, expected 4: {line!r}"
690            )
691        context, vu_id, syntactic_type, semantic_type = parts
692        if vu_id == "":
693            raise ValueError(f"line {line_no}: #!verbal_units row has an empty token id")
694        if vu_id not in id_to_citation:
695            raise ValueError(
696                f"line {line_no}: #!verbal_units references token id "
697                f"{vu_id!r}, which does not appear in the #!tokens block"
698            )
699        recorded_context = _parse_optional(context)
700        expected_context = id_to_citation[vu_id]
701        if recorded_context != expected_context:
702            raise ValueError(
703                f"line {line_no}: #!verbal_units row's context "
704                f"{recorded_context!r} for token {vu_id!r} does not match "
705                f"the #!tokens block's recorded context {expected_context!r} "
706                "for the same id"
707            )
708
709        verbalunits.append(
710            VerbalExpression(
711                id=vu_id,
712                syntactic_type=syntactic_type,
713                semantic_type=semantic_type,
714            )
715        )
716
717    # --- #!sentences ---
718    sentences: List[Sentence] = []
719    for line_no, line in blocks[SENTENCES_LABEL]:
720        parts = line.split("|")
721        if len(parts) != 4:
722            raise ValueError(
723                f"line {line_no}: #!sentences row has {len(parts)} "
724                f"columns, expected 4: {line!r}"
725            )
726        context_begin, first_id, context_end, last_id = parts
727        if first_id == "" or last_id == "":
728            raise ValueError(
729                f"line {line_no}: #!sentences row is missing first_token "
730                f"or last_token: {line!r}"
731            )
732        if first_id not in id_position or last_id not in id_position:
733            raise ValueError(
734                f"line {line_no}: #!sentences references a first_token/"
735                "last_token id not found in the #!tokens block"
736            )
737
738        start = id_position[first_id]
739        end = id_position[last_id]
740        if start > end:
741            raise ValueError(
742                f"line {line_no}: #!sentences row's first_token "
743                f"{first_id!r} comes after last_token {last_id!r} in the "
744                "#!tokens block's row order"
745            )
746
747        parsed_begin = _parse_optional(context_begin)
748        parsed_end = _parse_optional(context_end)
749        if parsed_begin != id_to_citation[first_id]:
750            raise ValueError(
751                f"line {line_no}: #!sentences row's context_begin "
752                f"{parsed_begin!r} does not match the #!tokens block's "
753                f"recorded context {id_to_citation[first_id]!r} for token "
754                f"{first_id!r}"
755            )
756        if parsed_end != id_to_citation[last_id]:
757            raise ValueError(
758                f"line {line_no}: #!sentences row's context_end "
759                f"{parsed_end!r} does not match the #!tokens block's "
760                f"recorded context {id_to_citation[last_id]!r} for token "
761                f"{last_id!r}"
762            )
763
764        sentence_ids = [
765            tid
766            for tid in row_order[start : end + 1]
767            if tokengraph[id_position[tid]].tokentype not in IMPLIED_TOKENTYPES
768        ]
769        sentences.append(
770            Sentence(
771                tokens=[
772                    Token(
773                        id=tid,
774                        text=tokengraph[id_position[tid]].token,
775                        citation=id_to_citation[tid],
776                    )
777                    for tid in sentence_ids
778                ]
779            )
780        )
781
782    # --- #!LM (optional) ---
783    lm_raw = blocks[LM_LABEL]
784    lm_infos: List[LMInfo] = []
785    if lm_raw:
786        if len(lm_raw) % 3 != 0:
787            first_line_no = lm_raw[0][0]
788            raise ValueError(
789                f"line {first_line_no}: '#!LM' block has {len(lm_raw)} "
790                "line(s), which is not a multiple of 3 -- each entry needs "
791                "exactly a MODEL=, CONTEXT=, and REASONING= line, in that "
792                "order"
793            )
794        for i in range(0, len(lm_raw), 3):
795            model_line_no, model_line = lm_raw[i]
796            context_line_no, context_line = lm_raw[i + 1]
797            reasoning_line_no, reasoning_line = lm_raw[i + 2]
798            if not model_line.startswith(_LM_MODEL_PREFIX):
799                raise ValueError(
800                    f"line {model_line_no}: expected a line starting with "
801                    f"{_LM_MODEL_PREFIX!r} in the '#!LM' block, got "
802                    f"{model_line!r}"
803                )
804            if not context_line.startswith(_LM_CONTEXT_PREFIX):
805                raise ValueError(
806                    f"line {context_line_no}: expected a line starting "
807                    f"with {_LM_CONTEXT_PREFIX!r} in the '#!LM' block, got "
808                    f"{context_line!r}"
809                )
810            if not reasoning_line.startswith(_LM_REASONING_PREFIX):
811                raise ValueError(
812                    f"line {reasoning_line_no}: expected a line starting "
813                    f"with {_LM_REASONING_PREFIX!r} in the '#!LM' block, "
814                    f"got {reasoning_line!r}"
815                )
816            lm_infos.append(
817                LMInfo(
818                    model=_parse_optional(model_line[len(_LM_MODEL_PREFIX):]),
819                    context=_parse_optional(context_line[len(_LM_CONTEXT_PREFIX):]),
820                    reasoning=_parse_optional(
821                        reasoning_line[len(_LM_REASONING_PREFIX):]
822                    ),
823                )
824            )
825
826        if len(lm_infos) != len(sentences):
827            raise ValueError(
828                f"'#!LM' block has {len(lm_infos)} "
829                f"entr{'y' if len(lm_infos) == 1 else 'ies'}, but "
830                f"#!sentences reconstructed {len(sentences)} sentence(s) -- "
831                "'#!LM' entries are recorded one per sentence, so these "
832                "must match"
833            )
834
835    return tokengraph, verbalunits, sentences, lm_infos

Read path (as written by write_analyses()/serialize_analyses()) and reconstruct (tokengraph, verbalunits, sentences, lm_infos) -- in that order, matching the order these 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, then the optional per-sentence '#!LM' record of what produced them).

Each of the three required 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. '#!LM' may repeat the same way.

lm_infos is [] if path has no '#!LM' block at all (every file written before this block existed, or any file written without passing reasoning to write_analyses()/serialize_analyses(), reads back exactly as before) -- otherwise it's one LMInfo per sentence, aligned with sentences by position, same as tokengraph/ verbalunits are conceptually aligned with sentences via token ids.

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, including '#!LM's own narrower checks (a line count that isn't a multiple of 3, a line missing its expected prefix, or a number of entries that doesn't match the number of sentences). 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 split_analysis_by_sentence( tokengraph: List[TokenAnalysis], verbalunits: List[VerbalExpression], sentences: List[Sentence]) -> List[Tuple[List[TokenAnalysis], List[VerbalExpression]]]:
838def split_analysis_by_sentence(
839    tokengraph: List[TokenAnalysis],
840    verbalunits: List[VerbalExpression],
841    sentences: List[Sentence],
842) -> List[Tuple[List[TokenAnalysis], List[VerbalExpression]]]:
843    """The inverse of what write_analyses()/serialize_analyses() flatten
844    together: given the same `(tokengraph, verbalunits, sentences)` triple
845    read_analyses() returns (or that analyze_sources()/combined_tokengraph()
846    produce before ever being written to a file), split `tokengraph` and
847    `verbalunits` back into one slice per sentence.
848
849    Returns a list the same length and order as `sentences` -- entry i is
850    `(sentence_tokengraph, sentence_verbalunits)` for `sentences[i]`. Useful
851    for anything that wants to review or render one sentence's analysis at
852    a time (e.g. a sentence-picker UI, like marimo/latin_syntaxer_review.py)
853    without re-running analysis or re-deriving the same id-position
854    bookkeeping read_analyses()/write_analyses() already do internally.
855
856    Relies on the same invariant read_analyses() and write_analyses()
857    already depend on: a sentence's own tokens form a contiguous,
858    matching-order run in `tokengraph` (see this module's own docstring).
859    `sentence_tokengraph` is the slice of `tokengraph` between that
860    sentence's first and last token's positions, inclusive -- which also
861    picks up any implied/elided tokens (tokentype in IMPLIED_TOKENTYPES)
862    interspersed within that range, since those were never part of
863    `sentence.tokens` to begin with but do belong to that sentence's own
864    analysis. `sentence_verbalunits` is every VerbalExpression whose id
865    falls within that same slice.
866
867    One consequence of using [first, last] *real* token positions as the
868    slice boundary, shared with read_analyses()'s own sentence
869    reconstruction: an implied token placed AFTER a sentence's last real
870    token (rather than nested between two real tokens) falls just outside
871    that slice, since there's no further real token of the same sentence
872    to bound it from above -- e.g. a one-real-token sentence like "Rara
873    [sunt]." (see tests/test_serialization.py's
874    test_split_excludes_a_trailing_implied_token_past_the_sentences_last_real_token).
875    An implied token nested between two real tokens of the same sentence
876    is included as expected; only this specific trailing case isn't.
877
878    Raises ValueError for a sentence with no tokens at all, or whose first
879    or last token id isn't present in `tokengraph` -- both should be
880    impossible for a triple that actually came from read_analyses(), which
881    already guarantees this by construction, but this function checks
882    explicitly anyway rather than trusting the caller, since nothing stops
883    it being called with a hand-built triple too.
884    """
885    id_position: Dict[str, int] = {tok.id: i for i, tok in enumerate(tokengraph)}
886
887    result: List[Tuple[List[TokenAnalysis], List[VerbalExpression]]] = []
888    for s_idx, sentence in enumerate(sentences):
889        if not sentence.tokens:
890            raise ValueError(f"sentence at index {s_idx} has no tokens")
891
892        first_id = sentence.tokens[0].id
893        last_id = sentence.tokens[-1].id
894        if first_id not in id_position or last_id not in id_position:
895            raise ValueError(
896                f"sentence at index {s_idx} (tokens {first_id!r}.."
897                f"{last_id!r}) has a boundary token not present in the "
898                "given tokengraph"
899            )
900
901        start = id_position[first_id]
902        end = id_position[last_id]
903        sentence_tokengraph = tokengraph[start : end + 1]
904        sentence_ids = {tok.id for tok in sentence_tokengraph}
905        sentence_verbalunits = [vu for vu in verbalunits if vu.id in sentence_ids]
906        result.append((sentence_tokengraph, sentence_verbalunits))
907
908    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/latin_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 like "Rara [sunt]." (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.

def read_ctsdata(path: str, delimiter: str = '|') -> List[CitedText]:
 61def read_ctsdata(path: str, delimiter: str = "|") -> List[CitedText]:
 62    """Read every `#!ctsdata` block in `path` and return their rows,
 63    concatenated in file order, as a list of `CitedText` -- see this
 64    module's docstring for the file shape, why `citation` holds the row's
 65    whole URN rather than a piece of it, and what counts as malformed.
 66
 67    `delimiter` is the column separator used both for the header line
 68    ('urn' + delimiter + 'text') and for splitting each data row; '|' by
 69    default, matching serialization.py's own convention. Pass a different
 70    character if the source file's own text content might contain '|' (the
 71    same escaping caveat serialization.py's module docstring notes for its
 72    own fields applies here too -- there is no escaping mechanism for
 73    whichever character is chosen as the delimiter).
 74
 75    Raises ValueError, naming the offending line, for: a data line
 76    appearing before any '#!ctsdata' label; a label line with no header
 77    line before the next block or before the file ends; a header line that
 78    doesn't match `delimiter`-joined 'urn'/'text' exactly; a data row that
 79    isn't exactly 2 columns; a blank urn or text column; or a urn that
 80    doesn't split into exactly 5 colon-separated parts. Raises ValueError
 81    (not returning an empty list) if the file has no '#!ctsdata' block at
 82    all, so a caller can't mistake "wrong file" for "file with zero
 83    passages".
 84    """
 85    expected_header = delimiter.join(["urn", "text"])
 86
 87    with open(path, "r", encoding="utf-8") as f:
 88        raw_lines = f.read().splitlines()
 89
 90    rows: List[CitedText] = []
 91    seen_block = False
 92    awaiting_header = False
 93
 94    for line_no, line in enumerate(raw_lines, start=1):
 95        if line.strip() == "":
 96            continue
 97
 98        if line == CTSDATA_LABEL:
 99            if awaiting_header:
100                raise ValueError(
101                    f"line {line_no}: a {CTSDATA_LABEL!r} block has a label "
102                    "line but no header line before the next block starts"
103                )
104            seen_block = True
105            awaiting_header = True
106            continue
107
108        if not seen_block:
109            raise ValueError(
110                f"line {line_no}: data line {line!r} appears before any "
111                f"{CTSDATA_LABEL!r} block label"
112            )
113
114        if awaiting_header:
115            if line != expected_header:
116                raise ValueError(
117                    f"line {line_no}: expected header {expected_header!r} "
118                    f"for a {CTSDATA_LABEL!r} block, got {line!r}"
119                )
120            awaiting_header = False
121            continue
122
123        parts = line.split(delimiter)
124        if len(parts) != 2:
125            raise ValueError(
126                f"line {line_no}: {CTSDATA_LABEL!r} row has {len(parts)} "
127                f"column(s) (delimiter {delimiter!r}), expected 2: {line!r}"
128            )
129        urn, text = parts
130        if urn == "":
131            raise ValueError(f"line {line_no}: {CTSDATA_LABEL!r} row has an empty urn column")
132        if text == "":
133            raise ValueError(f"line {line_no}: {CTSDATA_LABEL!r} row has an empty text column")
134
135        urn_parts = urn.split(":")
136        if len(urn_parts) != 5:
137            raise ValueError(
138                f"line {line_no}: urn {urn!r} has {len(urn_parts)} "
139                "colon-separated part(s), expected 5 (e.g. "
140                "'urn:cts:compnov:bible.genesis.vulgate:45.1')"
141            )
142        if urn_parts[4] == "":
143            raise ValueError(
144                f"line {line_no}: urn {urn!r} has an empty final (citation) part"
145            )
146
147        rows.append(CitedText(citation=urn, text=text))
148
149    if not seen_block:
150        raise ValueError(f"file has no {CTSDATA_LABEL!r} block")
151    if awaiting_header:
152        raise ValueError(
153            f"a {CTSDATA_LABEL!r} block has a label line but no header "
154            "line (and no data) -- the file ends too early"
155        )
156
157    return rows

Read every #!ctsdata block in path and return their rows, concatenated in file order, as a list of CitedText -- see this module's docstring for the file shape, why citation holds the row's whole URN rather than a piece of it, 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 serialize_segmentation(sentences: List[Sentence]) -> str:
111def serialize_segmentation(sentences: List[Sentence]) -> str:
112    """Build the `#!sentences`/`#!tokens` text described in this module's
113    own docstring for `sentences` -- `segment_sources()`'s own output, with
114    no syntax analysis run over it -- and return it as a single string,
115    including its trailing newline.
116
117    Raises ValueError, naming the offending sentence/row, if any sentence
118    has no tokens at all (nothing to derive `#!sentences`' own
119    first_token/last_token from), or if any field value contains '|' or a
120    newline (see `_field()`).
121    """
122    sentence_lines: List[str] = [SENTENCES_LABEL, SENTENCES_HEADER]
123    token_lines: List[str] = [TOKENS_LABEL, TOKENS_HEADER]
124
125    for s_idx, sentence in enumerate(sentences):
126        if not sentence.tokens:
127            raise ValueError(
128                f"sentence at index {s_idx} has no tokens -- cannot derive "
129                "#!sentences' own first_token/last_token for an empty sentence"
130            )
131
132        first_tok = sentence.tokens[0]
133        last_tok = sentence.tokens[-1]
134        where = f"#!sentences row for sentence {s_idx}"
135        sentence_lines.append(
136            "|".join(
137                [
138                    _field(first_tok.citation, where=where),
139                    _field(first_tok.id, where=where),
140                    _field(last_tok.citation, where=where),
141                    _field(last_tok.id, where=where),
142                ]
143            )
144        )
145
146        for tok in sentence.tokens:
147            where = f"#!tokens row for sentence {s_idx} token {tok.id!r}"
148            token_lines.append(
149                "|".join(
150                    [
151                        _field(tok.citation, where=where),
152                        str(s_idx),
153                        _field(tok.id, where=where),
154                        _field(tok.text, where=where),
155                    ]
156                )
157            )
158
159    return "\n".join(sentence_lines + [""] + token_lines) + "\n"

Build the #!sentences/#!tokens text described in this module's own docstring for sentences -- segment_sources()'s own output, with no syntax analysis run over it -- and return it as a single string, including its trailing newline.

Raises ValueError, naming the offending sentence/row, if any sentence has no tokens at all (nothing to derive #!sentences' own first_token/last_token from), or if any field value contains '|' or a newline (see _field()).

def write_segmentation(sentences: List[Sentence], path: str) -> None:
162def write_segmentation(sentences: List[Sentence], path: str) -> None:
163    """Write `serialize_segmentation(sentences)`'s output straight to
164    `path` (UTF-8), overwriting any existing file. Thin wrapper, same
165    relationship `write_analyses()` has to `serialize_analyses()`."""
166    content = serialize_segmentation(sentences)
167    with open(path, "w", encoding="utf-8") as f:
168        f.write(content)

Write serialize_segmentation(sentences)'s output straight to path (UTF-8), overwriting any existing file. Thin wrapper, same relationship write_analyses() has to serialize_analyses().

def read_segmentation(path: str) -> List[Sentence]:
171def read_segmentation(path: str) -> List[Sentence]:
172    """Read `path` (as written by `serialize_segmentation()`/
173    `write_segmentation()`) and reconstruct the `List[Sentence]` it was
174    built from -- see this module's own docstring for the file shape and
175    what counts as malformed.
176
177    Raises ValueError, naming the offending line, for: a missing
178    `#!sentences` or `#!tokens` block; a label line with no header line
179    before the next block or before the file ends; a header line that
180    doesn't match exactly; a data row with the wrong column count; a
181    `#!tokens` row with a blank id, a duplicate id, or a `sentence_index`
182    that isn't a non-negative integer; `#!tokens`' own `sentence_index`
183    values not forming a contiguous `0..N-1` range with no gaps; a mismatch
184    in how many sentences `#!sentences` and `#!tokens` each imply; or a
185    `#!sentences` row whose own first_token/last_token/citations disagree
186    with the `#!tokens` block's own grouping for that sentence.
187    """
188    with open(path, "r", encoding="utf-8") as f:
189        raw_lines = f.read().splitlines()
190
191    blocks: Dict[str, List[Tuple[int, str]]] = {label: [] for label in _EXPECTED_HEADERS}
192    seen_labels = set()
193    current_label: Optional[str] = None
194    awaiting_header = False
195
196    for line_no, line in enumerate(raw_lines, start=1):
197        if line.strip() == "":
198            continue
199
200        if line in _EXPECTED_HEADERS:
201            if awaiting_header:
202                raise ValueError(
203                    f"line {line_no}: block {current_label!r} has a label "
204                    "line but no header line before the next block starts"
205                )
206            if line in seen_labels:
207                raise ValueError(
208                    f"line {line_no}: block {line!r} appears more than "
209                    "once -- this format doesn't support repeated blocks "
210                    "(see module docstring)"
211                )
212            current_label = line
213            seen_labels.add(line)
214            awaiting_header = True
215            continue
216
217        if current_label is None:
218            raise ValueError(
219                f"line {line_no}: data line {line!r} appears before any "
220                "'#!' block label"
221            )
222
223        if awaiting_header:
224            expected = _EXPECTED_HEADERS[current_label]
225            if line != expected:
226                raise ValueError(
227                    f"line {line_no}: expected header {expected!r} for "
228                    f"block {current_label!r}, got {line!r}"
229                )
230            awaiting_header = False
231            continue
232
233        blocks[current_label].append((line_no, line))
234
235    missing = sorted(set(_EXPECTED_HEADERS) - seen_labels)
236    if missing:
237        raise ValueError(f"file is missing required block(s): {missing}")
238    if awaiting_header:
239        raise ValueError(
240            f"block {current_label!r} has a label line but no header line "
241            "(and no data) -- the file ends too early"
242        )
243
244    # --- #!tokens: parse rows, then group into per-sentence lists by
245    # sentence_index, preserving each row's own file order within a group. ---
246    seen_ids = set()
247    parsed_rows: List[Tuple[int, int, str, Optional[str], str]] = []
248    for line_no, line in blocks[TOKENS_LABEL]:
249        parts = line.split("|")
250        if len(parts) != 4:
251            raise ValueError(
252                f"line {line_no}: #!tokens row has {len(parts)} column(s), "
253                f"expected 4: {line!r}"
254            )
255        context, sentence_index_raw, tok_id, text = parts
256        if tok_id == "":
257            raise ValueError(f"line {line_no}: #!tokens row has an empty id")
258        if tok_id in seen_ids:
259            raise ValueError(f"line {line_no}: duplicate token id {tok_id!r} in #!tokens")
260        seen_ids.add(tok_id)
261
262        try:
263            sentence_index = int(sentence_index_raw)
264        except ValueError:
265            raise ValueError(
266                f"line {line_no}: #!tokens row's sentence_index "
267                f"{sentence_index_raw!r} is not an integer"
268            ) from None
269        if sentence_index < 0:
270            raise ValueError(
271                f"line {line_no}: #!tokens row's sentence_index "
272                f"{sentence_index} is negative"
273            )
274
275        parsed_rows.append((line_no, sentence_index, tok_id, _parse_optional(context), text))
276
277    groups: Dict[int, List[Tuple[str, Optional[str], str]]] = {}
278    for _line_no, sentence_index, tok_id, citation, text in parsed_rows:
279        groups.setdefault(sentence_index, []).append((tok_id, citation, text))
280
281    num_sentences = len(groups)
282    if sorted(groups) != list(range(num_sentences)):
283        raise ValueError(
284            f"#!tokens block's sentence_index values are {sorted(groups)}, "
285            f"expected a contiguous 0..{max(num_sentences - 1, 0)} range "
286            "with no gaps and no negative values"
287        )
288
289    sentences: List[Sentence] = [
290        Sentence(
291            tokens=[
292                Token(id=tok_id, text=text, citation=citation)
293                for tok_id, citation, text in groups[s_idx]
294            ]
295        )
296        for s_idx in range(num_sentences)
297    ]
298
299    # --- #!sentences: cross-check against the #!tokens block's own grouping. ---
300    if len(blocks[SENTENCES_LABEL]) != num_sentences:
301        raise ValueError(
302            f"#!sentences has {len(blocks[SENTENCES_LABEL])} row(s) but "
303            f"#!tokens implies {num_sentences} sentence(s) -- these must match"
304        )
305
306    for s_idx, (line_no, line) in enumerate(blocks[SENTENCES_LABEL]):
307        parts = line.split("|")
308        if len(parts) != 4:
309            raise ValueError(
310                f"line {line_no}: #!sentences row has {len(parts)} "
311                f"column(s), expected 4: {line!r}"
312            )
313        context_begin, first_id, context_end, last_id = parts
314        if first_id == "" or last_id == "":
315            raise ValueError(
316                f"line {line_no}: #!sentences row is missing first_token "
317                f"or last_token: {line!r}"
318            )
319
320        sentence = sentences[s_idx]
321        if not sentence.tokens:
322            raise ValueError(f"line {line_no}: sentence {s_idx} has no tokens in the #!tokens block")
323        actual_first, actual_last = sentence.tokens[0], sentence.tokens[-1]
324
325        if first_id != actual_first.id or last_id != actual_last.id:
326            raise ValueError(
327                f"line {line_no}: #!sentences row for sentence {s_idx} "
328                f"names first_token/last_token {first_id!r}/{last_id!r}, "
329                f"but the #!tokens block's own sentence {s_idx} group runs "
330                f"from {actual_first.id!r} to {actual_last.id!r}"
331            )
332
333        parsed_begin = _parse_optional(context_begin)
334        parsed_end = _parse_optional(context_end)
335        if parsed_begin != actual_first.citation:
336            raise ValueError(
337                f"line {line_no}: #!sentences row's context_begin "
338                f"{parsed_begin!r} does not match the #!tokens block's "
339                f"recorded citation {actual_first.citation!r} for token "
340                f"{first_id!r}"
341            )
342        if parsed_end != actual_last.citation:
343            raise ValueError(
344                f"line {line_no}: #!sentences row's context_end "
345                f"{parsed_end!r} does not match the #!tokens block's "
346                f"recorded citation {actual_last.citation!r} for token "
347                f"{last_id!r}"
348            )
349
350    return sentences

Read path (as written by serialize_segmentation()/ write_segmentation()) and reconstruct the List[Sentence] it was built from -- see this module's own docstring for the file shape and what counts as malformed.

Raises ValueError, naming the offending line, for: a missing #!sentences or #!tokens block; a label line with no header line before the next block or before the file ends; a header line that doesn't match exactly; a data row with the wrong column count; a #!tokens row with a blank id, a duplicate id, or a sentence_index that isn't a non-negative integer; #!tokens' own sentence_index values not forming a contiguous 0..N-1 range with no gaps; a mismatch in how many sentences #!sentences and #!tokens each imply; or a #!sentences row whose own first_token/last_token/citations disagree with the #!tokens block's own grouping for that sentence.

def estimate_max_tokens( num_tokens: int, *, safety_margin: float = 1.4, floor: int = 256, ceiling: int = 32000) -> int:
134def estimate_max_tokens(
135    num_tokens: int,
136    *,
137    safety_margin: float = DEFAULT_SAFETY_MARGIN,
138    floor: int = DEFAULT_FLOOR,
139    ceiling: int = DEFAULT_CEILING,
140) -> int:
141    """Estimate a `max_tokens` budget for a SentenceAnalysis call over a
142    sentence with `num_tokens` input tokens.
143
144    `raw = intercept + slope * num_tokens` comes from the calibrated (or
145    fallback) linear fit (see _load_calibration()); `safety_margin`
146    multiplies that to leave room for the reasoning field's length being
147    only roughly, not exactly, a function of passage length. The result is
148    clamped to `[floor, ceiling]` -- `floor` guards against a degenerate
149    tiny estimate for a 1-2 token sentence, `ceiling` is a hard cap you
150    should set to your actual model's real max-output-tokens limit (see
151    DEFAULT_CEILING's docstring note).
152
153    Raises ValueError if `num_tokens` is negative.
154    """
155    if num_tokens < 0:
156        raise ValueError(f"num_tokens must be >= 0, got {num_tokens}")
157
158    calibration = _load_calibration()
159    raw = calibration["intercept"] + calibration["slope"] * num_tokens
160    budget = math.ceil(raw * safety_margin)
161    return max(floor, min(ceiling, budget))

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

Call analyze(), detecting truncation and retrying with a larger max_tokens budget instead of either crashing or silently returning an incomplete result.

The starting budget is initial_max_tokens if given, else estimate_max_tokens(len(tokens), safety_margin=safety_margin, floor=floor, ceiling=ceiling).

After each attempt, truncation is checked two ways: _missing_token_ids against the result (the primary, LM-independent signal -- works whenever a result exists at all, parsed or not, including under DummyLM in tests) and, if the call raised AdapterParseError instead of returning a result (the JSON was cut off badly enough to not parse at all), _finish_reason_was_length() as a corroborating check.

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.

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.

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:
126def get_calibration() -> dict:
127    """Public introspection: what (intercept, slope) is estimate_max_tokens()
128    currently using, and did it come from calibrate_max_tokens.py's fit or
129    from this module's untuned fallback? See _load_calibration()'s
130    docstring for the shape returned."""
131    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
class LMCostSummary(typing.NamedTuple):
45class LMCostSummary(NamedTuple):
46    """The result of `summarize_lm_cost()`.
47
48    `total_cost` is the dollar sum of every history entry that actually
49    recorded a cost, or `None` if there were no such entries at all --
50    either because `history` was empty, or because every call in it was a
51    cache hit (see this module's own docstring). Callers should treat
52    `None` as "unknown", not as "$0" -- summing an empty/all-`None` set of
53    costs is not the same claim as "this cost nothing".
54
55    `priced_calls` and `uncosted_calls` count entries that did and didn't
56    record a `cost`, respectively (a `None` `cost` value on any individual
57    entry -- see the module docstring for why that happens); `total_calls`
58    is their sum, i.e. `len(history)`."""
59
60    total_cost: Optional[float]
61    priced_calls: int
62    uncosted_calls: int
63
64    @property
65    def total_calls(self) -> int:
66        return self.priced_calls + self.uncosted_calls

The result of summarize_lm_cost().

total_cost is the dollar sum of every history entry that actually recorded a cost, or None if there were no such entries at all -- either because history was empty, or because every call in it was a cache hit (see this module's own docstring). Callers should treat None as "unknown", not as "$0" -- summing an empty/all-None set of costs is not the same claim as "this cost nothing".

priced_calls and uncosted_calls count entries that did and didn't record a cost, respectively (a None cost value on any individual entry -- see the module docstring for why that happens); total_calls is their sum, i.e. len(history).

LMCostSummary(total_cost: float | None, priced_calls: int, uncosted_calls: int)

Create new instance of LMCostSummary(total_cost, priced_calls, uncosted_calls)

total_cost: float | None

Alias for field number 0

priced_calls: int

Alias for field number 1

uncosted_calls: int

Alias for field number 2

total_calls: int
64    @property
65    def total_calls(self) -> int:
66        return self.priced_calls + self.uncosted_calls
def summarize_lm_cost(history: List[Any]) -> LMCostSummary:
 83def summarize_lm_cost(history: List[Any]) -> LMCostSummary:
 84    """Sum the `cost` recorded on every entry of `history` (a `dspy.LM`
 85    instance's own `.history` list, or any list shaped like it) that
 86    actually has one, and count how many did versus didn't.
 87
 88    Never raises: an empty `history` returns
 89    `LMCostSummary(total_cost=None, priced_calls=0, uncosted_calls=0)`,
 90    and a `history` where every entry's `cost` is `None` (every call
 91    served from cache) returns `LMCostSummary(total_cost=None,
 92    priced_calls=0, uncosted_calls=len(history))` -- `total_cost` is only
 93    ever a number when at least one entry actually recorded one.
 94    """
 95    priced_calls = 0
 96    uncosted_calls = 0
 97    total_cost = 0.0
 98
 99    for entry in history:
100        cost = _entry_cost(entry)
101        if cost is None:
102            uncosted_calls += 1
103        else:
104            priced_calls += 1
105            total_cost += cost
106
107    if priced_calls == 0:
108        return LMCostSummary(total_cost=None, priced_calls=0, uncosted_calls=uncosted_calls)
109    return LMCostSummary(total_cost=total_cost, priced_calls=priced_calls, uncosted_calls=uncosted_calls)

Sum the cost recorded on every entry of history (a dspy.LM instance's own .history list, or any list shaped like it) that actually has one, and count how many did versus didn't.

Never raises: an empty history returns LMCostSummary(total_cost=None, priced_calls=0, uncosted_calls=0), and a history where every entry's cost is None (every call served from cache) returns LMCostSummary(total_cost=None, priced_calls=0, uncosted_calls=len(history)) -- total_cost is only ever a number when at least one entry actually recorded one.

def format_lm_cost(summary: LMCostSummary) -> str:
112def format_lm_cost(summary: LMCostSummary) -> str:
113    """Render an `LMCostSummary` as one short, human-readable line for
114    display (e.g. a notebook's own "Cost" markdown cell) -- covering every
115    case `summarize_lm_cost()` can return without the caller needing to
116    branch on `None` itself:
117
118    - no calls at all -> "no LM calls yet"
119    - calls, but every one served from cache -> says so explicitly,
120      rather than printing a bare, unexplained "None"
121    - a mix of priced and cached calls -> the priced total, plus a note
122      that some calls aren't included in it
123    - every call priced -> the total alone
124    """
125    if summary.total_calls == 0:
126        return "no LM calls yet"
127
128    if summary.total_cost is None:
129        call_word = "call" if summary.uncosted_calls == 1 else "calls"
130        return f"$0.00 billed -- {summary.uncosted_calls} {call_word}, all served from cache (no cost recorded)"
131
132    priced_word = "call" if summary.priced_calls == 1 else "calls"
133    base = f"${summary.total_cost:.4f} across {summary.priced_calls} {priced_word}"
134    if summary.uncosted_calls:
135        cached_word = "call" if summary.uncosted_calls == 1 else "calls"
136        return f"{base} (+ {summary.uncosted_calls} more {cached_word} served from cache, not included)"
137    return base

Render an LMCostSummary as one short, human-readable line for display (e.g. a notebook's own "Cost" markdown cell) -- covering every case summarize_lm_cost() can return without the caller needing to branch on None itself:

  • no calls at all -> "no LM calls yet"
  • calls, but every one served from cache -> says so explicitly, rather than printing a bare, unexplained "None"
  • a mix of priced and cached calls -> the priced total, plus a note that some calls aren't included in it
  • every call priced -> the total alone
class LewisShortEntry(typing.NamedTuple):
64class LewisShortEntry(NamedTuple):
65    """One row of the ls-articles.cex file, essentially unchanged -- a
66    plain NamedTuple, not a pydantic model, matching this codebase's own
67    convention for a data record that never needs DSPy to generate or
68    validate it (see GraphMetrics in graphs.py, LMCostSummary in
69    lm_cost.py) rather than models.py's pydantic BaseModel subclasses,
70    which are reserved for structures dspy.Predict itself produces."""
71
72    seq: int
73    urn: str
74    key: str
75    entry: str

One row of the ls-articles.cex file, essentially unchanged -- a plain NamedTuple, not a pydantic model, matching this codebase's own convention for a data record that never needs DSPy to generate or validate it (see GraphMetrics in graphs.py, LMCostSummary in lm_cost.py) rather than models.py's pydantic BaseModel subclasses, which are reserved for structures dspy.Predict itself produces.

LewisShortEntry(seq: int, urn: str, key: str, entry: str)

Create new instance of LewisShortEntry(seq, urn, key, entry)

seq: int

Alias for field number 0

urn: str

Alias for field number 1

key: str

Alias for field number 2

entry: str

Alias for field number 3

class LewisShortMatch(typing.NamedTuple):
78class LewisShortMatch(NamedTuple):
79    """One candidate returned by LewisShortLexicon.lookup(): the matched
80    entry, plus a similarity score in [0.0, 1.0] -- always exactly 1.0 for
81    an exact (case/diacritic-insensitive) match, and a difflib
82    SequenceMatcher ratio for a fuzzy-fallback candidate. Never a mix of
83    the two within one lookup() call: an exact hit short-circuits fuzzy
84    matching entirely (see lookup()'s own docstring)."""
85
86    entry: LewisShortEntry
87    score: float

One candidate returned by LewisShortLexicon.lookup(): the matched entry, plus a similarity score in [0.0, 1.0] -- always exactly 1.0 for an exact (case/diacritic-insensitive) match, and a difflib SequenceMatcher ratio for a fuzzy-fallback candidate. Never a mix of the two within one lookup() call: an exact hit short-circuits fuzzy matching entirely (see lookup()'s own docstring).

LewisShortMatch(entry: LewisShortEntry, score: float)

Create new instance of LewisShortMatch(entry, score)

Alias for field number 0

score: float

Alias for field number 1

class LewisShortLexicon:
235class LewisShortLexicon:
236    """Lewis & Short's *A Latin Dictionary*, loaded once from
237    ls-articles.cex (or any file read_lewis_short() accepts), indexed for
238    two kinds of lookup: get() for a literal, unnormalized `key` string
239    you already have exactly right, and lookup() for the general case --
240    "what article, if any, matches this lemma" -- covering typos, case,
241    and macron differences via a fuzzy-ranked fallback.
242    """
243
244    def __init__(self, entries: List[LewisShortEntry]):
245        self._entries: List[LewisShortEntry] = list(entries)
246
247        self._by_key: Dict[str, LewisShortEntry] = {}
248        self._by_normalized: Dict[str, List[LewisShortEntry]] = {}
249        for e in self._entries:
250            if e.key in self._by_key:
251                other = self._by_key[e.key]
252                raise ValueError(
253                    f"duplicate key {e.key!r} (seq {other.seq} and seq {e.seq}) -- "
254                    "LewisShortLexicon needs one entry per key to build its lookup index"
255                )
256            self._by_key[e.key] = e
257            self._by_normalized.setdefault(_normalize_lemma(e.key), []).append(e)
258
259        # The candidate pool difflib.get_close_matches() ranks against in
260        # lookup()'s fuzzy fallback -- computed once here, not per call.
261        self._normalized_keys: List[str] = list(self._by_normalized.keys())
262
263    @classmethod
264    def from_file(cls, path: str, delimiter: str = "|") -> "LewisShortLexicon":
265        """Convenience constructor: read_lewis_short(path, delimiter=delimiter)
266        followed by LewisShortLexicon(...) -- the common case of loading
267        straight from a file on disk rather than an already-built entry list."""
268        return cls(read_lewis_short(path, delimiter=delimiter))
269
270    @classmethod
271    def from_url(
272        cls, url: str = LEWIS_SHORT_URL, *, delimiter: str = "|", timeout: float = 60.0
273    ) -> "LewisShortLexicon":
274        """Convenience constructor: read_lewis_short_from_url(url, ...)
275        followed by LewisShortLexicon(...) -- fetches the dictionary over
276        HTTP (from LEWIS_SHORT_URL, the published location, by default)
277        rather than reading a local file (from_file()'s job). Same
278        parameters, same exceptions, and the same "not verified against
279        the real endpoint from this sandbox" caveat as
280        read_lewis_short_from_url() itself -- see that function's own
281        docstring."""
282        return cls(read_lewis_short_from_url(url, delimiter=delimiter, timeout=timeout))
283
284    def __len__(self) -> int:
285        return len(self._entries)
286
287    def __iter__(self) -> Iterator[LewisShortEntry]:
288        return iter(self._entries)
289
290    def get(self, key: str) -> Optional[LewisShortEntry]:
291        """Literal, case-sensitive, diacritic-sensitive lookup by `key` --
292        exactly the column-3 value, homonym digit suffix and all (e.g.
293        'abdico1', not 'abdico'). None if `key` isn't in the lexicon.
294
295        Use this when you already have an exact key in hand (e.g. from a
296        citation index, or from a previous lookup()'s own
297        `match.entry.key`); use lookup() instead for anything that might
298        need normalization or fuzzy matching."""
299        return self._by_key.get(key)
300
301    def lookup(self, lemma: str, *, limit: int = 5, cutoff: float = 0.6) -> List[LewisShortMatch]:
302        """Find the article(s) matching `lemma`, preferring an exact match
303        and falling back to a ranked fuzzy match only when there isn't one.
304
305        "Exact" here means case- and diacritic-insensitive (see
306        _normalize_lemma()'s own docstring for why that still counts as
307        exact rather than fuzzy) -- so 'amo', 'Amo', and 'amō' all exact-
308        match the same 'amo' entry. When an exact match is found, fuzzy
309        matching never runs at all: the result is exactly that entry (or,
310        in the -- currently nonexistent in the real data, but not assumed
311        away -- case of two keys colliding once normalized, every one of
312        them) at score 1.0, sorted by key for a deterministic order.
313
314        Without an exact match, `lemma` is ranked by fuzzy similarity
315        (difflib's SequenceMatcher.ratio(), via get_close_matches() for
316        the initial fast pass -- stdlib only, no new dependency, and fast
317        enough in practice: under ~0.15s per query benchmarked against the
318        real 51,596-key vocabulary) against every OTHER normalized key in
319        the lexicon, returning at most `limit` results with score >=
320        `cutoff`, highest score first (ties broken by key, for a
321        deterministic order). An empty list means nothing scored at or
322        above `cutoff` -- not an error.
323
324        A headword Lewis & Short split into homonyms (e.g. 'abdico1' /
325        'abdico2' -- see this module's own docstring) has no bare-lemma
326        exact match at all, so looking up 'abdico' lands in the fuzzy
327        branch, where both numbered forms score identically (only the
328        trailing digit differs) and come back tied for first place. That
329        is the correct, honest answer -- Lewis & Short's own spelling
330        gives no way to pick one over the other -- not a bug: a caller
331        that wants a single answer regardless needs its own
332        disambiguation logic (surrounding context, part of speech, ...)
333        layered on top of this.
334
335        Raises ValueError if `lemma` is blank (nothing to match against).
336        """
337        if not lemma.strip():
338            raise ValueError("lemma must not be blank")
339
340        normalized = _normalize_lemma(lemma)
341
342        exact = self._by_normalized.get(normalized)
343        if exact:
344            return [LewisShortMatch(e, 1.0) for e in sorted(exact, key=lambda e: e.key)]
345
346        close = difflib.get_close_matches(normalized, self._normalized_keys, n=limit, cutoff=cutoff)
347        matches: List[LewisShortMatch] = []
348        for candidate in close:
349            ratio = difflib.SequenceMatcher(None, candidate, normalized).ratio()
350            for e in self._by_normalized[candidate]:
351                matches.append(LewisShortMatch(e, ratio))
352        matches.sort(key=lambda m: (-m.score, m.entry.key))
353        return matches[:limit]

Lewis & Short's A Latin Dictionary, loaded once from ls-articles.cex (or any file read_lewis_short() accepts), indexed for two kinds of lookup: get() for a literal, unnormalized key string you already have exactly right, and lookup() for the general case -- "what article, if any, matches this lemma" -- covering typos, case, and macron differences via a fuzzy-ranked fallback.

LewisShortLexicon(entries: List[LewisShortEntry])
244    def __init__(self, entries: List[LewisShortEntry]):
245        self._entries: List[LewisShortEntry] = list(entries)
246
247        self._by_key: Dict[str, LewisShortEntry] = {}
248        self._by_normalized: Dict[str, List[LewisShortEntry]] = {}
249        for e in self._entries:
250            if e.key in self._by_key:
251                other = self._by_key[e.key]
252                raise ValueError(
253                    f"duplicate key {e.key!r} (seq {other.seq} and seq {e.seq}) -- "
254                    "LewisShortLexicon needs one entry per key to build its lookup index"
255                )
256            self._by_key[e.key] = e
257            self._by_normalized.setdefault(_normalize_lemma(e.key), []).append(e)
258
259        # The candidate pool difflib.get_close_matches() ranks against in
260        # lookup()'s fuzzy fallback -- computed once here, not per call.
261        self._normalized_keys: List[str] = list(self._by_normalized.keys())
@classmethod
def from_file( cls, path: str, delimiter: str = '|') -> LewisShortLexicon:
263    @classmethod
264    def from_file(cls, path: str, delimiter: str = "|") -> "LewisShortLexicon":
265        """Convenience constructor: read_lewis_short(path, delimiter=delimiter)
266        followed by LewisShortLexicon(...) -- the common case of loading
267        straight from a file on disk rather than an already-built entry list."""
268        return cls(read_lewis_short(path, delimiter=delimiter))

Convenience constructor: read_lewis_short(path, delimiter=delimiter) followed by LewisShortLexicon(...) -- the common case of loading straight from a file on disk rather than an already-built entry list.

@classmethod
def from_url( cls, url: str = 'https://shot.holycross.edu/lexica/ls-articles.cex', *, delimiter: str = '|', timeout: float = 60.0) -> LewisShortLexicon:
270    @classmethod
271    def from_url(
272        cls, url: str = LEWIS_SHORT_URL, *, delimiter: str = "|", timeout: float = 60.0
273    ) -> "LewisShortLexicon":
274        """Convenience constructor: read_lewis_short_from_url(url, ...)
275        followed by LewisShortLexicon(...) -- fetches the dictionary over
276        HTTP (from LEWIS_SHORT_URL, the published location, by default)
277        rather than reading a local file (from_file()'s job). Same
278        parameters, same exceptions, and the same "not verified against
279        the real endpoint from this sandbox" caveat as
280        read_lewis_short_from_url() itself -- see that function's own
281        docstring."""
282        return cls(read_lewis_short_from_url(url, delimiter=delimiter, timeout=timeout))

Convenience constructor: read_lewis_short_from_url(url, ...) followed by LewisShortLexicon(...) -- fetches the dictionary over HTTP (from LEWIS_SHORT_URL, the published location, by default) rather than reading a local file (from_file()'s job). Same parameters, same exceptions, and the same "not verified against the real endpoint from this sandbox" caveat as read_lewis_short_from_url() itself -- see that function's own docstring.

def get(self, key: str) -> LewisShortEntry | None:
290    def get(self, key: str) -> Optional[LewisShortEntry]:
291        """Literal, case-sensitive, diacritic-sensitive lookup by `key` --
292        exactly the column-3 value, homonym digit suffix and all (e.g.
293        'abdico1', not 'abdico'). None if `key` isn't in the lexicon.
294
295        Use this when you already have an exact key in hand (e.g. from a
296        citation index, or from a previous lookup()'s own
297        `match.entry.key`); use lookup() instead for anything that might
298        need normalization or fuzzy matching."""
299        return self._by_key.get(key)

Literal, case-sensitive, diacritic-sensitive lookup by key -- exactly the column-3 value, homonym digit suffix and all (e.g. 'abdico1', not 'abdico'). None if key isn't in the lexicon.

Use this when you already have an exact key in hand (e.g. from a citation index, or from a previous lookup()'s own match.entry.key); use lookup() instead for anything that might need normalization or fuzzy matching.

def lookup( self, lemma: str, *, limit: int = 5, cutoff: float = 0.6) -> List[LewisShortMatch]:
301    def lookup(self, lemma: str, *, limit: int = 5, cutoff: float = 0.6) -> List[LewisShortMatch]:
302        """Find the article(s) matching `lemma`, preferring an exact match
303        and falling back to a ranked fuzzy match only when there isn't one.
304
305        "Exact" here means case- and diacritic-insensitive (see
306        _normalize_lemma()'s own docstring for why that still counts as
307        exact rather than fuzzy) -- so 'amo', 'Amo', and 'amō' all exact-
308        match the same 'amo' entry. When an exact match is found, fuzzy
309        matching never runs at all: the result is exactly that entry (or,
310        in the -- currently nonexistent in the real data, but not assumed
311        away -- case of two keys colliding once normalized, every one of
312        them) at score 1.0, sorted by key for a deterministic order.
313
314        Without an exact match, `lemma` is ranked by fuzzy similarity
315        (difflib's SequenceMatcher.ratio(), via get_close_matches() for
316        the initial fast pass -- stdlib only, no new dependency, and fast
317        enough in practice: under ~0.15s per query benchmarked against the
318        real 51,596-key vocabulary) against every OTHER normalized key in
319        the lexicon, returning at most `limit` results with score >=
320        `cutoff`, highest score first (ties broken by key, for a
321        deterministic order). An empty list means nothing scored at or
322        above `cutoff` -- not an error.
323
324        A headword Lewis & Short split into homonyms (e.g. 'abdico1' /
325        'abdico2' -- see this module's own docstring) has no bare-lemma
326        exact match at all, so looking up 'abdico' lands in the fuzzy
327        branch, where both numbered forms score identically (only the
328        trailing digit differs) and come back tied for first place. That
329        is the correct, honest answer -- Lewis & Short's own spelling
330        gives no way to pick one over the other -- not a bug: a caller
331        that wants a single answer regardless needs its own
332        disambiguation logic (surrounding context, part of speech, ...)
333        layered on top of this.
334
335        Raises ValueError if `lemma` is blank (nothing to match against).
336        """
337        if not lemma.strip():
338            raise ValueError("lemma must not be blank")
339
340        normalized = _normalize_lemma(lemma)
341
342        exact = self._by_normalized.get(normalized)
343        if exact:
344            return [LewisShortMatch(e, 1.0) for e in sorted(exact, key=lambda e: e.key)]
345
346        close = difflib.get_close_matches(normalized, self._normalized_keys, n=limit, cutoff=cutoff)
347        matches: List[LewisShortMatch] = []
348        for candidate in close:
349            ratio = difflib.SequenceMatcher(None, candidate, normalized).ratio()
350            for e in self._by_normalized[candidate]:
351                matches.append(LewisShortMatch(e, ratio))
352        matches.sort(key=lambda m: (-m.score, m.entry.key))
353        return matches[:limit]

Find the article(s) matching lemma, preferring an exact match and falling back to a ranked fuzzy match only when there isn't one.

"Exact" here means case- and diacritic-insensitive (see _normalize_lemma()'s own docstring for why that still counts as exact rather than fuzzy) -- so 'amo', 'Amo', and 'amō' all exact- match the same 'amo' entry. When an exact match is found, fuzzy matching never runs at all: the result is exactly that entry (or, in the -- currently nonexistent in the real data, but not assumed away -- case of two keys colliding once normalized, every one of them) at score 1.0, sorted by key for a deterministic order.

Without an exact match, lemma is ranked by fuzzy similarity (difflib's SequenceMatcher.ratio(), via get_close_matches() for the initial fast pass -- stdlib only, no new dependency, and fast enough in practice: under ~0.15s per query benchmarked against the real 51,596-key vocabulary) against every OTHER normalized key in the lexicon, returning at most limit results with score >= cutoff, highest score first (ties broken by key, for a deterministic order). An empty list means nothing scored at or above cutoff -- not an error.

A headword Lewis & Short split into homonyms (e.g. 'abdico1' / 'abdico2' -- see this module's own docstring) has no bare-lemma exact match at all, so looking up 'abdico' lands in the fuzzy branch, where both numbered forms score identically (only the trailing digit differs) and come back tied for first place. That is the correct, honest answer -- Lewis & Short's own spelling gives no way to pick one over the other -- not a bug: a caller that wants a single answer regardless needs its own disambiguation logic (surrounding context, part of speech, ...) layered on top of this.

Raises ValueError if lemma is blank (nothing to match against).

LEWIS_SHORT_URL = 'https://shot.holycross.edu/lexica/ls-articles.cex'
def read_lewis_short( path: str, delimiter: str = '|') -> List[LewisShortEntry]:
148def read_lewis_short(path: str, delimiter: str = "|") -> List[LewisShortEntry]:
149    """Read every row of `path` (the ls-articles.cex file, or anything
150    sharing its exact 4-column shape) into a flat list of LewisShortEntry,
151    in file order.
152
153    `delimiter` is the column separator, for both the header line and each
154    data row -- '|' by default, matching this file's own published format
155    and every other serialized format in this codebase. There is no
156    escaping mechanism for whichever character is chosen (same caveat
157    ctsdata.py's and serialization.py's own docstrings note): pick a
158    `delimiter` that can't appear in `entry`'s own text if '|' ever does.
159
160    See _parse_lewis_short_lines() for exactly what counts as malformed
161    (same validation either way) -- this function only adds the "read a
162    local file" half; read_lewis_short_from_url() is its "fetch over HTTP
163    instead" sibling, sharing that same validation.
164    """
165    with open(path, "r", encoding="utf-8") as f:
166        raw_lines = f.read().splitlines()
167
168    return _parse_lewis_short_lines(raw_lines, delimiter, source=path)

Read every row of path (the ls-articles.cex file, or anything sharing its exact 4-column shape) into a flat list of LewisShortEntry, in file order.

delimiter is the column separator, for both the header line and each data row -- '|' by default, matching this file's own published format and every other serialized format in this codebase. There is no escaping mechanism for whichever character is chosen (same caveat ctsdata.py's and serialization.py's own docstrings note): pick a delimiter that can't appear in entry's own text if '|' ever does.

See _parse_lewis_short_lines() for exactly what counts as malformed (same validation either way) -- this function only adds the "read a local file" half; read_lewis_short_from_url() is its "fetch over HTTP instead" sibling, sharing that same validation.

def read_lewis_short_from_url( url: str = 'https://shot.holycross.edu/lexica/ls-articles.cex', *, delimiter: str = '|', timeout: float = 60.0) -> List[LewisShortEntry]:
171def read_lewis_short_from_url(
172    url: str = LEWIS_SHORT_URL, *, delimiter: str = "|", timeout: float = 60.0
173) -> List[LewisShortEntry]:
174    """Fetch `url` (the published ls-articles.cex file at LEWIS_SHORT_URL
175    by default) over HTTP and parse it exactly like read_lewis_short()
176    does for a local file -- same validation, same ValueError conditions
177    (see _parse_lewis_short_lines()), naming `url` instead of a path when
178    something's wrong with the content.
179
180    The response body is decoded as UTF-8 (matching the published file's
181    own encoding -- verified directly, not assumed: Lewis & Short's
182    Greek quotations and other non-ASCII text round-trip correctly under
183    plain UTF-8 decoding). `timeout` is a socket timeout in seconds passed
184    straight to urllib.request.urlopen() -- 60s by default, generously
185    sized for the published file's real size (~28MB, 51,596 entries) over
186    an ordinary connection, not because the endpoint itself is typically
187    slow to respond.
188
189    Raises whatever urllib.request.urlopen() itself raises for a network
190    failure -- urllib.error.HTTPError for a non-2xx response,
191    urllib.error.URLError for anything that never got an HTTP response at
192    all (DNS failure, connection refused, timeout) -- deliberately NOT
193    caught or translated into some other exception type here, so a caller
194    already handling those two (or letting them propagate) doesn't need a
195    third, different exception type just for this one function.
196
197    I couldn't verify this function against the real endpoint myself: this
198    sandbox's own network access, and the linked device's, both got a 403
199    from their egress proxy trying to reach shot.holycross.edu directly
200    (see notes/lewis_short.md) -- so this is implemented and unit-tested
201    against a mocked HTTP response (tests/test_lewis_short.py), not run
202    end-to-end against the real file. Run its `network`-marked test
203    yourself once (`pytest -m network`) to confirm it actually reaches the
204    real file from a network that can.
205    """
206    with urllib.request.urlopen(url, timeout=timeout) as response:
207        raw_bytes = response.read()
208
209    raw_lines = raw_bytes.decode("utf-8").splitlines()
210    return _parse_lewis_short_lines(raw_lines, delimiter, source=url)

Fetch url (the published ls-articles.cex file at LEWIS_SHORT_URL by default) over HTTP and parse it exactly like read_lewis_short() does for a local file -- same validation, same ValueError conditions (see _parse_lewis_short_lines()), naming url instead of a path when something's wrong with the content.

The response body is decoded as UTF-8 (matching the published file's own encoding -- verified directly, not assumed: Lewis & Short's Greek quotations and other non-ASCII text round-trip correctly under plain UTF-8 decoding). timeout is a socket timeout in seconds passed straight to urllib.request.urlopen() -- 60s by default, generously sized for the published file's real size (~28MB, 51,596 entries) over an ordinary connection, not because the endpoint itself is typically slow to respond.

Raises whatever urllib.request.urlopen() itself raises for a network failure -- urllib.error.HTTPError for a non-2xx response, urllib.error.URLError for anything that never got an HTTP response at all (DNS failure, connection refused, timeout) -- deliberately NOT caught or translated into some other exception type here, so a caller already handling those two (or letting them propagate) doesn't need a third, different exception type just for this one function.

I couldn't verify this function against the real endpoint myself: this sandbox's own network access, and the linked device's, both got a 403 from their egress proxy trying to reach shot.holycross.edu directly (see notes/lewis_short.md) -- so this is implemented and unit-tested against a mocked HTTP response (tests/test_lewis_short.py), not run end-to-end against the real file. Run its network-marked test yourself once (pytest -m network) to confirm it actually reaches the real file from a network that can.

def attgraph( sentences: List[Sentence], results: list) -> Tuple[aat.core.graph.AATGraph, List[str]]:
179def attgraph(sentences: List[Sentence], results: list) -> Tuple[AATGraph, List[str]]:
180    """Build an `aat.core.AATGraph` from an already-completed
181    arsgrammatica analysis -- `sentences`/`results`, in the exact shape
182    `pipeline.analyze_sources()` (or `analyze_string()`) returns them:
183    `results[i]` is the SentenceAnalysis result (with its own `.tokengraph`
184    and `.verbalunits`) for `sentences[i]`, same order, one entry per
185    sentence.
186
187    Returns `(graph, warnings)` -- `warnings` follows this codebase's
188    usual "degrade visibly, don't raise" convention (see
189    `verbal_units.compute_subordination_depths()`): a sentence whose
190    tokens span more than one citation, or a verbal expression missing
191    its own `verbalunits` entry (shouldn't happen in well-formed output,
192    but this function doesn't assume it), is reported here rather than
193    raising. An empty list means nothing unusual was found -- not that
194    the underlying Latin analysis itself is correct; run the resulting
195    graph through `aat.core.validate.validate()` (with a matching
196    `CitableToken` list) for that.
197
198    Every action, agent, and target node this function can derive is
199    documented in this module's own docstring, above.
200    """
201    warnings: List[str] = []
202    nodes: List[AATNode] = []
203
204    for index, (sentence, result) in enumerate(zip(sentences, results)):
205        tokengraph = result.tokengraph
206        verbalunits = result.verbalunits
207
208        context = _sentence_context(sentence, warnings, index)
209        by_id = {tok.id: tok for tok in tokengraph}
210        order_index = {tok.id: i for i, tok in enumerate(tokengraph)}
211        anchor_ids = sorted(
212            (tok.id for tok in tokengraph if tok.verbalunitid == tok.id),
213            key=lambda tid: order_index.get(tid, -1),
214        )
215        vu_by_id = {vu.id: vu for vu in verbalunits}
216        governing = find_governing_verbal_expression(tokengraph)
217
218        for anchor_id in anchor_ids:
219            vu = vu_by_id.get(anchor_id)
220            if vu is None:
221                warnings.append(
222                    f"sentence {index + 1}: anchor {anchor_id!r} has no "
223                    "matching entry in verbalunits -- skipping its action "
224                    "(and any agent/target) node"
225                )
226                continue
227
228            component_ids = sorted(
229                set(_component_ids(anchor_id, tokengraph)),
230                key=lambda tid: order_index.get(tid, -1),
231            )
232            value = " ".join(token_label(by_id[tid]) for tid in component_ids)
233
234            nodes.append(
235                AATNode(
236                    context=context,
237                    id=anchor_id,
238                    value=value,
239                    role="action",
240                    related_node=governing.get(anchor_id),
241                )
242            )
243
244            semantic_type = vu.semantic_type
245            seen_role_token_ids: set = set()
246            for tok in tokengraph:
247                for related_field, label_field in (
248                    ("relatedtoken1", "relationship1"),
249                    ("relatedtoken2", "relationship2"),
250                ):
251                    label = getattr(tok, label_field)
252                    target_id = getattr(tok, related_field)
253                    if target_id != anchor_id:
254                        continue
255                    role = _role_for_relation(label, semantic_type)
256                    if role is None:
257                        continue
258
259                    if label == "agent":
260                        role_tok = _object_of_preposition(tok.id, tokengraph)
261                        if role_tok is None:
262                            warnings.append(
263                                f"sentence {index + 1}: {tok.id!r} relates to "
264                                f"{anchor_id!r} as 'agent' but has no 'object "
265                                "of preposition' dependent -- skipping its "
266                                "agent node"
267                            )
268                            continue
269                    else:
270                        role_tok = tok
271
272                    if role_tok.tokentype in IMPLIED_TOKENTYPES:
273                        continue
274                    if role_tok.id in seen_role_token_ids:
275                        continue
276                    seen_role_token_ids.add(role_tok.id)
277
278                    nodes.append(
279                        AATNode(
280                            context=context,
281                            id=role_tok.id,
282                            value=token_label(role_tok),
283                            role=role,
284                            related_node=anchor_id,
285                        )
286                    )
287
288    return AATGraph(nodes=nodes), warnings

Build an aat.core.AATGraph from an already-completed arsgrammatica analysis -- sentences/results, in the exact shape pipeline.analyze_sources() (or analyze_string()) returns them: results[i] is the SentenceAnalysis result (with its own .tokengraph and .verbalunits) for sentences[i], same order, one entry per sentence.

Returns (graph, warnings) -- warnings follows this codebase's usual "degrade visibly, don't raise" convention (see verbal_units.compute_subordination_depths()): a sentence whose tokens span more than one citation, or a verbal expression missing its own verbalunits entry (shouldn't happen in well-formed output, but this function doesn't assume it), is reported here rather than raising. An empty list means nothing unusual was found -- not that the underlying Latin analysis itself is correct; run the resulting graph through aat.core.validate.validate() (with a matching CitableToken list) for that.

Every action, agent, and target node this function can derive is documented in this module's own docstring, above.