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    filter_tokengraph_by_aat_depth,
 15    find_governing_verbal_expression,
 16    max_aat_depth,
 17    max_subordination_depth,
 18    find_unanchored_coordinated_verbs,
 19)
 20from .rendering import tokengraph_to_text, tokengraph_to_html, tokengraph_to_depth_html
 21from .displacy_viz import (
 22    tokengraph_to_displacy_data,
 23    tokengraph_to_displacy_svg,
 24    save_displacy_html,
 25)
 26# latin_syntax_dspy.py, segmentation_dspy.py, and pipeline.py all touch
 27# dspy at *class-definition* time (dspy.Signature subclasses), not just at
 28# call time, so -- unlike aatgraph()'s lazy import below -- there's no way
 29# to defer dspy out of these three modules' own imports. That makes dspy
 30# the one thing standing between "import arsgrammatica" and working in an
 31# environment that can't have dspy installed at all: current dspy releases
 32# hard-require litellm>=1.65.8, and current litellm releases ship native
 33# cp310-abi3 wheels only (a Rust extension), which blocks Pyodide/WASM
 34# environments outright -- see notes/wasm_export.md. None of that is
 35# specific to *this* package, though: nothing else in arsgrammatica
 36# (models.py, rendering.py, mermaid.py, dot.py, graphs.py, verbal_units.py,
 37# displacy_viz.py, serialization.py, ctsdata.py, lewis_short.py, ...) ever
 38# imports dspy, so a caller who only wants to read/render/serialize
 39# already-saved analyses -- exactly what marimo/latin_syntaxer_review.py
 40# does -- has no actual need for dspy at all. Following the same
 41# lazy-import-with-a-helpful-stub pattern aatgraph() already uses below for
 42# the optional `aat` package, this block lets `import arsgrammatica` (and
 43# now, per pyproject.toml's new `llm` extra, the base install itself)
 44# succeed either way: with dspy installed, every name below is the real
 45# thing; without it, calling any of them raises a clear ImportError naming
 46# `pip install 'arsgrammatica[llm]'` instead of failing this whole import
 47# with dspy's own, less legible ImportError.
 48try:
 49    from .latin_syntax_dspy import (
 50        SentenceAnalysis,
 51        analyze,
 52        validate,
 53        print_analysis,
 54    )
 55    from .segmentation_dspy import SegmentPassage, segment_sources
 56    from .pipeline import (
 57        analyze_string,
 58        analyze_sources,
 59        analyze_selected_passages,
 60        analyze_ctsdata,
 61        combined_tokengraph,
 62    )
 63except ImportError as _llm_exc:
 64    _llm_import_error = _llm_exc
 65
 66    def _llm_stub(_name):
 67        def _stub(*_args, **_kwargs):
 68            raise ImportError(
 69                f"{_name}() needs the optional 'llm' extra (dspy, and a "
 70                "configured LM) to analyze new Latin text -- install it "
 71                "with: pip install 'arsgrammatica[llm]'. Reading, "
 72                "rendering, or serializing an already-saved analysis "
 73                "(read_analyses(), tokengraph_to_html(), etc.) doesn't "
 74                "need dspy at all and works without this extra."
 75            ) from _llm_import_error
 76
 77        _stub.__name__ = _name
 78        return _stub
 79
 80    SentenceAnalysis = _llm_stub("SentenceAnalysis")
 81    analyze = _llm_stub("analyze")
 82    validate = _llm_stub("validate")
 83    print_analysis = _llm_stub("print_analysis")
 84    SegmentPassage = _llm_stub("SegmentPassage")
 85    segment_sources = _llm_stub("segment_sources")
 86    analyze_string = _llm_stub("analyze_string")
 87    analyze_sources = _llm_stub("analyze_sources")
 88    analyze_selected_passages = _llm_stub("analyze_selected_passages")
 89    analyze_ctsdata = _llm_stub("analyze_ctsdata")
 90    combined_tokengraph = _llm_stub("combined_tokengraph")
 91from .passage_grouping import group_passages_by_sentence_boundary
 92from .token_ids import assign_passage_scoped_ids
 93from .serialization import (
 94    LMInfo,
 95    serialize_analyses,
 96    write_analyses,
 97    read_analyses,
 98    split_analysis_by_sentence,
 99)
100from .ctsdata import read_ctsdata
101from .segmentation_serialization import (
102    serialize_segmentation,
103    write_segmentation,
104    read_segmentation,
105)
106from .token_budget import estimate_max_tokens, analyze_with_retry, get_calibration, DEFAULT_CEILING
107from .lm_cost import LMCostSummary, summarize_lm_cost, format_lm_cost
108from .run_report import FailedPassage, format_warnings_report, write_warnings_report
109from .lewis_short import (
110    LewisShortEntry,
111    LewisShortMatch,
112    LewisShortLexicon,
113    LEWIS_SHORT_URL,
114    read_lewis_short,
115    read_lewis_short_from_url,
116)
117
118# aatgraph() depends on the separate `aat` package, which most callers of
119# arsgrammatica have no need to install at all -- not on PyPI, so
120# `pip install git+https://github.com/neelsmith/aat.git` (not a bare
121# `pip install aat`) is what actually installs it; pyproject.toml's own
122# "aat" extra, or its `dev` extra (which includes "aat" too, since
123# marimo/latin_syntaxer_review.py -- itself only reachable via `dev` --
124# is the one thing in this repo that actually calls aatgraph()), are only
125# reachable if arsgrammatica itself is pip-installed (`pip install
126# '.[dev]'` from a checkout, or an editable install) rather than just run
127# from a checkout on sys.path, which is how this project is normally used
128# -- see notes/install.md's "Dev-only tools" for installing `aat` by hand
129# in that case instead. Importing it lazily/defensively here, rather than
130# unconditionally like every other submodule above, means `import
131# arsgrammatica` still succeeds without `aat` installed; only actually
132# calling `arsgrammatica.aatgraph(...)` without it raises, with a message
133# naming the missing package and how to get it.
134try:
135    from .aat_bridge import aatgraph
136except ImportError as _exc:  # pragma: no cover -- exercised only when `aat` isn't installed
137    # `except ... as name` implicitly deletes `name` once this block ends
138    # (a Python gotcha, not specific to this code) -- reassign to a plain
139    # variable first so aatgraph(), called later, can still reference it.
140    _aat_import_error = _exc
141
142    def aatgraph(*args, **kwargs):
143        raise ImportError(
144            "aatgraph() needs the separate 'aat' package "
145            "(https://github.com/neelsmith/aat), which isn't installed. "
146            "Install it with: pip install git+https://github.com/"
147            "neelsmith/aat.git -- (if you've also `pip install`ed "
148            "arsgrammatica itself, rather than just running it from a "
149            "checkout, `pip install '.[dev]'` from its own directory "
150            "does the same thing, along with the rest of this repo's dev "
151            "tooling -- see notes/install.md)."
152        ) from _aat_import_error
153
154__all__ = [
155    "Token",
156    "CitedText",
157    "Sentence",
158    "VerbalExpression",
159    "TokenAnalysis",
160    "RelationLabel",
161    "tokengraph_to_mermaid",
162    "token_label",
163    "save_mermaid",
164    "tokengraph_to_dot",
165    "compute_graph_depths",
166    "max_graph_depth",
167    "save_dot",
168    "GraphMetrics",
169    "tokengraph_to_networkx",
170    "graph_metrics",
171    "assign_verbal_units",
172    "assign_verbal_unit_colors",
173    "compute_aat_depths",
174    "compute_subordination_depths",
175    "filter_tokengraph_by_aat_depth",
176    "find_governing_verbal_expression",
177    "max_aat_depth",
178    "max_subordination_depth",
179    "find_unanchored_coordinated_verbs",
180    "tokengraph_to_text",
181    "tokengraph_to_html",
182    "tokengraph_to_depth_html",
183    "tokengraph_to_displacy_data",
184    "tokengraph_to_displacy_svg",
185    "save_displacy_html",
186    "SentenceAnalysis",
187    "analyze",
188    "analyze_string",
189    "validate",
190    "print_analysis",
191    "SegmentPassage",
192    "segment_sources",
193    "analyze_sources",
194    "analyze_selected_passages",
195    "analyze_ctsdata",
196    "combined_tokengraph",
197    "group_passages_by_sentence_boundary",
198    "assign_passage_scoped_ids",
199    "LMInfo",
200    "serialize_analyses",
201    "write_analyses",
202    "read_analyses",
203    "split_analysis_by_sentence",
204    "read_ctsdata",
205    "serialize_segmentation",
206    "write_segmentation",
207    "read_segmentation",
208    "estimate_max_tokens",
209    "analyze_with_retry",
210    "get_calibration",
211    "DEFAULT_CEILING",
212    "LMCostSummary",
213    "summarize_lm_cost",
214    "format_lm_cost",
215    "FailedPassage",
216    "format_warnings_report",
217    "write_warnings_report",
218    "LewisShortEntry",
219    "LewisShortMatch",
220    "LewisShortLexicon",
221    "LEWIS_SHORT_URL",
222    "read_lewis_short",
223    "read_lewis_short_from_url",
224    "aatgraph",
225]
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, unique across the whole input, e.g. 't0', 't1', .... As produced directly by segment_sources(), sequential and unique only WITHIN that one call; a caller that combines several such calls (or wants a given passage to keep the same ids across separate runs) should rewrite these first -- see token_ids.assign_passage_scoped_ids().")
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, unique across the whole input, e.g. 't0', 't1', .... As produced directly by segment_sources(), sequential and unique only WITHIN that one call; a caller that combines several such calls (or wants a given passage to keep the same ids across separate runs) should rewrite these first -- see token_ids.assign_passage_scoped_ids().

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). Ids are global
51    across whatever ONE segment_sources() call produced this Sentence --
52    numbering continues across sentence boundaries within that call rather
53    than restarting at t0 for each sentence -- so a freshly-segmented
54    Sentence is a contiguous slice of that call's own id sequence, not an
55    independently-numbered unit. A Sentence handed back by
56    pipeline.analyze_sources() (or read back from a file
57    tokenize_ctsdata.py wrote) may instead carry passage-scoped composite
58    ids (token_ids.assign_passage_scoped_ids()) in place of that raw
59    per-call numbering -- see that module's own docstring."""
60 
61    tokens: List[Token] = Field(
62        description="This sentence's tokens, in reading order, using the passage's global token ids."
63    )

One sentence's worth of tokens, in reading order, as produced by the LLM-driven segmentation stage (segmentation_dspy.py). Ids are global across whatever ONE segment_sources() call produced this Sentence -- numbering continues across sentence boundaries within that call rather than restarting at t0 for each sentence -- so a freshly-segmented Sentence is a contiguous slice of that call's own id sequence, not an independently-numbered unit. A Sentence handed back by pipeline.analyze_sources() (or read back from a file tokenize_ctsdata.py wrote) may instead carry passage-scoped composite ids (token_ids.assign_passage_scoped_ids()) in place of that raw per-call numbering -- see that module's own docstring.

tokens: List[Token] = PydanticUndefined

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

class VerbalExpression(pydantic.main.BaseModel):
 66class VerbalExpression(BaseModel):
 67    """One entry in the table of verbal expressions (syntax_model.md, 'Table
 68    of verbal expressions'). Three constructions count as a verbal
 69    expression: finite verbs, infinitives (when part of indirect speech),
 70    and participles (when they have a *predicate* sense -- e.g. an
 71    ablative-absolute-like "Anco regnante...", 'while Ancus was reigning'
 72    -- rather than a purely *attributive* sense, like an ordinary adjective,
 73    e.g. "consentiens laus", 'universal praise': that case is NOT a verbal
 74    expression at all).
 75 
 76    Each construction has its own set of allowed `syntactic_type` values:
 77    a finite verb is 'independent', 'dependent', 'direct quote' (occurring
 78    in directly quoted speech, e.g. "est" in `"Tuum est," inquit,`), or
 79    'aside' (a verbal expression that interrupts the surrounding syntax,
 80    e.g. "dixerim" in "pace dixerim deum"); an infinitive anchoring an
 81    indirect statement is always 'indirect statement'. syntax_model.md
 82    doesn't specify a dedicated syntactic_type value for participles --
 83    this codebase's convention is to use 'dependent' for a predicate
 84    participle's verbal expression, since a circumstantial participle /
 85    ablative absolute functions like a subordinate clause; flag it if you
 86    intended something else."""
 87 
 88    id: str = Field(
 89        description=(
 90            "The token id (from the input `tokens` list) of the finite verb, "
 91            "infinitive, or predicate-sense participle that anchors this "
 92            "verbal expression. For a compound perfect/pluperfect passive or "
 93            "future-infinitive form (participle + a form of *sum*), use the id "
 94            "of the participle or infinitive itself, NOT the auxiliary form of "
 95            "*sum* (which instead relates to it via the 'auxiliary' relation). "
 96            "For an implied/elided verbal expression (see "
 97            "TokenAnalysis's 'implied sum'/'continued discourse' tokentypes, "
 98            "IMPLIED_TOKENTYPES), use the new implied token's id instead. "
 99            "(A third IMPLIED_TOKENTYPES value, 'implied subject', is NOT "
100            "a verbal expression and never gets an entry here -- see "
101            "TokenAnalysis's own docstring.)"
102        )
103    )
104    syntactic_type: Literal[
105        "independent", "dependent", "direct quote", "aside", "indirect statement"
106    ] = Field(
107        description=(
108            "For a finite verb: 'independent' (main/principal), 'dependent' "
109            "(subordinate/secondary), 'direct quote' (occurring in directly "
110            "quoted speech), or 'aside' (interrupts the surrounding syntax). "
111            "For an infinitive anchoring an indirect statement: 'indirect "
112            "statement'. For a predicate-sense participle: 'dependent' (this "
113            "codebase's convention; syntax_model.md doesn't specify)."
114        )
115    )
116    semantic_type: Literal[
117        "transitive active", "transitive passive", "intransitive", "linking verb"
118    ] = 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):
246class TokenAnalysis(BaseModel):
247    """One entry per token in the dependency graph (syntax_model.md,
248    'Token-level table of dependencies'). Per syntax_model.md's 'Incomplete
249    status' section, not every token will have a relation -- leave the
250    relatedtoken*/relationship* fields unset when none of the documented
251    relations apply.
252
253    Most entries correspond 1:1 to an entry in the input `tokens` list. The
254    exceptions are the three IMPLIED_TOKENTYPES values below, which fall
255    into two DIFFERENT groups. syntax_model.md's 'understood or implied
256    verbal expressions' section documents two situations where a VERBAL
257    EXPRESSION exists grammatically but has no surface realization at all
258    in the passage, and this codebase distinguishes them with two distinct
259    tokentype values rather than one generic 'implied':
260
261    - 'implied sum': an elided form of *sum* -- syntax_model.md's three
262      elided-sum sub-cases (a bare predicate construction, a compound
263      perfect passive/future infinitive missing its auxiliary, or the
264      always-implied present participle of *sum*, which doesn't exist in
265      Latin at all) all use this one value.
266    - 'continued discourse': a governing verb of indirect discourse left
267      unrepeated across several continuation clauses.
268
269    'continued discourse' always anchors its own entry in `verbalunits`,
270    same as any other verbal expression -- and so do two of 'implied
271    sum's own three sub-cases (the bare predicate construction, and the
272    always-implied present participle of *sum*). The THIRD 'implied sum'
273    sub-case -- a compound perfect passive/future infinitive with its
274    auxiliary omitted (e.g. 'facti' for 'facti sunt') -- is the one
275    exception: there, a real, already-present participle/infinitive
276    anchors the verbal expression instead (per VerbalExpression.id's own
277    docstring), and the implied token merely relates to IT via
278    'auxiliary', exactly as a written-out auxiliary would; it gets no
279    `verbalunits` entry of its own in that sub-case. The third
280    IMPLIED_TOKENTYPES value is a DIFFERENT kind of gap entirely -- not a
281    missing verb, but a missing NOUN or pronoun:
282
283    - 'implied subject': a participle's own antecedent (the noun/pronoun
284      it agrees with via 'circumstantial participle') can itself be
285      unexpressed, most often when the participle agrees with a governing
286      verb's own unexpressed subject. This token never anchors a
287      `verbalunits` entry -- it isn't a verbal expression at all -- and
288      instead takes a normal 'subject' relation into the verb whose
289      subject it stands in for, exactly as if that subject had been
290      written out; the participle then relates to THIS token via
291      'circumstantial participle', same as it would to any real noun. See
292      latin_syntax_dspy.SentenceAnalysis's docstring for the worked example.
293
294    For any of the three, add a NEW entry here -- with a NEW id, not
295    present in `tokens` -- rather than skipping the construction entirely;
296    see latin_syntax_dspy.SentenceAnalysis's docstring for the full rules and
297    the id-naming convention."""
298
299    id: str = Field(
300        description=(
301            "For an ordinary entry, must match the id of the corresponding "
302            "entry in the input `tokens` list. For an implied token "
303            "(tokentype in IMPLIED_TOKENTYPES -- 'implied sum', "
304            "'continued discourse', or 'implied subject'), a NEW id not "
305            "used by any entry in `tokens` or elsewhere in this "
306            "tokengraph -- see SentenceAnalysis's docstring for the naming "
307            "convention."
308        )
309    )
310    token: Optional[str] = Field(
311        default=None,
312        description=(
313            "The token's surface text; should match the `text` of the input "
314            "token with this id. Leave as None ONLY for an implied token "
315            "(tokentype 'implied sum', 'continued discourse', or 'implied "
316            "subject') -- one with no surface realization in the passage at "
317            "all; every other tokentype must have real text."
318        ),
319    )
320    tokentype: Literal[
321        "lexical", "enclitic", "punctuation", "numeral", "praenomen", "abbreviation",
322        "implied sum", "continued discourse", "implied subject",
323    ] = Field(
324        description=(
325            "'numeral' is a number written NUMERICALLY -- Roman (e.g. "
326            "'XXV') or Arabic -- rather than spelled out as a word; a "
327            "number spelled out as an ordinary word (e.g. 'decem' for "
328            "'ten') is 'lexical' instead, even though it's semantically a "
329            "number -- e.g. in 'fratres Joseph decem', all three tokens "
330            "are 'lexical', not 'numeral'. "
331            "'praenomen' is specifically an abbreviated Roman first name "
332            "(e.g. 'M.' for Marcus), including its period; 'abbreviation' is "
333            "any other abbreviation, including its period (e.g. 'f.' for "
334            "filius, 'cos.' for consul) -- syntax_model.md's tokenization "
335            "section documents these as two distinct token types, not one. "
336            "'implied sum', 'continued discourse', and 'implied subject' "
337            "each mark a token with NO surface realization at all (see "
338            "this model's own docstring for the distinction) -- the only "
339            "three tokentypes whose `token` field is None and whose `id` "
340            "is not one of the input `tokens`' own ids. 'continued discourse' "
341            "always anchors its own verbal expression (an entry in "
342            "`verbalunits`), and so does 'implied sum' in two of its three "
343            "sub-cases -- EXCEPT the omitted-auxiliary sub-case (e.g. 'facti' "
344            "for 'facti sunt'), where the real, already-present participle "
345            "anchors instead and this implied token only relates to it via "
346            "'auxiliary' (see this model's own docstring). 'implied subject' "
347            "never anchors a verbal expression -- it stands in for an "
348            "unexpressed NOUN or pronoun, not a verb."
349        )
350    )
351 
352    lemma: Optional[str] = Field(default=None, description="Dictionary headword, for lexical tokens. Omit for punctuation.")
353    verbalunitid: Optional[str] = Field(
354        default=None,
355        description="If this token anchors a verbal expression in `verbalunits`, repeat its own id here; otherwise omit.",
356    )
357 
358    relatedtoken1: Optional[str] = Field(
359        default=None,
360        description=(
361            "Token id this token relates to (primary relation). For an "
362            "INDEPENDENT verb's own 'unit verb' relation, use the special "
363            "sentinel string 'root' instead of a token id -- 'root' is "
364            "reserved and must never be assigned as an actual token's id."
365        ),
366    )
367    relationship1: Optional[RelationLabel] = Field(default=None, description="The primary relation type, if any.")
368 
369    relatedtoken2: Optional[str] = Field(default=None, description="Token id this token relates to (secondary relation, used when relation1 is already occupied).")
370    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, aat_depth: int | None = None, show_root: bool = True) -> Tuple[str, List[str]]:
140def tokengraph_to_mermaid(
141    tokengraph: List[TokenAnalysis],
142    orientation: str = "BT",
143    color_by_verbal_unit: bool = True,
144    rank_by_depth: bool = True,
145    aat_depth: Optional[int] = None,
146    show_root: bool = True,
147) -> Tuple[str, List[str]]:
148    """Build a Mermaid `graph` diagram from a tokengraph.
149
150    `orientation` is Mermaid's own flowchart orientation code -- `BT`
151    (bottom-to-top, the default here), `TB`, `LR`, or `RL` -- used verbatim
152    in the diagram's opening line (`graph BT`, `graph LR`, etc.). See
153    https://mermaid.js.org/syntax/flowchart.html for what each value looks
154    like; this function doesn't validate it, so a typo just becomes invalid
155    Mermaid syntax in the output rather than an error here.
156
157    `color_by_verbal_unit` (default True) colors every node by the verbal
158    unit it belongs to, per verbal_units.assign_verbal_units() -- so each
159    clause is visually distinguishable. Verbal units are assigned colors
160    from `_VERBAL_UNIT_PALETTE` in the order their tokens first appear in
161    `tokengraph`; a token assigned to no verbal unit is left with Mermaid's
162    default node styling. The one exception is an implied/elided token
163    (models.py's IMPLIED_TOKENTYPES) -- it always gets its own dedicated
164    `implied` class, colored with `verbal_units._IMPLIED_TOKEN_COLOR`,
165    instead of whatever `_VERBAL_UNIT_PALETTE` color its own verbal unit
166    would otherwise get (see this module's own docstring for why). Pass
167    False to skip coloring and get a plain diagram, as before this
168    parameter existed.
169
170    `rank_by_depth` (default True) makes the diagram's layout respect each
171    verbal expression's own depth in the `aat` package's Agent-Action-
172    Target model (see verbal_units.compute_aat_depths()) -- the same depth
173    aat_bridge.aatgraph() would give that verbal expression's own AAT
174    action node, walking related_node chains to an independent (depth 0)
175    action: every verbal-unit anchor node (any token with `verbalunitid`
176    set to its own id, implied tokens included) at the SAME depth gets
177    chained together with Mermaid's invisible-link syntax (`~~~`), e.g.
178    `t1 ~~~ t6 ~~~ t9` for three anchors all at depth 2. This draws no
179    visible edge and adds no relation of its own -- it only nudges
180    Mermaid's layout engine to keep same-depth verbal expressions level
181    with each other, the same way independent clauses, the dependent
182    clauses one level below them, and so on, end up visually aligned by
183    rank rather than scattered -- and lines this diagram's layout up with
184    an AAT graph of the same sentence (see compute_aat_depths()'s own
185    docstring for exactly how its numbers relate to arsgrammatica's own,
186    richer subordination-depth notion, which this does NOT use). A depth
187    with only one anchor gets no chain at all (nothing to link); unlike
188    the old depth-of-subordination ranking, no anchor is ever excluded for
189    having an unresolved depth -- compute_aat_depths() never leaves one
190    unresolved (see its own docstring for the one malformed-input case,
191    a relation cycle, where that means an arbitrary rather than a
192    meaningful depth, rather than an excluded one). Pass False to skip
193    this and get the diagram's previous, unranked layout.
194
195    `aat_depth`, if given, caps the diagram to nodes belonging to a verbal
196    expression at or within that AAT depth (verbal_units.
197    compute_aat_depths() again -- the SAME notion `rank_by_depth` above
198    uses to align nodes, now used to drop them instead). Every token takes
199    the AAT depth of the verbal unit it belongs to (verbal_units.
200    assign_verbal_units() -- so a whole clause's subject, object, and
201    other ordinary dependents share ONE depth with their own governing
202    verb, exactly the way rendering.tokengraph_to_depth_html()'s own
203    `depth` groups a clause's tokens by verbal_units.compute_
204    subordination_depths()); a token belonging to no verbal unit at all
205    defaults to depth 0 (kept), the same "can't determine, default to root
206    level" fallback used throughout this codebase. This is a DIFFERENT
207    depth notion from dot.py's tokengraph_to_dot(depth=...) filter (a
208    plain graph distance in edges, where each dependent is its own hop
209    deeper than its governor -- see dot.py's own module docstring for the
210    full three-way distinction); `aat_depth=0` shows only root/independent
211    clauses in full (verb plus all of that clause's own dependents), not
212    just root verbs on their own the way tokengraph_to_dot()'s `depth=0`
213    does. `aat_depth=1` adds every verbal expression one AAT-hierarchy
214    level down (and all of ITS clause's own dependents), and so on. A
215    dropped node's own edges (and any kept node's edge that happened to
216    point at it) are skipped, with a warning -- see Returns below -- same
217    "degrade visibly" convention as an edge to punctuation or a missing
218    id. Omit `aat_depth` (or pass `None`, the default) to show every node,
219    same as before this parameter existed; a value at or beyond
220    verbal_units.max_aat_depth()'s own return value for this `tokengraph`
221    shows everything too; a negative `aat_depth` raises `ValueError`.
222    `aat_depth` filtering composes freely with `rank_by_depth` and
223    `color_by_verbal_unit` -- a dropped node is simply never considered by
224    either.
225
226    `show_root` (default True) draws every independent verbal expression's
227    own 'root' relation (relatedtoken1 == 'root', relationship1 == 'unit
228    verb', per syntax_model.md) as a real edge into a single dedicated
229    'root' node, instead of dropping it -- a plain oval (Mermaid's stadium
230    `([...])` shape) labelled "root", deliberately left uncolored/unfilled
231    regardless of `color_by_verbal_unit` (it's never assigned to any
232    verbal unit's classDef/class, so it always keeps Mermaid's own default,
233    unfilled node styling). The node is added only if at least one
234    surviving token actually has a 'root' edge to draw (so an empty or
235    fully-filtered-out diagram never gets an orphan 'root' node), and 'root'
236    itself is exempt from every depth filter above -- an independent verb's
237    anchor is always at depth 0 in every depth notion this codebase uses,
238    so it's never excluded by `aat_depth` on its own account. Pass
239    `show_root=False` to skip the node and its edges entirely, exactly
240    reproducing this function's behavior before this parameter existed.
241
242    Returns (diagram_text, warnings). `warnings` lists any edges that were
243    skipped because they referenced a punctuation token, a node excluded by
244    the `aat_depth` cutoff, or an id not present in `tokengraph` -- worth
245    checking, since a punctuation/missing-id gap usually means the id came
246    from a validation problem upstream (see latin_syntax_dspy.validate) --
247    plus, if `color_by_verbal_unit` is True and the passage has more than 8
248    verbal units, one warning that colors are repeating rather than staying
249    distinct (the palette has 8 slots; see _VERBAL_UNIT_PALETTE).
250    `rank_by_depth` itself never adds a warning (see compute_aat_depths()),
251    and neither does `aat_depth` filtering (same reason: no anchor's AAT
252    depth is ever unresolved).
253    """
254    if aat_depth is not None and aat_depth < 0:
255        raise ValueError(f"aat_depth must be >= 0 (root clauses only), got {aat_depth!r}")
256
257    node_ids = {tok.id for tok in tokengraph if tok.tokentype != "punctuation"}
258
259    if aat_depth is not None:
260        depth_assignment = assign_verbal_units(tokengraph)
261        aat_depths_by_anchor = compute_aat_depths(tokengraph)
262        aat_depth_excluded_ids = {
263            tok.id
264            for tok in tokengraph
265            if tok.id in node_ids
266            and (
267                aat_depths_by_anchor.get(depth_assignment.get(tok.id), 0)
268                if depth_assignment.get(tok.id) is not None
269                else 0
270            )
271            > aat_depth
272        }
273        node_ids -= aat_depth_excluded_ids
274
275    lines = [f"graph {orientation}"]
276    for tok in tokengraph:
277        if tok.id not in node_ids:
278            continue
279        # An implied/elided token (see models.py's IMPLIED_TOKENTYPES) has
280        # no surface text at all -- tok.token is None -- so it needs a
281        # placeholder label rather than crashing _escape_label() on None;
282        # token_label() supplies that. The node's color (below) is what
283        # actually marks it as an implied token, not the label text.
284        label = token_label(tok)
285        # An implied/elided token (models.py's IMPLIED_TOKENTYPES) gets a
286        # rounded-corner rectangle -- Mermaid's `(...)` node shape -- instead
287        # of the plain `[...]` rectangle every other node uses, as a second,
288        # shape-based signal (on top of the dedicated amber color below)
289        # that this node stands in for a word that isn't actually there.
290        open_bracket, close_bracket = (
291            ("(", ")") if tok.tokentype in IMPLIED_TOKENTYPES else ("[", "]")
292        )
293        lines.append(f'    {tok.id}{open_bracket}"{_escape_label(label)}"{close_bracket}')
294
295    # The dedicated 'root' node every independent verb's own 'root' edge
296    # points to (see this module's own docstring) -- added only if at
297    # least one surviving token actually has such an edge to draw, so an
298    # empty or fully-filtered diagram never gets an orphan 'root' node.
299    # Mermaid's stadium shape (`([...])`) for a plain oval look; no
300    # classDef/class is ever applied to it below, so it always keeps
301    # Mermaid's own default, unfilled node styling regardless of
302    # `color_by_verbal_unit`.
303    has_root_edge = show_root and any(
304        tok.relatedtoken1 == "root" for tok in tokengraph if tok.id in node_ids
305    )
306    if has_root_edge:
307        lines.append('    root(["root"])')
308
309    warnings = []
310    for tok in tokengraph:
311        if tok.id not in node_ids:
312            continue
313        for related_field, label_field in (
314            ("relatedtoken1", "relationship1"),
315            ("relatedtoken2", "relationship2"),
316        ):
317            related_id = getattr(tok, related_field)
318            label = getattr(tok, label_field)
319            if related_id is None or label is None:
320                continue
321            if related_id == "root":
322                # An independent verb's own unit-verb relation, per
323                # syntax_model.md -- never a warning-worthy gap either way.
324                # When show_root is True (the default), draw the edge into
325                # the single dedicated 'root' node built above; when False,
326                # skip it silently, exactly as before this parameter
327                # existed.
328                if show_root:
329                    lines.append(f'    {tok.id} -->|{_escape_label(label)}| root')
330                continue
331            if related_id not in node_ids:
332                warnings.append(
333                    f"skipped edge {tok.id} -[{label}]-> {related_id}: "
334                    f"target is punctuation, excluded by the aat_depth "
335                    f"cutoff, or not in tokengraph"
336                )
337                continue
338            lines.append(f'    {tok.id} -->|{_escape_label(label)}| {related_id}')
339
340    if rank_by_depth:
341        # Named aat_depths (not `depths`) to avoid any confusion with the
342        # `aat_depth` FILTERING parameter above -- a different use of the
343        # same underlying compute_aat_depths() numbers (grouping for
344        # ranking here, dropping nodes there), see dot.py's own
345        # analogous rank_by_depth block for the same naming convention.
346        aat_depths = compute_aat_depths(tokengraph)
347
348        # Group every verbal-unit anchor node still in the diagram by its
349        # own AAT-graph depth, preserving tokengraph's own (first-
350        # appearance) order within each group. Unlike the old depth-of-
351        # subordination ranking, compute_aat_depths() never leaves an
352        # anchor's depth unresolved -- aat_depths.get() is None here only
353        # for a non-anchor token (it only ever keys its result by anchor
354        # id), so this `is None` check is purely "is this token an anchor
355        # at all", not "did its depth fail to resolve".
356        depth_groups: dict = {}
357        for tok in tokengraph:
358            if tok.id not in node_ids:
359                continue
360            depth = aat_depths.get(tok.id)
361            if depth is None:
362                continue
363            depth_groups.setdefault(depth, []).append(tok.id)
364
365        rank_lines = [
366            "    " + " ~~~ ".join(ids)
367            for depth in sorted(depth_groups)
368            for ids in (depth_groups[depth],)
369            if len(ids) > 1
370        ]
371        if rank_lines:
372            lines.append("")
373            lines.extend(rank_lines)
374
375    if color_by_verbal_unit:
376        assignment = assign_verbal_units(tokengraph)
377        colors, color_warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment)
378        warnings.extend(color_warnings)
379
380        # Implied tokens (models.py's IMPLIED_TOKENTYPES) always get a
381        # dedicated "caution" amber (_IMPLIED_TOKEN_COLOR) instead of
382        # whatever color their own verbal unit would otherwise get --
383        # regardless of which unit they anchor -- so they're excluded from
384        # every per-unit `member_ids` group below and given their own
385        # classDef/class pair instead. See rendering.py's
386        # tokengraph_to_html() docstring for the matching HTML behavior.
387        implied_ids = [
388            tok.id
389            for tok in tokengraph
390            if tok.id in node_ids and tok.tokentype in IMPLIED_TOKENTYPES
391        ]
392
393        if colors or implied_ids:
394            lines.append("")
395            class_names = {}
396            for i, (unit_id, (fill, stroke, text)) in enumerate(colors.items()):
397                class_name = f"vu{i}"
398                class_names[unit_id] = class_name
399                lines.append(
400                    f"    classDef {class_name} fill:{fill},stroke:{stroke},color:{text};"
401                )
402            for unit_id in colors:
403                member_ids = [
404                    tok.id
405                    for tok in tokengraph
406                    if tok.id in node_ids
407                    and assignment.get(tok.id) == unit_id
408                    and tok.id not in implied_ids
409                ]
410                if member_ids:
411                    lines.append(f"    class {','.join(member_ids)} {class_names[unit_id]};")
412            if implied_ids:
413                fill, stroke, text = _IMPLIED_TOKEN_COLOR
414                lines.append(
415                    f"    classDef implied fill:{fill},stroke:{stroke},color:{text};"
416                )
417                lines.append(f"    class {','.join(implied_ids)} implied;")
418 
419    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.aatgraph() 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.

aat_depth, if given, caps the diagram to nodes belonging to a verbal expression at or within that AAT depth (verbal_units. compute_aat_depths() again -- the SAME notion rank_by_depth above uses to align nodes, now used to drop them instead). Every token takes the AAT depth of the verbal unit it belongs to (verbal_units. assign_verbal_units() -- so a whole clause's subject, object, and other ordinary dependents share ONE depth with their own governing verb, exactly the way rendering.tokengraph_to_depth_html()'s own depth groups a clause's tokens by verbal_units.compute_ subordination_depths()); a token belonging to no verbal unit at all defaults to depth 0 (kept), the same "can't determine, default to root level" fallback used throughout this codebase. This is a DIFFERENT depth notion from dot.py's tokengraph_to_dot(depth=...) filter (a plain graph distance in edges, where each dependent is its own hop deeper than its governor -- see dot.py's own module docstring for the full three-way distinction); aat_depth=0 shows only root/independent clauses in full (verb plus all of that clause's own dependents), not just root verbs on their own the way tokengraph_to_dot()'s depth=0 does. aat_depth=1 adds every verbal expression one AAT-hierarchy level down (and all of ITS clause's own dependents), and so on. A dropped node's own edges (and any kept node's edge that happened to point at it) are skipped, with a warning -- see Returns below -- same "degrade visibly" convention as an edge to punctuation or a missing id. Omit aat_depth (or pass None, the default) to show every node, same as before this parameter existed; a value at or beyond verbal_units.max_aat_depth()'s own return value for this tokengraph shows everything too; a negative aat_depth raises ValueError. aat_depth filtering composes freely with rank_by_depth and color_by_verbal_unit -- a dropped node is simply never considered by either.

show_root (default True) draws every independent verbal expression's own 'root' relation (relatedtoken1 == 'root', relationship1 == 'unit verb', per syntax_model.md) as a real edge into a single dedicated 'root' node, instead of dropping it -- a plain oval (Mermaid's stadium ([...]) shape) labelled "root", deliberately left uncolored/unfilled regardless of color_by_verbal_unit (it's never assigned to any verbal unit's classDef/class, so it always keeps Mermaid's own default, unfilled node styling). The node is added only if at least one surviving token actually has a 'root' edge to draw (so an empty or fully-filtered-out diagram never gets an orphan 'root' node), and 'root' itself is exempt from every depth filter above -- an independent verb's anchor is always at depth 0 in every depth notion this codebase uses, so it's never excluded by aat_depth on its own account. Pass show_root=False to skip the node and its edges entirely, exactly reproducing this function's behavior before this parameter existed.

Returns (diagram_text, warnings). warnings lists any edges that were skipped because they referenced a punctuation token, a node excluded by the aat_depth cutoff, or an id not present in tokengraph -- worth checking, since a punctuation/missing-id gap 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()), and neither does aat_depth filtering (same reason: no anchor's AAT depth is ever unresolved).

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

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, aat_depth: int | None = None, show_root: bool = True) -> List[str]:
422def save_mermaid(
423    tokengraph: List[TokenAnalysis],
424    path: str,
425    orientation: str = "BT",
426    color_by_verbal_unit: bool = True,
427    rank_by_depth: bool = True,
428    aat_depth: Optional[int] = None,
429    show_root: bool = True,
430) -> List[str]:
431    """Write the diagram to `path` (e.g. 'analysis.mmd') and return any
432    warnings from tokengraph_to_mermaid."""
433    diagram, warnings = tokengraph_to_mermaid(
434        tokengraph,
435        orientation=orientation,
436        color_by_verbal_unit=color_by_verbal_unit,
437        rank_by_depth=rank_by_depth,
438        aat_depth=aat_depth,
439        show_root=show_root,
440    )
441    with open(path, "w", encoding="utf-8") as f:
442        f.write(diagram + "\n")
443    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, aat_depth: int | None = None, show_root: bool = True) -> Tuple[str, List[str]]:
250def tokengraph_to_dot(
251    tokengraph: List[TokenAnalysis],
252    orientation: str = "BT",
253    color_by_verbal_unit: bool = True,
254    rank_by_depth: bool = True,
255    depth: Optional[int] = None,
256    aat_depth: Optional[int] = None,
257    show_root: bool = True,
258) -> Tuple[str, List[str]]:
259    """Build a Graphviz DOT `digraph` from a tokengraph -- the same
260    diagram tokengraph_to_mermaid() draws (same nodes, same edges, same
261    coloring), as DOT source instead of Mermaid source. See this module's
262    own docstring for why this exists alongside tokengraph_to_mermaid()
263    and what actually rendering the result requires.
264
265    `orientation` maps directly onto DOT's `rankdir` graph attribute --
266    `BT` (bottom-to-top, the default here, matching
267    tokengraph_to_mermaid()'s own default), `TB`, `LR`, or `RL`. Not
268    validated here, same as tokengraph_to_mermaid()'s `orientation` --  a
269    typo just becomes an attribute value Graphviz itself will reject.
270
271    `color_by_verbal_unit` (default True) colors every node by the verbal
272    unit it belongs to, per verbal_units.assign_verbal_units() -- the
273    exact same colors (and the same >8-verbal-units warning) as
274    tokengraph_to_mermaid(), just written as `fillcolor`/`color`/
275    `fontcolor` attributes directly on each node line instead of Mermaid's
276    separate `classDef`/`class` statements (DOT has no equivalent of a
277    named, reusable class -- inline per-node attributes are the idiomatic
278    way to do this). An implied/elided token (IMPLIED_TOKENTYPES) always
279    gets its own dedicated amber (verbal_units._IMPLIED_TOKEN_COLOR)
280    instead of whatever color its own verbal unit would otherwise get,
281    same as tokengraph_to_mermaid(). Pass False for a plain, uncolored
282    diagram.
283
284    `rank_by_depth` (default True) is the reason this module exists
285    alongside tokengraph_to_mermaid() -- see the module docstring. Every
286    verbal-unit anchor node (any token with `verbalunitid` set to its own
287    id, implied tokens included) at the same depth in
288    verbal_units.compute_aat_depths() gets listed together in one
289    `{rank=same; id1; id2; ...}` subgraph statement, which *forces*
290    Graphviz's layout engine to place them on the same rank -- not a nudge,
291    a hard constraint. A depth with only one anchor gets no `rank=same`
292    statement (nothing to align it WITH; unlike Mermaid's `~~~` chain,
293    which genuinely needs 2+ nodes to have anything to link, a
294    single-member `rank=same` would be harmless here too, just an inert
295    statement -- it's skipped for output cleanliness, not necessity). Pass
296    False to skip this and let Graphviz's own layout heuristics place
297    every node.
298
299    `depth`, if given, caps the diagram to nodes at or within that many
300    edges of a root/independent verbal-unit anchor -- compute_graph_depths()
301    above, a plain GRAPH distance along the same relatedtoken1/
302    relatedtoken2 edges drawn as `->` lines below, NOT
303    verbal_units.compute_subordination_depths() (the CLAUSE-level notion
304    behind tokengraph_to_depth_html()'s own indented-HTML `depth` slider --
305    a whole clause's subject, object, and other ordinary dependents share
306    ONE subordination depth with their verb, but each is its own hop of
307    GRAPH depth) and NOT verbal_units.compute_aat_depths() (`rank_by_depth`
308    above). `depth=0` shows ONLY root anchors -- an independent verb with
309    no dependents at all; `depth=1` adds every token one edge away from a
310    root anchor (its subject, object, adverbials, ...); and so on. A token
311    farther than `depth` is dropped entirely: omitted as a node, exactly as
312    if it had never been in `tokengraph`. Omit `depth` (or pass `None`, the
313    default) to show every node, same as before this parameter existed. A
314    `depth` at or beyond max_graph_depth()'s own return value for this
315    `tokengraph` shows everything too; a negative `depth` raises
316    ValueError.
317
318    Dropping a node can leave a KEPT node's edge pointing at a now-excluded
319    one. Such an edge is skipped, with the same combined warning already
320    used for an edge targeting punctuation or a genuinely absent id (see
321    Returns below) -- `depth` filtering degrades visibly rather than
322    emitting a dangling `->` line Graphviz would reject.
323
324    `aat_depth`, if given, is a SEPARATE filter from `depth` above, using
325    the SAME verbal_units.compute_aat_depths() notion `rank_by_depth` uses
326    to align nodes -- now used to drop them instead, mirroring
327    mermaid.tokengraph_to_mermaid()'s own `aat_depth` parameter exactly
328    (see that function's docstring for the full rationale). Every token
329    takes the AAT depth of the verbal unit it belongs to
330    (verbal_units.assign_verbal_units()), so a whole clause's subject,
331    object, and other ordinary dependents share ONE depth with their own
332    governing verb -- unlike `depth` above, where each is its own hop of
333    GRAPH distance. `aat_depth=0` therefore shows a root/independent
334    clause IN FULL (its verb plus every one of that clause's own ordinary
335    dependents), not just the bare root verb the way `depth=0` does. A
336    token belonging to no verbal unit at all defaults to depth 0 (kept),
337    same "can't determine, default to root level" fallback used
338    throughout this codebase. Omit `aat_depth` (or pass `None`, the
339    default) to show every node with respect to this filter; a value at
340    or beyond verbal_units.max_aat_depth()'s own return value for this
341    `tokengraph` shows everything too; a negative `aat_depth` raises
342    `ValueError`. `depth` and `aat_depth` compose freely -- a node is kept
343    only if it survives BOTH cutoffs (whichever are actually given); a
344    dangling edge left by either one is skipped with the same warning.
345
346    `show_root` (default True) draws every independent verbal expression's
347    own 'root' relation (relatedtoken1 == 'root', relationship1 == 'unit
348    verb', per syntax_model.md) as a real edge into a single dedicated
349    'root' node, instead of dropping it -- a plain oval (`shape=oval`)
350    labelled "root", deliberately given no `fillcolor`/`style=filled` (so
351    it's never colored, regardless of `color_by_verbal_unit`) -- mirroring
352    mermaid.tokengraph_to_mermaid()'s own `show_root` parameter exactly.
353    The node is added only if at least one surviving token actually has a
354    'root' edge to draw, and -- like tokengraph_to_mermaid()'s version --
355    'root' is exempt from `depth`/`aat_depth` filtering on its own account:
356    an independent verb's anchor is always depth 0 in both
357    compute_graph_depths() and compute_aat_depths(), so it's never excluded
358    by either cutoff. Pass `show_root=False` to skip the node and its
359    edges entirely, exactly reproducing this function's behavior before
360    this parameter existed.
361
362    Returns `(dot_source, warnings)` -- same shape and same warnings as
363    tokengraph_to_mermaid(): an edge skipped because it targets a
364    punctuation token, a token excluded by the `depth` or `aat_depth`
365    cutoff, or an id not present in `tokengraph` (except the 'root'
366    sentinel, skipped silently, same as there); if `color_by_verbal_unit`
367    is True and the passage has more than 8 verbal units, one warning that
368    colors are repeating. Neither `depth` nor `aat_depth` filtering itself
369    ever adds a warning (compute_graph_depths() and compute_aat_depths()
370    both have no unresolved state -- an unrelated or cyclic token just
371    defaults to depth 0 in either), same as `rank_by_depth`.
372    """
373    if depth is not None and depth < 0:
374        raise ValueError(f"depth must be >= 0 (root nodes only), got {depth!r}")
375    if aat_depth is not None and aat_depth < 0:
376        raise ValueError(f"aat_depth must be >= 0 (root clauses only), got {aat_depth!r}")
377
378    node_ids = {tok.id for tok in tokengraph if tok.tokentype != "punctuation"}
379
380    warnings: List[str] = []
381    if depth is not None:
382        graph_depths = compute_graph_depths(tokengraph)
383        depth_excluded_ids = {tok_id for tok_id, d in graph_depths.items() if d > depth}
384        node_ids -= depth_excluded_ids
385
386    if aat_depth is not None:
387        aat_depth_assignment = assign_verbal_units(tokengraph)
388        aat_depths_by_anchor = compute_aat_depths(tokengraph)
389        aat_depth_excluded_ids = {
390            tok.id
391            for tok in tokengraph
392            if tok.id in node_ids
393            and (
394                aat_depths_by_anchor.get(aat_depth_assignment.get(tok.id), 0)
395                if aat_depth_assignment.get(tok.id) is not None
396                else 0
397            )
398            > aat_depth
399        }
400        node_ids -= aat_depth_excluded_ids
401
402    colors_by_unit = {}
403    implied_ids: set = set()
404    if color_by_verbal_unit:
405        assignment = assign_verbal_units(tokengraph)
406        colors_by_unit, color_warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment)
407        warnings.extend(color_warnings)
408        implied_ids = {
409            tok.id
410            for tok in tokengraph
411            if tok.id in node_ids and tok.tokentype in IMPLIED_TOKENTYPES
412        }
413    else:
414        assignment = {}
415
416    lines = ["digraph tokengraph {", f"    rankdir={orientation};", "    node [shape=box];", ""]
417    for tok in tokengraph:
418        if tok.id not in node_ids:
419            continue
420        color = None
421        if color_by_verbal_unit:
422            if tok.id in implied_ids:
423                color = _IMPLIED_TOKEN_COLOR
424            else:
425                unit_id = assignment.get(tok.id)
426                color = colors_by_unit.get(unit_id) if unit_id is not None else None
427        lines.append(f"    {_dot_id(tok.id)} [{_node_attrs(tok, color)}];")
428
429    # The dedicated 'root' node every independent verb's own 'root' edge
430    # points to (see this module's own docstring) -- added only if at
431    # least one surviving token actually has such an edge to draw, so an
432    # empty or fully-filtered diagram never gets an orphan 'root' node.
433    # `shape=oval` with no `fillcolor`/`style=filled` -- a plain, uncolored
434    # oval regardless of `color_by_verbal_unit`.
435    has_root_edge = show_root and any(
436        tok.relatedtoken1 == "root" for tok in tokengraph if tok.id in node_ids
437    )
438    if has_root_edge:
439        lines.append(f'    {_dot_id("root")} [label="root", shape=oval];')
440
441    lines.append("")
442    for tok in tokengraph:
443        if tok.id not in node_ids:
444            continue
445        for related_field, label_field in (
446            ("relatedtoken1", "relationship1"),
447            ("relatedtoken2", "relationship2"),
448        ):
449            related_id = getattr(tok, related_field)
450            label = getattr(tok, label_field)
451            if related_id is None or label is None:
452                continue
453            if related_id == "root":
454                # An independent verb's own unit-verb relation, per
455                # syntax_model.md -- never a warning-worthy gap either way.
456                # When show_root is True (the default), draw the edge into
457                # the single dedicated 'root' node built above; when False,
458                # skip it silently, exactly as before this parameter
459                # existed.
460                if show_root:
461                    lines.append(f'    {_dot_id(tok.id)} -> {_dot_id("root")} [label="{_escape_label(label)}"];')
462                continue
463            if related_id not in node_ids:
464                warnings.append(
465                    f"skipped edge {tok.id} -[{label}]-> {related_id}: "
466                    f"target is punctuation, excluded by the depth or "
467                    f"aat_depth cutoff, or not in tokengraph"
468                )
469                continue
470            lines.append(f'    {_dot_id(tok.id)} -> {_dot_id(related_id)} [label="{_escape_label(label)}"];')
471
472    if rank_by_depth:
473        aat_depths = compute_aat_depths(tokengraph)
474
475        # Same grouping tokengraph_to_mermaid() builds for its `~~~`
476        # chains -- see that function's own comment for why
477        # aat_depths.get() being None here means "not an anchor", never
478        # "unresolved depth" (compute_aat_depths() has no such state).
479        # This loop's own per-anchor variable is named `rank_depth`, not
480        # `depth` or `aat_depth`, to avoid shadowing EITHER of this
481        # function's own depth-filtering parameters above -- two different
482        # depth notions entirely, see this function's own docstring.
483        depth_groups: dict = {}
484        for tok in tokengraph:
485            if tok.id not in node_ids:
486                continue
487            rank_depth = aat_depths.get(tok.id)
488            if rank_depth is None:
489                continue
490            depth_groups.setdefault(rank_depth, []).append(tok.id)
491
492        rank_lines = [
493            "    {rank=same; " + "; ".join(_dot_id(i) for i in ids) + ";}"
494            for rank_depth in sorted(depth_groups)
495            for ids in (depth_groups[rank_depth],)
496            if len(ids) > 1
497        ]
498        if rank_lines:
499            lines.append("")
500            lines.extend(rank_lines)
501
502    lines.append("}")
503    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.

aat_depth, if given, is a SEPARATE filter from depth above, using the SAME verbal_units.compute_aat_depths() notion rank_by_depth uses to align nodes -- now used to drop them instead, mirroring mermaid.tokengraph_to_mermaid()'s own aat_depth parameter exactly (see that function's docstring for the full rationale). Every token takes the AAT depth of the verbal unit it belongs to (verbal_units.assign_verbal_units()), so a whole clause's subject, object, and other ordinary dependents share ONE depth with their own governing verb -- unlike depth above, where each is its own hop of GRAPH distance. aat_depth=0 therefore shows a root/independent clause IN FULL (its verb plus every one of that clause's own ordinary dependents), not just the bare root verb the way depth=0 does. A token belonging to no verbal unit at all defaults to depth 0 (kept), same "can't determine, default to root level" fallback used throughout this codebase. Omit aat_depth (or pass None, the default) to show every node with respect to this filter; a value at or beyond verbal_units.max_aat_depth()'s own return value for this tokengraph shows everything too; a negative aat_depth raises ValueError. depth and aat_depth compose freely -- a node is kept only if it survives BOTH cutoffs (whichever are actually given); a dangling edge left by either one is skipped with the same warning.

show_root (default True) draws every independent verbal expression's own 'root' relation (relatedtoken1 == 'root', relationship1 == 'unit verb', per syntax_model.md) as a real edge into a single dedicated 'root' node, instead of dropping it -- a plain oval (shape=oval) labelled "root", deliberately given no fillcolor/style=filled (so it's never colored, regardless of color_by_verbal_unit) -- mirroring mermaid.tokengraph_to_mermaid()'s own show_root parameter exactly. The node is added only if at least one surviving token actually has a 'root' edge to draw, and -- like tokengraph_to_mermaid()'s version -- 'root' is exempt from depth/aat_depth filtering on its own account: an independent verb's anchor is always depth 0 in both compute_graph_depths() and compute_aat_depths(), so it's never excluded by either cutoff. Pass show_root=False to skip the node and its edges entirely, exactly reproducing this function's behavior before this parameter existed.

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 or aat_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. Neither depth nor aat_depth filtering itself ever adds a warning (compute_graph_depths() and compute_aat_depths() both have no unresolved state -- an unrelated or cyclic token just defaults to depth 0 in either), same as rank_by_depth.

def compute_graph_depths(tokengraph: List[TokenAnalysis]) -> Dict[str, int]:
153def compute_graph_depths(tokengraph: List[TokenAnalysis]) -> Dict[str, int]:
154    """Each non-punctuation token's *graph depth*: the number of edges
155    separating it from the nearest root/independent verbal-unit anchor,
156    following the exact same relatedtoken1/relatedtoken2 edges
157    tokengraph_to_dot() itself draws as `->` lines (dependent -> governor)
158    -- the depth notion behind tokengraph_to_dot()'s own `depth` parameter.
159    See this module's own docstring for how this differs from
160    verbal_units.compute_subordination_depths() (rendering.
161    tokengraph_to_depth_html()'s clause-level notion) and
162    verbal_units.compute_aat_depths() (`rank_by_depth`'s notion).
163
164    A root anchor (relatedtoken1 == 'root') is depth 0. Every other
165    token's depth is one more than its PARENT's -- relatedtoken1, falling
166    back to relatedtoken2 only when relatedtoken1 itself doesn't resolve
167    to a usable parent (None, or an id not in `tokengraph`) -- the SAME
168    "relatedtoken1, fall back to relatedtoken2" preference
169    verbal_units.compute_subordination_depths() already uses to chase a
170    verbal expression's own governor. This matters for a token that plays
171    two roles at once, most notably a relative pronoun: e.g. "qui"
172    pointing at its antecedent via relatedtoken1 ('relative pronoun') AND
173    at the dependent verb it's ALSO the subject of via relatedtoken2
174    ('subject') -- that second edge points forward, toward a token that in
175    turn points back at the pronoun itself (its own 'unit verb' relation),
176    a genuine two-way link the data model allows. Taking the shallower of
177    BOTH edges (rather than preferring relatedtoken1) would let that
178    forward edge "cheat" the pronoun's own depth down to whatever the
179    dependent verb's -- itself only computable FROM the pronoun -- happens
180    to resolve to first, collapsing what should be a deeper chain. Only
181    ever falling back to relatedtoken2, never averaging or taking a
182    minimum over both, avoids that: relatedtoken1 alone already resolves
183    to the antecedent here, so relatedtoken2 is simply never consulted for
184    depth (it's still drawn as its own edge below, same as always -- this
185    only affects which relation DEPTH follows).
186
187    A token whose relatedtoken1 AND any fallback relatedtoken2 both fail
188    to resolve (neither set, or pointing at ids not in `tokengraph`), or
189    which is caught in a relation cycle even after preferring
190    relatedtoken1, defaults to depth 0 -- the same "can't determine,
191    default to root level" fallback verbal_units.compute_subordination_
192    depths() and rendering.tokengraph_to_depth_html() both use for their
193    own unresolved cases, rather than raising.
194
195    Returns `{token id: depth}`, one entry per non-punctuation token in
196    `tokengraph` (punctuation is never part of the diagram, so never
197    included here either).
198    """
199    by_id = {tok.id: tok for tok in tokengraph}
200    depths: Dict[str, int] = {}
201    in_progress: set = set()
202
203    def depth_of(tok_id: str) -> int:
204        if tok_id in depths:
205            return depths[tok_id]
206        tok = by_id[tok_id]
207        if tok.relatedtoken1 == "root":
208            depths[tok_id] = 0
209            return 0
210
211        if tok_id in in_progress:
212            # A relation cycle -- fall back to 0 rather than recursing
213            # forever; NOT cached, so a non-cyclic call further up the
214            # stack still computes (and caches) this token's real depth if
215            # some other path reaches it.
216            return 0
217        in_progress.add(tok_id)
218
219        parent_id = None
220        if tok.relatedtoken1 is not None and tok.relatedtoken1 != "root" and tok.relatedtoken1 in by_id:
221            parent_id = tok.relatedtoken1
222        elif tok.relatedtoken2 is not None and tok.relatedtoken2 in by_id:
223            parent_id = tok.relatedtoken2
224
225        result = 1 + depth_of(parent_id) if parent_id is not None else 0
226
227        in_progress.discard(tok_id)
228        depths[tok_id] = result
229        return result
230
231    for tok in tokengraph:
232        if tok.tokentype == "punctuation":
233            continue
234        depth_of(tok.id)
235
236    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:
239def max_graph_depth(tokengraph: List[TokenAnalysis]) -> Optional[int]:
240    """The highest value compute_graph_depths() assigns to any token in
241    `tokengraph` -- the upper end of the meaningful range for
242    tokengraph_to_dot()'s own `depth` parameter, the same role
243    verbal_units.max_subordination_depth() plays for
244    tokengraph_to_depth_html()'s unrelated depth notion. Returns None for
245    an empty tokengraph, or one with only punctuation."""
246    depths = compute_graph_depths(tokengraph)
247    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, aat_depth: int | None = None, show_root: bool = True) -> List[str]:
506def save_dot(
507    tokengraph: List[TokenAnalysis],
508    path: str,
509    orientation: str = "BT",
510    color_by_verbal_unit: bool = True,
511    rank_by_depth: bool = True,
512    depth: Optional[int] = None,
513    aat_depth: Optional[int] = None,
514    show_root: bool = True,
515) -> List[str]:
516    """Write the diagram to `path` (e.g. 'analysis.dot') and return any
517    warnings from tokengraph_to_dot()."""
518    diagram, warnings = tokengraph_to_dot(
519        tokengraph,
520        orientation=orientation,
521        color_by_verbal_unit=color_by_verbal_unit,
522        rank_by_depth=rank_by_depth,
523        depth=depth,
524        aat_depth=aat_depth,
525        show_root=show_root,
526    )
527    with open(path, "w", encoding="utf-8") as f:
528        f.write(diagram + "\n")
529    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.aatgraph()`) 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: `aatgraph()` 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.aatgraph()) 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: aatgraph() 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    # aatgraph(). 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 filter_tokengraph_by_aat_depth( tokengraph: List[TokenAnalysis], aat_depth: int | None, assignment: Dict[str, str | None] | None = None, depths: Dict[str, int] | None = None) -> List[TokenAnalysis]:
622def filter_tokengraph_by_aat_depth(
623    tokengraph: List[TokenAnalysis],
624    aat_depth: Optional[int],
625    assignment: Optional[Dict[str, Optional[str]]] = None,
626    depths: Optional[Dict[str, int]] = None,
627) -> List[TokenAnalysis]:
628    """Return a NEW tokengraph containing only the tokens of `tokengraph`
629    whose own verbal expression is at or within `aat_depth` -- the same
630    cutoff `mermaid.tokengraph_to_mermaid()`'s and `dot.tokengraph_to_dot()`'s
631    own `aat_depth` parameter applies when deciding which nodes to draw,
632    but returned here as an actual filtered tokengraph (a plain list of
633    TokenAnalysis) rather than a set of node ids to include -- for a
634    caller that wants to shrink the ANALYSIS itself before doing something
635    else with it, rather than just limiting a diagram's own node set. The
636    motivating case: `latin_syntaxer_review.py`'s AAT (Agent-Action-Target)
637    graph display, which needs an actual depth-limited `tokengraph` (and
638    its matching `verbalunits`) to pass to `aat_bridge.aatgraph()`, not a
639    node-inclusion set for a diagram it isn't drawing.
640
641    Every token takes the AAT depth of the verbal unit it belongs to
642    (`assign_verbal_units()`), exactly the same way `tokengraph_to_mermaid()`
643    /`tokengraph_to_dot()`'s own `aat_depth` filtering groups tokens -- so a
644    whole clause's subject, object, and other ordinary dependents are kept
645    or dropped together with their own governing verb; a token belonging to
646    no verbal unit at all defaults to depth 0 (kept), the same "can't
647    determine, default to root level" fallback used throughout this
648    codebase. Unlike those two functions, punctuation tokens are NOT
649    unconditionally dropped here -- there is no diagram node set to shrink,
650    so only the depth cutoff itself matters, and a punctuation token whose
651    own resolved depth is within the cutoff is kept.
652
653    This is safe to feed straight into `aat_bridge.aatgraph()` (via
654    `find_governing_verbal_expression()`, which it also uses internally):
655    for every documented case `compute_subordination_depths()`'s own
656    docstring lists (unit verb, direct quote/aside/indirect statement,
657    circumstantial participle), whichever intermediate, non-anchor token a
658    governing-expression chase has to pass through to reach a KEPT verbal
659    expression's own governor is itself assigned (by `assign_verbal_units()`)
660    to that SAME verbal expression's own unit -- so it shares that
661    expression's depth and is filtered in or out right along with it. A
662    verbal expression that survives this filter therefore never has its own
663    governing-chain token filtered out from under it.
664
665    Returns a new list, preserving `tokengraph`'s own original order; pass
666    `aat_depth=None` to get back an equivalent copy of `tokengraph`
667    unchanged (no filtering at all). A negative `aat_depth` raises
668    `ValueError`, matching `tokengraph_to_mermaid()`'s/`tokengraph_to_dot()`'s
669    own guard. `assign_verbal_units()`'s own return value is monotonic with
670    depth here in the sense described above, not merely by coincidence --
671    see this module's own docstring for the resolution rules that make it
672    so.
673
674    Pass `assignment` (`assign_verbal_units()`'s own return value) and/or
675    `depths` (`compute_aat_depths()`'s own return value) if the caller
676    already computed either, to avoid re-deriving them here.
677    """
678    if aat_depth is not None and aat_depth < 0:
679        raise ValueError(f"aat_depth must be >= 0 (root clauses only), got {aat_depth!r}")
680
681    if aat_depth is None:
682        return list(tokengraph)
683
684    if assignment is None:
685        assignment = assign_verbal_units(tokengraph)
686    if depths is None:
687        depths = compute_aat_depths(tokengraph)
688
689    def _token_depth(tok: TokenAnalysis) -> int:
690        unit_id = assignment.get(tok.id)
691        return depths.get(unit_id, 0) if unit_id is not None else 0
692
693    return [tok for tok in tokengraph if _token_depth(tok) <= aat_depth]

Return a NEW tokengraph containing only the tokens of tokengraph whose own verbal expression is at or within aat_depth -- the same cutoff mermaid.tokengraph_to_mermaid()'s and dot.tokengraph_to_dot()'s own aat_depth parameter applies when deciding which nodes to draw, but returned here as an actual filtered tokengraph (a plain list of TokenAnalysis) rather than a set of node ids to include -- for a caller that wants to shrink the ANALYSIS itself before doing something else with it, rather than just limiting a diagram's own node set. The motivating case: latin_syntaxer_review.py's AAT (Agent-Action-Target) graph display, which needs an actual depth-limited tokengraph (and its matching verbalunits) to pass to aat_bridge.aatgraph(), not a node-inclusion set for a diagram it isn't drawing.

Every token takes the AAT depth of the verbal unit it belongs to (assign_verbal_units()), exactly the same way tokengraph_to_mermaid() /tokengraph_to_dot()'s own aat_depth filtering groups tokens -- so a whole clause's subject, object, and other ordinary dependents are kept or dropped together with their own governing verb; a token belonging to no verbal unit at all defaults to depth 0 (kept), the same "can't determine, default to root level" fallback used throughout this codebase. Unlike those two functions, punctuation tokens are NOT unconditionally dropped here -- there is no diagram node set to shrink, so only the depth cutoff itself matters, and a punctuation token whose own resolved depth is within the cutoff is kept.

This is safe to feed straight into aat_bridge.aatgraph() (via find_governing_verbal_expression(), which it also uses internally): for every documented case compute_subordination_depths()'s own docstring lists (unit verb, direct quote/aside/indirect statement, circumstantial participle), whichever intermediate, non-anchor token a governing-expression chase has to pass through to reach a KEPT verbal expression's own governor is itself assigned (by assign_verbal_units()) to that SAME verbal expression's own unit -- so it shares that expression's depth and is filtered in or out right along with it. A verbal expression that survives this filter therefore never has its own governing-chain token filtered out from under it.

Returns a new list, preserving tokengraph's own original order; pass aat_depth=None to get back an equivalent copy of tokengraph unchanged (no filtering at all). A negative aat_depth raises ValueError, matching tokengraph_to_mermaid()'s/tokengraph_to_dot()'s own guard. assign_verbal_units()'s own return value is monotonic with depth here in the sense described above, not merely by coincidence -- see this module's own docstring for the resolution rules that make it so.

Pass assignment (assign_verbal_units()'s own return value) and/or depths (compute_aat_depths()'s own return value) if the caller already computed either, to avoid re-deriving them here.

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 `aatgraph()`,
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 aatgraph(), 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_aat_depth( tokengraph: List[TokenAnalysis], depths: Dict[str, int] | None = None) -> int | None:
590def max_aat_depth(
591    tokengraph: List[TokenAnalysis],
592    depths: Optional[Dict[str, int]] = None,
593) -> Optional[int]:
594    """Return the deepest AAT depth reached anywhere in `tokengraph` -- the
595    highest value `compute_aat_depths()` assigns to any verbal-expression
596    anchor. Root/independent actions are depth 0, so this is also the upper
597    end of the valid `aat_depth` range for `mermaid.tokengraph_to_mermaid()`
598    and `dot.tokengraph_to_dot()`'s own `aat_depth` filtering parameter (see
599    each function's own docstring): 0, root actions only, through this
600    function's return value, everything.
601
602    Pass `depths` (`compute_aat_depths()`'s own return value) if the caller
603    already computed it, to avoid re-deriving it here; otherwise it's
604    computed internally.
605
606    Unlike `max_subordination_depth()` (which ignores any anchor whose
607    depth came back unresolved), every anchor `compute_aat_depths()` visits
608    already has a plain `int` -- that function never leaves one `None` --
609    so there's no "unresolved" case to filter out here. Returns `None`
610    only when `tokengraph` has no verbal expressions at all (an empty
611    passage, or one with none of the three constructions syntax_model.md
612    counts as one); otherwise the max of every anchor's depth.
613    """
614    if depths is None:
615        depths = compute_aat_depths(tokengraph)
616
617    if not depths:
618        return None
619    return max(depths.values())

Return the deepest AAT depth reached anywhere in tokengraph -- the highest value compute_aat_depths() assigns to any verbal-expression anchor. Root/independent actions are depth 0, so this is also the upper end of the valid aat_depth range for mermaid.tokengraph_to_mermaid() and dot.tokengraph_to_dot()'s own aat_depth filtering parameter (see each function's own docstring): 0, root actions only, through this function's return value, everything.

Pass depths (compute_aat_depths()'s own return value) if the caller already computed it, to avoid re-deriving it here; otherwise it's computed internally.

Unlike max_subordination_depth() (which ignores any anchor whose depth came back unresolved), every anchor compute_aat_depths() visits already has a plain int -- that function never leaves one None -- so there's no "unresolved" case to filter out here. Returns None only when tokengraph has no verbal expressions at all (an empty passage, or one with none of the three constructions syntax_model.md counts as one); otherwise the max of every anchor's depth.

def max_subordination_depth( tokengraph: List[TokenAnalysis], depths: Dict[str, int | None] | None = None) -> int | None:
696def max_subordination_depth(
697    tokengraph: List[TokenAnalysis],
698    depths: Optional[Dict[str, Optional[int]]] = None,
699) -> Optional[int]:
700    """Return the deepest level of subordination reached anywhere in
701    `tokengraph` -- the highest value `compute_subordination_depths()`
702    assigns to any verbal expression. Root/independent clauses are depth
703    0, so this is also the upper end of the valid `depth` range for
704    `rendering.tokengraph_to_depth_html()`'s own `depth` parameter (whose
705    valid range is 0, root clauses only, through this function's return
706    value, everything).
707
708    Pass `depths` (the first element of `compute_subordination_depths()`'s
709    return value) if the caller already computed it, to avoid re-deriving
710    it here; otherwise it's computed internally (any resolution warnings
711    are silently dropped in that case -- call
712    `compute_subordination_depths()` directly first if the caller also
713    needs those).
714
715    Returns `None` if `tokengraph` has no verbal expressions at all (an
716    empty passage, or one with none of the three constructions
717    syntax_model.md counts as one), or if every anchor's own depth came
718    back unresolved (see `compute_subordination_depths()`'s own
719    warnings for why an anchor might be unresolved -- a relation cycle, or
720    a governing verbal expression that couldn't be found). Otherwise
721    returns the maximum of every RESOLVED anchor's depth, ignoring
722    unresolved ones rather than letting a single bad anchor blank out the
723    whole result.
724    """
725    if depths is None:
726        depths, _warnings = compute_subordination_depths(tokengraph)
727
728    resolved = [d for d in depths.values() if d is not None]
729    if not resolved:
730        return None
731    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]:
734def find_unanchored_coordinated_verbs(tokengraph: List[TokenAnalysis]) -> List[str]:
735    """Heuristic sanity check for a specific, observed live-LM mistake: a
736    coordinating conjunction that pairs two verbal expressions (see
737    latin_syntax_dspy.py's docstring) is supposed to leave BOTH conjuncts
738    anchoring their own verbal unit -- each with its own `verbalunitid`
739    (and its own `verbalunits` entry). In practice, the LM sometimes drops
740    this for the second conjunct, especially when that verb also governs
741    further subordinate structure of its own (a dependent clause, an
742    indirect statement) -- see gold_examples.py's
743    coordinating_conjunction_dedit_et_dixit_esse fixture for a real example
744    (dixit, coordinated with dedit via "et" AND governing its own indirect
745    statement, came back from a live model with no verbalunitid at all).
746
747    This is NOT the same kind of check as validate() (referential id
748    integrity) or compute_subordination_depths()'s warnings (a resolvable-
749    but-broken relation graph) -- both of those only catch a problem if
750    the tokengraph is already self-inconsistent. This function catches a
751    tokengraph that's perfectly well-formed and internally consistent, but
752    still probably WRONG, by looking for an asymmetry a correct analysis
753    should never produce.
754
755    The heuristic: find every "coordinating conjunction" token that uses
756    BOTH relatedtoken1 and relatedtoken2 (the two-conjunct pairing case --
757    see that relation's own note about the one-sided, sentence-initial
758    exception, which this deliberately ignores since there's only one
759    conjunct to check there). For each such pair, if EXACTLY ONE of the
760    two joined tokens is a recognized verbal-unit anchor (`verbalunitid`
761    set to its own id) and the other is not, that asymmetry is flagged: if
762    the conjunction is genuinely pairing two nouns/adjectives/
763    prepositional phrases, NEITHER side would be an anchor; if it's
764    correctly pairing two verbal expressions, BOTH sides would be. Only
765    the lopsided case -- one anchored, one not -- is unusual enough to be
766    worth a human look.
767
768    A repeated connector coordinating a series of three or more items
769    (polysyndeton, e.g. 'et...et...et' -- see latin_syntax_dspy.py's
770    docstring) fits this same pairwise shape naturally: a connector
771    strictly between two items relates directly to its flanking items via
772    relatedtoken1/relatedtoken2, exactly like an ordinary two-item pair, so
773    it's checked the same way. Only the introductory connector before the
774    very first item ("et A et B et C") is different -- it sets
775    relatedtoken1 alone, with no relatedtoken2 -- and that shape is already
776    excluded by this function's own requirement (above) that both
777    relatedtoken1 and relatedtoken2 be present, so no special-case is
778    needed for it here.
779
780    Returns a list of warning strings (empty if nothing looks suspicious),
781    the same "degrade visibly, don't raise" convention every other
782    warnings-returning function in this codebase uses. This is a
783    heuristic, not a guarantee: it can only flag the asymmetry itself, not
784    confirm the unanchored side really was meant to be a verb, so a clean
785    result here isn't a substitute for validate() or a human read of the
786    analysis -- and a flagged result deserves a look rather than an
787    automatic "fix," since guessing the right verbalunitid/relation back
788    in could just as easily paper over a different, unrelated mistake.
789    """
790    by_id = {tok.id: tok for tok in tokengraph}
791    anchor_ids = {tok.id for tok in tokengraph if tok.verbalunitid == tok.id}
792
793    warnings: List[str] = []
794    seen_pairs = set()
795
796    for tok in tokengraph:
797        if not (
798            tok.relatedtoken1 is not None
799            and tok.relatedtoken1 != "root"
800            and tok.relationship1 == "coordinating conjunction"
801            and tok.relatedtoken2 is not None
802            and tok.relatedtoken2 != "root"
803            and tok.relationship2 == "coordinating conjunction"
804        ):
805            continue
806
807        pair = (tok.relatedtoken1, tok.relatedtoken2)
808        if pair in seen_pairs:
809            continue
810        seen_pairs.add(pair)
811
812        first_id, second_id = pair
813        first_anchored = first_id in anchor_ids
814        second_anchored = second_id in anchor_ids
815        if first_anchored == second_anchored:
816            # Both anchored (a correctly paired pair of verbs) or neither
817            # (almost certainly a noun/adjective/prepositional-phrase
818            # pair) -- either way, not the asymmetry this check looks for.
819            continue
820
821        anchored_id, unanchored_id = (
822            (first_id, second_id) if first_anchored else (second_id, first_id)
823        )
824        anchored_text = by_id[anchored_id].token if anchored_id in by_id else anchored_id
825        unanchored_text = by_id[unanchored_id].token if unanchored_id in by_id else unanchored_id
826        warnings.append(
827            f"{tok.id} ({tok.token!r}) coordinates {anchored_id} "
828            f"({anchored_text!r}), which anchors its own verbal unit, with "
829            f"{unanchored_id} ({unanchored_text!r}), which does not -- if "
830            "this conjunction is meant to join two verbal expressions "
831            "(rather than a noun/adjective/prepositional-phrase pair), "
832            f"{unanchored_id} is likely missing its own verbalunitid and "
833            "'unit verb'/'root' (or dependent-clause) relation."
834        )
835
836    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], depth: int | None = None) -> str:
150def tokengraph_to_html(
151    tokengraph: List[TokenAnalysis],
152    depth: Optional[int] = None,
153) -> str:
154    """Render `tokengraph` as an HTML string: the same continuous text
155    `tokengraph_to_text()` produces -- identical spacing rules, and the same
156    punctuation/enclitic/quote-pair handling -- except every **lexical**
157    token, every **praenomen** token, every **numeral** token, and every
158    **coordinating conjunction** (any token with relationship1 or
159    relationship2 == "coordinating conjunction", lexical or not), has its
160    text wrapped in a `<span style="...">` colored by the verbal unit it
161    belongs to. Colors
162    come from `verbal_units.assign_verbal_units()` /
163    `assign_verbal_unit_colors()` -- the same assignment and the same
164    first-appearance palette ordering `tokengraph_to_mermaid()` uses for its
165    node coloring -- so a passage rendered here and the same passage's
166    Mermaid diagram color each verbal unit identically.
167
168    `depth`, if given, caps how deep the *color highlighting* goes -- it
169    never affects which tokens appear. The complete reconstructed passage
170    is always rendered, exactly as with `depth` left unset; a token whose
171    verbal unit's depth of subordination (see
172    `verbal_units.compute_subordination_depths()`) exceeds `depth` simply
173    falls back to plain, unhighlighted (but still escaped) text, exactly
174    like a token with no verbal-unit assignment already does today. This is
175    the same clause-level subordination-depth notion
176    `tokengraph_to_depth_html()`'s own `depth` parameter uses -- NOT the
177    AAT/rank depth `tokengraph_to_mermaid()`'s `rank_by_depth` uses, nor the
178    graph-distance depth `tokengraph_to_dot()`'s `depth` uses (see
179    notes/dot_diagrams.md's note on the three distinct "depth" notions in
180    this codebase). Unlike `tokengraph_to_depth_html()`, which DROPS an
181    entire out-of-depth block from its output, this function never drops
182    any token -- only the highlighting is limited. `depth=0` highlights
183    root/independent-clause units only; omit `depth` (or pass `None`, the
184    default) to highlight every unit, same as before this parameter
185    existed. A negative `depth` raises `ValueError`, matching
186    `tokengraph_to_depth_html()`'s own validation. A verbal unit whose depth
187    couldn't be resolved (see `compute_subordination_depths()`) is treated
188    as depth 0 for this purpose -- the same "unresolved counts as root"
189    convention `tokengraph_to_depth_html()` uses -- so it stays highlighted
190    at any valid (non-negative) `depth`, same as a genuine root clause.
191
192    The coordinating-conjunction carve-out exists because a conjunction
193    like "-que" or "-ve" is typically tokentype "enclitic", not "lexical",
194    but `assign_verbal_units()` still resolves it to one of the units it
195    coordinates (see that module's docstring) -- e.g. in "arma virumque
196    cano.", "que" resolves to the same unit as "cano", same as "arma" and
197    "virum" do. Leaving it unwrapped would visually hide that assignment
198    even though it's a real one, unlike the other non-lexical tokentypes
199    below. A subordinating conjunction (e.g. "cum", "ut") doesn't need this
200    carve-out: it's always tokentype "lexical" (a full word, never
201    enclitic), so it's already wrapped.
202
203    The praenomen carve-out is the same idea for a different reason:
204    syntax_model.md's "Praenomina" section gives every `tokentype`=
205    "praenomen" token (e.g. "Sex.") its own real relation -- relatedtoken1
206    -> the lexical name it precedes, relationship1 = "praenomen" -- so
207    `assign_verbal_units()` resolves it to that name's own verbal unit
208    exactly like any other token in the clause (e.g. "Sex." lands in the
209    same unit as "Tarquinius", which is "venit"'s). Unlike the
210    coordinating-conjunction case, this is keyed on `tokentype` directly
211    rather than on the relationship label, matching how the "lexical" half
212    of this check works -- every praenomen, by convention, is eligible for
213    this treatment, not just ones that happen to already carry the
214    relation (a praenomen with nothing to relate to, e.g. "L." in the
215    genitive filiation formula "L. f.", simply has no verbal-unit
216    assignment and so renders unwrapped anyway, same as an unrelated
217    lexical token would).
218
219    The numeral carve-out is for the same reason again: syntax_model.md's
220    tokenization section restricts `tokentype`="numeral" to a number
221    written NUMERICALLY (Roman or Arabic) -- a number spelled out as an
222    ordinary word (e.g. "decem") is "lexical" instead -- but a numeral is
223    otherwise an ordinary participant in the clause, able to carry a real
224    relation like any noun or adjective (e.g. "XII" modifying "filii" via
225    "adjectival", the same relation "decem" would use if spelled out).
226    `assign_verbal_units()` resolves that relation exactly like any other,
227    so a numeral belonging to a verbal unit is wrapped the same way a
228    lexical token would be -- unlike punctuation, a non-conjunction
229    enclitic, or an abbreviation, none of which carry that kind of
230    ordinary syntactic relation under the current scheme.
231
232    Every other non-lexical, non-praenomen, non-numeral token --
233    punctuation, a non-conjunction enclitic (e.g. the interrogative "-ne"),
234    and abbreviations -- is still emitted as plain (escaped) text even
235    though `assign_verbal_units()` assigns every token, including
236    punctuation, to whichever unit its relations resolve to; this function
237    just doesn't turn that assignment into a span for anything else. A
238    lexical, praenomen, numeral, or coordinating-conjunction token
239    belonging to no verbal unit (assignment is `None`, e.g. a bare
240    accusative of place) is left unwrapped too, as is one whose unit
241    happens to have no non-punctuation member at all and so never got a
242    color slot from `assign_verbal_unit_colors()` (should not occur in
243    practice for a lexical, praenomen, or numeral token, since it's always
244    a non-punctuation member of its own unit, but handled defensively
245    rather than assumed).
246
247    An **implied/elided token** (models.py's IMPLIED_TOKENTYPES: "implied
248    sum", "continued discourse", "implied subject") is omitted entirely -- same as
249    tokengraph_to_text() -- rather than rendered with any span: it has no
250    surface text (`tok.token` is always `None`), and unlike
251    `tokengraph_to_mermaid()`'s diagram (which DOES show these, as their
252    own specially-colored, specially-labeled node -- see that module's own
253    docstring), inserting placeholder text into the middle of reconstructed
254    prose here would misrepresent what the passage actually says. The
255    Mermaid diagram is the one place an implied token's presence is worth
256    seeing at all.
257
258    Every token's text is HTML-escaped (`&`, `<`, `>`, and quote characters)
259    before being emitted, spans or not -- real Latin text can contain a
260    literal `"` or `'` (see the quote-pair handling below), which would
261    otherwise be indistinguishable from markup to anything that re-parses
262    this output.
263
264    The span's inline style sets both `background-color` (the verbal unit's
265    palette `fill`, the same value used as a Mermaid node's `fill`) and
266    `color` (the palette's `text` value, currently black for every slot) --
267    the latter so the token reads correctly regardless of whatever text
268    color the surrounding page has set, matching the explicit black
269    `color:` every Mermaid node in that unit also gets.
270    """
271    if depth is not None and depth < 0:
272        raise ValueError(f"depth must be >= 0 (root clauses only), got {depth!r}")
273
274    assignment = assign_verbal_units(tokengraph)
275    colors, _warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment)
276
277    if depth is not None:
278        # Limit highlighting, not content: drop the out-of-depth units from
279        # `colors` (rather than filtering `tokengraph`/`assignment`) so
280        # _tokens_to_html() still walks every token unchanged, but a token
281        # whose unit's color entry was removed here takes the same
282        # `colors.get(unit_id) is None` fallback to plain text that an
283        # unassigned token already takes -- no change needed to
284        # _tokens_to_html() itself.
285        depths, _depth_warnings = compute_subordination_depths(tokengraph)
286        colors = {
287            unit_id: color
288            for unit_id, color in colors.items()
289            if (depths.get(unit_id) or 0) <= depth
290        }
291
292    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.

depth, if given, caps how deep the color highlighting goes -- it never affects which tokens appear. The complete reconstructed passage is always rendered, exactly as with depth left unset; a token whose verbal unit's depth of subordination (see verbal_units.compute_subordination_depths()) exceeds depth simply falls back to plain, unhighlighted (but still escaped) text, exactly like a token with no verbal-unit assignment already does today. This is the same clause-level subordination-depth notion tokengraph_to_depth_html()'s own depth parameter uses -- NOT the AAT/rank depth tokengraph_to_mermaid()'s rank_by_depth uses, nor the graph-distance depth tokengraph_to_dot()'s depth uses (see notes/dot_diagrams.md's note on the three distinct "depth" notions in this codebase). Unlike tokengraph_to_depth_html(), which DROPS an entire out-of-depth block from its output, this function never drops any token -- only the highlighting is limited. depth=0 highlights root/independent-clause units only; omit depth (or pass None, the default) to highlight every unit, same as before this parameter existed. A negative depth raises ValueError, matching tokengraph_to_depth_html()'s own validation. A verbal unit whose depth couldn't be resolved (see compute_subordination_depths()) is treated as depth 0 for this purpose -- the same "unresolved counts as root" convention tokengraph_to_depth_html() uses -- so it stays highlighted at any valid (non-negative) depth, same as a genuine root clause.

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

def tokengraph_to_displacy_data( tokengraph: List[TokenAnalysis], show_root: bool = True, aat_depth: int | None = None) -> Tuple[Dict[str, list], List[str | None], List[str]]:
130def tokengraph_to_displacy_data(
131    tokengraph: List[TokenAnalysis],
132    show_root: bool = True,
133    aat_depth: Optional[int] = None,
134) -> Tuple[Dict[str, list], List[Optional[str]], List[str]]:
135    """Build displaCy's own "manual" `dep`-style data --
136    `{"words": [{"text": ..., "tag": ...}, ...], "arcs": [{"start": int,
137    "end": int, "label": ..., "dir": "left" | "right"}, ...]}` -- from
138    `tokengraph`, in the same shape https://spacy.io/usage/visualizers
139    documents for `displacy.render(data, style="dep", manual=True)`.
140
141    **Words.** One word per tokengraph entry, IN TOKENGRAPH ORDER (already
142    reading order -- see Sentence's own docstring), EXCEPT: punctuation,
143    which (unlike mermaid.py's node graph, which draws it) this module
144    omits entirely, for a cleaner "just the words" diagram; and an
145    implied/elided token (tokentype in IMPLIED_TOKENTYPES) that nothing
146    else's relation ever names as a target, which has no surface
147    realization to give it a position at all (see this module's own
148    docstring) and is skipped here too. An implied/elided token that SOME
149    surviving token's relation DOES name as a target is instead included,
150    positioned immediately after that other token (the last one, in reading
151    order, if more than one names the same target) -- see this module's own
152    docstring for the full rule. Each word's own "tag" is its tokentype
153    string verbatim ("enclitic", "numeral", "praenomen", "abbreviation",
154    "implied sum", "continued discourse", or "implied subject" --
155    "punctuation" can't appear here at all, since punctuation never gets a
156    word; see above), EXCEPT "lexical" -- the ordinary case, and by far the
157    most common tokentype in most sentences -- which gets an empty tag
158    instead, so the diagram calls out only the genuinely informative
159    minority. This still isn't a part-of-speech tag in spaCy's own sense
160    (this codebase's tokentype vocabulary doesn't have one), but the
161    closest equivalent this data has, displayed the same way a POS tag
162    would be. A word's own TEXT is `tok.token`, except for an included
163    implied/elided token (`tok.token` is always `None`), which uses
164    mermaid.py's own `token_label()` placeholder instead -- the same label
165    mermaid.py's diagram already shows for that same token.
166
167    If `show_root` (default True), one further, synthetic word --
168    displayed text "ROOT", tag "" -- is appended at the END of the word
169    list (see this module's own docstring), but ONLY if at least one
170    surviving token actually has a 'root' relation to draw into it (so a
171    tokengraph with no independent verb at all -- e.g. one sentence sliced
172    out of a larger passage -- never gets an orphan ROOT word), mirroring
173    mermaid.py's own show_root convention of only adding its dedicated node
174    when there's an edge to draw into it.
175
176    **Arcs.** One arc per resolved relatedtoken1/relationship1 or
177    relatedtoken2/relationship2 pair (both checked, same as mermaid.py):
178    `start`/`end` are the LOWER/HIGHER of the two words' own indices into
179    the returned "words" list (displaCy's own `start < end` requirement),
180    and `dir` says which end the arrow points at -- "left" if the token
181    this relation points AT (`relatedtoken1`/`2`, i.e. the more
182    syntactically prominent side of the pair -- the governing verb, the
183    antecedent, the noun a genitive depends on, and so on) ends up at the
184    lower (`start`) index, "right" if it ends up at the higher (`end`)
185    index. This is a convention of THIS module's own choosing (there's no
186    real spaCy dependency parse underneath it for the "arrow points at the
187    head" idiom to matter for automatically) -- but a self-consistent one:
188    an arc's arrowhead always marks the token that its OWN source token
189    "relates to", regardless of which one comes first in the sentence.
190
191    A relation is skipped, and reported as a warning, rather than turned
192    into a malformed arc, when: its target is a punctuation token (dropped
193    from the word list, per above -- there's no word index for one); its
194    target was excluded by `aat_depth` (see below); its target id isn't in
195    `tokengraph` at all (the same "referentially malformed" case mermaid.py
196    already guards against); or its target IS its own source token (a
197    self-relation -- never valid, but defensively guarded against rather
198    than assumed impossible). A relation whose target is an implied/elided
199    token, by contrast, is exactly the case that gets the target INCLUDED
200    (per above) rather than skipped -- so this specific reason should never
201    actually surface in practice for a relation whose own source survives;
202    it's kept as a defensive fallback (same "guard it anyway" spirit as the
203    self-relation check) rather than assumed impossible.
204
205    `aat_depth`, if given, drops every token belonging to a verbal
206    expression DEEPER than that AAT-graph depth (`verbal_units.
207    compute_aat_depths()`) from the word list entirely -- the exact same
208    filter, and the exact same depth notion, `tokengraph_to_mermaid()`'s and
209    `tokengraph_to_dot()`'s own `aat_depth` parameters already use, so one
210    depth cutoff (e.g. a single slider in a UI) can drive all three
211    diagrams identically. A token belonging to no verbal unit at all
212    defaults to depth 0 (kept), same "can't determine, default to root
213    level" fallback used throughout this codebase. A dropped token's own
214    relations are simply never drawn (no warning -- there's no arc AT ALL to
215    skip, since the token isn't iterated for its own outgoing relations
216    once it has no word slot); a SURVIVING token's relation that points AT
217    a dropped one is what gets skipped-with-a-warning. An implied/elided
218    token is never itself excluded by `aat_depth` (same exemption
219    `tokengraph_to_mermaid()`'s/`tokengraph_to_dot()`'s own `aat_depth`
220    already give it) -- whether it appears in the diagram at all is
221    decided solely by whether something links to it, per above, regardless
222    of depth. Omit `aat_depth` (or pass `None`, the default) to show every
223    word; a negative `aat_depth` raises `ValueError`.
224
225    Returns `(data, word_token_ids, warnings)`. `word_token_ids` is a list
226    parallel to `data["words"]`, giving the ORIGINAL tokengraph id each word
227    came from (`None` for the synthetic ROOT word, if present -- an
228    included implied/elided token gets its own real id here, same as any
229    other word) -- for a caller (this module's own
230    `tokengraph_to_displacy_svg()`) that wants to recover "which token is
231    word index 3" without re-deriving this function's own
232    word-selection/ordering rules. `warnings` is the list of
233    skipped-relation messages described above.
234    """
235    if aat_depth is not None and aat_depth < 0:
236        raise ValueError(f"aat_depth must be >= 0 (root clauses only), got {aat_depth!r}")
237
238    by_id: Dict[str, TokenAnalysis] = {tok.id: tok for tok in tokengraph}
239
240    aat_depth_excluded_ids: set = set()
241    if aat_depth is not None:
242        assignment_for_depth = assign_verbal_units(tokengraph)
243        aat_depths_by_anchor = compute_aat_depths(tokengraph)
244        aat_depth_excluded_ids = {
245            tok.id
246            for tok in tokengraph
247            if tok.tokentype not in IMPLIED_TOKENTYPES
248            and (
249                aat_depths_by_anchor.get(assignment_for_depth.get(tok.id), 0)
250                if assignment_for_depth.get(tok.id) is not None
251                else 0
252            )
253            > aat_depth
254        }
255
256    # Every ordinary (non-implied) token that survives punctuation- and
257    # aat_depth-exclusion, in tokengraph's own reading order -- exactly
258    # this function's word list before implied/elided tokens could ever be
259    # included at all.
260    base_ids: List[str] = [
261        tok.id
262        for tok in tokengraph
263        if tok.tokentype not in IMPLIED_TOKENTYPES
264        and tok.tokentype != "punctuation"
265        and tok.id not in aat_depth_excluded_ids
266    ]
267    base_id_set = set(base_ids)
268
269    # Which implied/elided tokens get included, and where. `anchor_id[i]`
270    # is the id of the token an included implied token `i` goes right
271    # after -- the LAST token, in tokengraph order, whose own
272    # relatedtoken1/2 names `i` as a target (see this module's own
273    # docstring: only one slot exists per implied token, so ties favor the
274    # most recent linker). This is a fixed-point computation, not a single
275    # pass, so a linked implied token can itself anchor a FURTHER implied
276    # token through its own outgoing relations (a chain) -- no current gold
277    # example needs more than one link, but nothing here assumes that.
278    implied_by_id = {
279        tok.id: tok for tok in tokengraph if tok.tokentype in IMPLIED_TOKENTYPES
280    }
281    anchor_id: Dict[str, str] = {}
282    newly_linked = True
283    while newly_linked:
284        newly_linked = False
285        for tok in tokengraph:
286            if tok.id not in base_id_set and tok.id not in anchor_id:
287                continue  # tok itself isn't (yet) known to survive
288            for related_field in ("relatedtoken1", "relatedtoken2"):
289                related_id = getattr(tok, related_field)
290                if related_id is None or related_id == "root":
291                    continue
292                if related_id in implied_by_id:
293                    if related_id not in anchor_id:
294                        newly_linked = True
295                    anchor_id[related_id] = tok.id
296
297    # Every implied token anchored to a given real (or already-included
298    # implied) token id, in tokengraph order among themselves.
299    implied_after: Dict[str, List[str]] = {}
300    for tok in tokengraph:
301        anchor = anchor_id.get(tok.id)
302        if anchor is not None:
303            implied_after.setdefault(anchor, []).append(tok.id)
304
305    ordered_ids: List[str] = []
306
307    def _place(token_id: str) -> None:
308        ordered_ids.append(token_id)
309        for implied_id in implied_after.get(token_id, ()):
310            _place(implied_id)
311
312    for token_id in base_ids:
313        _place(token_id)
314
315    word_texts: List[str] = []
316    word_tags: List[str] = []
317    word_token_ids: List[Optional[str]] = []
318    index_by_id: Dict[str, int] = {}
319
320    for token_id in ordered_ids:
321        tok = by_id[token_id]
322        index_by_id[tok.id] = len(word_texts)
323        word_texts.append(token_label(tok))
324        word_tags.append(tok.tokentype if tok.tokentype != "lexical" else "")
325        word_token_ids.append(tok.id)
326
327    warnings: List[str] = []
328    raw_arcs: List[Tuple[int, int, str, str]] = []  # (start, end, label, dir)
329
330    has_root_edge = show_root and any(
331        tok.relatedtoken1 == "root" and tok.id in index_by_id for tok in tokengraph
332    )
333    root_index: Optional[int] = None
334    if has_root_edge:
335        root_index = len(word_texts)
336        word_texts.append(_ROOT_WORD_TEXT)
337        word_tags.append("")
338        word_token_ids.append(None)
339
340    for tok in tokengraph:
341        source_index = index_by_id.get(tok.id)
342        if source_index is None:
343            # tok itself was dropped (an unlinked implied/elided token,
344            # punctuation, or excluded by aat_depth) -- it can still be the
345            # TARGET of someone else's relation (handled via
346            # index_by_id.get() below), but has none of its own to draw. A
347            # LINKED implied/elided token, by contrast, already has an
348            # index_by_id entry by this point (see above), so it reaches
349            # this loop as an ordinary source and gets its own outgoing
350            # relations drawn exactly like any other token's.
351            continue
352        for related_field, label_field in (
353            ("relatedtoken1", "relationship1"),
354            ("relatedtoken2", "relationship2"),
355        ):
356            related_id = getattr(tok, related_field)
357            label = getattr(tok, label_field)
358            if related_id is None or label is None:
359                continue
360
361            if related_id == "root":
362                if root_index is not None:
363                    raw_arcs.append((source_index, root_index, label, "right"))
364                # else: show_root=False (or no surviving root edge at all,
365                # impossible here since this IS one) -- skip silently, same
366                # as mermaid.py's own show_root=False behavior.
367                continue
368
369            target_index = index_by_id.get(related_id)
370            if target_index is None:
371                target_tok = by_id.get(related_id)
372                if target_tok is None:
373                    reason = "target id is not present in tokengraph"
374                elif target_tok.tokentype in IMPLIED_TOKENTYPES:
375                    # A relation naming an implied/elided token as its
376                    # target is exactly the case this module now INCLUDES
377                    # the target for (see this module's own docstring), so
378                    # this branch should never actually run for a relation
379                    # whose own source survives -- kept as a defensive
380                    # fallback, same "guard it anyway" spirit as the
381                    # self-relation check below, rather than assumed
382                    # impossible.
383                    reason = (
384                        "target is an implied/elided token that no "
385                        "surviving token's own relation named as a target, "
386                        "so it never gained a position to draw an arc to"
387                    )
388                elif target_tok.tokentype == "punctuation":
389                    reason = "target is a punctuation token, omitted from the diagram"
390                else:
391                    reason = "target is excluded by the aat_depth cutoff"
392                warnings.append(
393                    f"skipped arc {tok.id} -[{label}]-> {related_id}: {reason}"
394                )
395                continue
396
397            if target_index == source_index:
398                warnings.append(
399                    f"skipped arc {tok.id} -[{label}]-> {related_id}: "
400                    "a token cannot relate to itself"
401                )
402                continue
403
404            start, end = sorted((source_index, target_index))
405            direction = "left" if target_index == start else "right"
406            raw_arcs.append((start, end, label, direction))
407
408    data = {
409        "words": [
410            {"text": text, "tag": tag} for text, tag in zip(word_texts, word_tags)
411        ],
412        "arcs": [
413            {"start": start, "end": end, "label": label, "dir": direction}
414            for start, end, label, direction in raw_arcs
415        ],
416    }
417    return data, word_token_ids, warnings

Build displaCy's own "manual" dep-style data -- {"words": [{"text": ..., "tag": ...}, ...], "arcs": [{"start": int, "end": int, "label": ..., "dir": "left" | "right"}, ...]} -- from tokengraph, in the same shape https://spacy.io/usage/visualizers documents for displacy.render(data, style="dep", manual=True).

Words. One word per tokengraph entry, IN TOKENGRAPH ORDER (already reading order -- see Sentence's own docstring), EXCEPT: punctuation, which (unlike mermaid.py's node graph, which draws it) this module omits entirely, for a cleaner "just the words" diagram; and an implied/elided token (tokentype in IMPLIED_TOKENTYPES) that nothing else's relation ever names as a target, which has no surface realization to give it a position at all (see this module's own docstring) and is skipped here too. An implied/elided token that SOME surviving token's relation DOES name as a target is instead included, positioned immediately after that other token (the last one, in reading order, if more than one names the same target) -- see this module's own docstring for the full rule. Each word's own "tag" is its tokentype string verbatim ("enclitic", "numeral", "praenomen", "abbreviation", "implied sum", "continued discourse", or "implied subject" -- "punctuation" can't appear here at all, since punctuation never gets a word; see above), EXCEPT "lexical" -- the ordinary case, and by far the most common tokentype in most sentences -- which gets an empty tag instead, so the diagram calls out only the genuinely informative minority. This still isn't a part-of-speech tag in spaCy's own sense (this codebase's tokentype vocabulary doesn't have one), but the closest equivalent this data has, displayed the same way a POS tag would be. A word's own TEXT is tok.token, except for an included implied/elided token (tok.token is always None), which uses mermaid.py's own token_label() placeholder instead -- the same label mermaid.py's diagram already shows for that same token.

If show_root (default True), one further, synthetic word -- displayed text "ROOT", tag "" -- is appended at the END of the word list (see this module's own docstring), but ONLY if at least one surviving token actually has a 'root' relation to draw into it (so a tokengraph with no independent verb at all -- e.g. one sentence sliced out of a larger passage -- never gets an orphan ROOT word), mirroring mermaid.py's own show_root convention of only adding its dedicated node when there's an edge to draw into it.

Arcs. One arc per resolved relatedtoken1/relationship1 or relatedtoken2/relationship2 pair (both checked, same as mermaid.py): start/end are the LOWER/HIGHER of the two words' own indices into the returned "words" list (displaCy's own start < end requirement), and dir says which end the arrow points at -- "left" if the token this relation points AT (relatedtoken1/2, i.e. the more syntactically prominent side of the pair -- the governing verb, the antecedent, the noun a genitive depends on, and so on) ends up at the lower (start) index, "right" if it ends up at the higher (end) index. This is a convention of THIS module's own choosing (there's no real spaCy dependency parse underneath it for the "arrow points at the head" idiom to matter for automatically) -- but a self-consistent one: an arc's arrowhead always marks the token that its OWN source token "relates to", regardless of which one comes first in the sentence.

A relation is skipped, and reported as a warning, rather than turned into a malformed arc, when: its target is a punctuation token (dropped from the word list, per above -- there's no word index for one); its target was excluded by aat_depth (see below); its target id isn't in tokengraph at all (the same "referentially malformed" case mermaid.py already guards against); or its target IS its own source token (a self-relation -- never valid, but defensively guarded against rather than assumed impossible). A relation whose target is an implied/elided token, by contrast, is exactly the case that gets the target INCLUDED (per above) rather than skipped -- so this specific reason should never actually surface in practice for a relation whose own source survives; it's kept as a defensive fallback (same "guard it anyway" spirit as the self-relation check) rather than assumed impossible.

aat_depth, if given, drops every token belonging to a verbal expression DEEPER than that AAT-graph depth (verbal_units. compute_aat_depths()) from the word list entirely -- the exact same filter, and the exact same depth notion, tokengraph_to_mermaid()'s and tokengraph_to_dot()'s own aat_depth parameters already use, so one depth cutoff (e.g. a single slider in a UI) can drive all three diagrams identically. A token belonging to no verbal unit at all defaults to depth 0 (kept), same "can't determine, default to root level" fallback used throughout this codebase. A dropped token's own relations are simply never drawn (no warning -- there's no arc AT ALL to skip, since the token isn't iterated for its own outgoing relations once it has no word slot); a SURVIVING token's relation that points AT a dropped one is what gets skipped-with-a-warning. An implied/elided token is never itself excluded by aat_depth (same exemption tokengraph_to_mermaid()'s/tokengraph_to_dot()'s own aat_depth already give it) -- whether it appears in the diagram at all is decided solely by whether something links to it, per above, regardless of depth. Omit aat_depth (or pass None, the default) to show every word; a negative aat_depth raises ValueError.

Returns (data, word_token_ids, warnings). word_token_ids is a list parallel to data["words"], giving the ORIGINAL tokengraph id each word came from (None for the synthetic ROOT word, if present -- an included implied/elided token gets its own real id here, same as any other word) -- for a caller (this module's own tokengraph_to_displacy_svg()) that wants to recover "which token is word index 3" without re-deriving this function's own word-selection/ordering rules. warnings is the list of skipped-relation messages described above.

def tokengraph_to_displacy_svg( tokengraph: List[TokenAnalysis], show_root: bool = True, color_by_verbal_unit: bool = True, aat_depth: int | None = None, char_width: float = 8.0, min_word_width: float = 30.0, word_gap: float = 24.0, level_height: float = 42.0, arc_base_height: float = 24.0, margin: float = 30.0, font_family: str = 'Helvetica, Arial, sans-serif', word_font_size: float = 16.0, tag_font_size: float = 10.0, label_font_size: float = 11.0, stroke_color: str = '#4a4a4a', bg_color: str = '#ffffff', text_color: str = '#1a1a1a') -> Tuple[str, List[str]]:
448def tokengraph_to_displacy_svg(
449    tokengraph: List[TokenAnalysis],
450    show_root: bool = True,
451    color_by_verbal_unit: bool = True,
452    aat_depth: Optional[int] = None,
453    char_width: float = 8.0,
454    min_word_width: float = 30.0,
455    word_gap: float = 24.0,
456    level_height: float = 42.0,
457    arc_base_height: float = 24.0,
458    margin: float = 30.0,
459    font_family: str = "Helvetica, Arial, sans-serif",
460    word_font_size: float = 16.0,
461    tag_font_size: float = 10.0,
462    label_font_size: float = 11.0,
463    stroke_color: str = "#4a4a4a",
464    bg_color: str = "#ffffff",
465    text_color: str = "#1a1a1a",
466) -> Tuple[str, List[str]]:
467    """Render `tokengraph` as one self-contained displaCy-style SVG string
468    (word row at the bottom, tag row just above each word, dependency arcs
469    stacked above that -- see this module's own docstring for the overall
470    layout and the `show_root`/implied-token conventions).
471
472    Word boxes are sized from a plain character count (`char_width` px per
473    character, floored at `min_word_width`) -- a text-measurement heuristic,
474    not real font-glyph metrics (this module has no font-metrics library to
475    call), same "good enough, not exact" tolerance this codebase already
476    accepts from `_approximate_corpus_counts()`'s own word count and from
477    mermaid.py's/dot.py's own layout engines. A passage with unusually wide
478    or narrow words may look slightly uneven as a result, but every arc and
479    label stays unambiguous regardless.
480
481    `color_by_verbal_unit` (default True) fills each word's own box with
482    its verbal unit's color, via `verbal_units.assign_verbal_units()` /
483    `assign_verbal_unit_colors()` -- the SAME shared palette and
484    first-appearance ordering mermaid.py and rendering.py already use, so
485    all three visualizations of the same sentence agree on which color
486    means which clause. An INCLUDED implied/elided token (see this module's
487    own docstring for when that happens) is the one exception: it always
488    gets `verbal_units._IMPLIED_TOKEN_COLOR` -- the same dedicated
489    "caution" amber mermaid.py's own diagram already uses for the very same
490    token -- regardless of which verbal unit it belongs to, exactly
491    mermaid.py's own convention. An unlinked implied/elided token, dropped
492    from the word list entirely, still never gets a word box at all, same
493    as before this existed. The synthetic ROOT word (if present) is never
494    colored, same as mermaid.py's own dedicated 'root' node.
495
496    `aat_depth`, if given, is passed straight through to
497    `tokengraph_to_displacy_data()` -- the same AAT-graph depth cutoff
498    `tokengraph_to_mermaid()`'s/`tokengraph_to_dot()`'s own `aat_depth`
499    parameters use, so one depth value can drive all three diagrams
500    identically (e.g. one slider in `marimo/latin_syntaxer_review.py`).
501
502    Returns `(svg, warnings)` -- `warnings` is exactly
503    `tokengraph_to_displacy_data()`'s own return value, plus (if
504    `color_by_verbal_unit`) `assign_verbal_unit_colors()`'s own warning when
505    a passage has more than 8 distinct verbal units and colors repeat.
506    """
507    data, word_token_ids, warnings = tokengraph_to_displacy_data(
508        tokengraph, show_root=show_root, aat_depth=aat_depth
509    )
510    words = data["words"]
511    arcs = data["arcs"]
512
513    word_colors: Dict[int, Tuple[str, str, str]] = {}
514    if color_by_verbal_unit:
515        assignment = assign_verbal_units(tokengraph)
516        colors, color_warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment)
517        warnings = warnings + color_warnings
518        by_id = {tok.id: tok for tok in tokengraph}
519        for i, token_id in enumerate(word_token_ids):
520            if token_id is None:
521                continue  # the synthetic ROOT word -- never colored
522            tok = by_id.get(token_id)
523            if tok is not None and tok.tokentype in IMPLIED_TOKENTYPES:
524                # An included implied/elided token always gets the same
525                # dedicated "caution" amber mermaid.py's own diagram
526                # already uses for it, regardless of which verbal unit it
527                # belongs to -- see this function's own docstring.
528                word_colors[i] = _IMPLIED_TOKEN_COLOR
529                continue
530            unit_id = assignment.get(token_id)
531            if unit_id in colors:
532                word_colors[i] = colors[unit_id]
533
534    # --- horizontal layout: one box per word, left to right ---
535    left_edges: List[float] = []
536    widths: List[float] = []
537    x = margin
538    for word in words:
539        w = max(min_word_width, char_width * len(word["text"]) + 12.0)
540        left_edges.append(x)
541        widths.append(w)
542        x += w + word_gap
543    total_width = (x - word_gap if words else x) + margin
544    centers = [left_edges[i] + widths[i] / 2 for i in range(len(words))]
545
546    # --- vertical layout: stack overlapping arcs into levels ---
547    spans = [(arc["start"], arc["end"]) for arc in arcs]
548    arc_levels = _assign_arc_levels(spans)
549    max_level = max(arc_levels, default=-1)
550
551    y_arc_foot = margin + (max_level + 1) * level_height + arc_base_height
552    y_tag = y_arc_foot + tag_font_size + 4
553    y_word = y_tag + word_font_size + 2
554    total_height = y_word + margin
555
556    svg_parts = [
557        f'<svg xmlns="http://www.w3.org/2000/svg" width="{total_width:.0f}" '
558        f'height="{total_height:.0f}" viewBox="0 0 {total_width:.0f} {total_height:.0f}" '
559        f'font-family="{_xml_escape(font_family)}">',
560        f'<rect x="0" y="0" width="{total_width:.0f}" height="{total_height:.0f}" fill="{bg_color}"/>',
561    ]
562
563    # Word boxes (colored background, if any) and text.
564    for i, word in enumerate(words):
565        is_root_word = word_token_ids[i] is None
566        if i in word_colors:
567            fill, stroke, fill_text_color = word_colors[i]
568            box_top = y_word - word_font_size
569            box_height = (y_word - box_top) + 4
570            svg_parts.append(
571                f'<rect x="{left_edges[i]:.1f}" y="{box_top:.1f}" '
572                f'width="{widths[i]:.1f}" height="{box_height:.1f}" rx="4" '
573                f'fill="{fill}" stroke="{stroke}" stroke-width="1"/>'
574            )
575            word_text_color = fill_text_color
576        else:
577            word_text_color = "#7a7a7a" if is_root_word else text_color
578        font_style = ' font-style="italic"' if is_root_word else ""
579        svg_parts.append(
580            f'<text x="{centers[i]:.1f}" y="{y_word:.1f}" text-anchor="middle" '
581            f'font-size="{word_font_size:.1f}" fill="{word_text_color}"{font_style}>'
582            f'{_xml_escape(word["text"])}</text>'
583        )
584        if word["tag"]:
585            svg_parts.append(
586                f'<text x="{centers[i]:.1f}" y="{y_tag:.1f}" text-anchor="middle" '
587                f'font-size="{tag_font_size:.1f}" fill="#9a9a9a">'
588                f'{_xml_escape(word["tag"])}</text>'
589            )
590
591    # Arcs: a cubic-bezier curve from one word's foot to another's, an
592    # arrowhead at whichever end `dir` names, and a label at the peak with
593    # a background patch behind it so the label reads clearly against the
594    # arc line passing beneath it.
595    for arc, level in zip(arcs, arc_levels):
596        start_x = centers[arc["start"]]
597        end_x = centers[arc["end"]]
598        y_peak = y_arc_foot - (arc_base_height + level * level_height)
599        svg_parts.append(
600            f'<path d="M{start_x:.1f},{y_arc_foot:.1f} '
601            f'C{start_x:.1f},{y_peak:.1f} {end_x:.1f},{y_peak:.1f} {end_x:.1f},{y_arc_foot:.1f}" '
602            f'fill="none" stroke="{stroke_color}" stroke-width="1.5"/>'
603        )
604        target_x = start_x if arc["dir"] == "left" else end_x
605        arrow_w, arrow_h = 7.0, 7.0
606        svg_parts.append(
607            f'<polygon points="{target_x - arrow_w / 2:.1f},{y_arc_foot - arrow_h:.1f} '
608            f'{target_x + arrow_w / 2:.1f},{y_arc_foot - arrow_h:.1f} '
609            f'{target_x:.1f},{y_arc_foot:.1f}" fill="{stroke_color}"/>'
610        )
611        label = arc["label"]
612        label_w = len(label) * label_font_size * 0.6 + 10
613        mid_x = (start_x + end_x) / 2
614        svg_parts.append(
615            f'<rect x="{mid_x - label_w / 2:.1f}" y="{y_peak - label_font_size:.1f}" '
616            f'width="{label_w:.1f}" height="{label_font_size + 4:.1f}" fill="{bg_color}"/>'
617        )
618        svg_parts.append(
619            f'<text x="{mid_x:.1f}" y="{y_peak + 2:.1f}" text-anchor="middle" '
620            f'font-size="{label_font_size:.1f}" fill="{stroke_color}">'
621            f'{_xml_escape(label)}</text>'
622        )
623
624    svg_parts.append("</svg>")
625    return "\n".join(svg_parts), warnings

Render tokengraph as one self-contained displaCy-style SVG string (word row at the bottom, tag row just above each word, dependency arcs stacked above that -- see this module's own docstring for the overall layout and the show_root/implied-token conventions).

Word boxes are sized from a plain character count (char_width px per character, floored at min_word_width) -- a text-measurement heuristic, not real font-glyph metrics (this module has no font-metrics library to call), same "good enough, not exact" tolerance this codebase already accepts from _approximate_corpus_counts()'s own word count and from mermaid.py's/dot.py's own layout engines. A passage with unusually wide or narrow words may look slightly uneven as a result, but every arc and label stays unambiguous regardless.

color_by_verbal_unit (default True) fills each word's own box with its verbal unit's color, via verbal_units.assign_verbal_units() / assign_verbal_unit_colors() -- the SAME shared palette and first-appearance ordering mermaid.py and rendering.py already use, so all three visualizations of the same sentence agree on which color means which clause. An INCLUDED implied/elided token (see this module's own docstring for when that happens) is the one exception: it always gets verbal_units._IMPLIED_TOKEN_COLOR -- the same dedicated "caution" amber mermaid.py's own diagram already uses for the very same token -- regardless of which verbal unit it belongs to, exactly mermaid.py's own convention. An unlinked implied/elided token, dropped from the word list entirely, still never gets a word box at all, same as before this existed. The synthetic ROOT word (if present) is never colored, same as mermaid.py's own dedicated 'root' node.

aat_depth, if given, is passed straight through to tokengraph_to_displacy_data() -- the same AAT-graph depth cutoff tokengraph_to_mermaid()'s/tokengraph_to_dot()'s own aat_depth parameters use, so one depth value can drive all three diagrams identically (e.g. one slider in marimo/latin_syntaxer_review.py).

Returns (svg, warnings) -- warnings is exactly tokengraph_to_displacy_data()'s own return value, plus (if color_by_verbal_unit) assign_verbal_unit_colors()'s own warning when a passage has more than 8 distinct verbal units and colors repeat.

def save_displacy_html( tokengraph: List[TokenAnalysis], path: str, caption: str | None = None, show_root: bool = True, color_by_verbal_unit: bool = True, **svg_kwargs) -> List[str]:
628def save_displacy_html(
629    tokengraph: List[TokenAnalysis],
630    path: str,
631    caption: Optional[str] = None,
632    show_root: bool = True,
633    color_by_verbal_unit: bool = True,
634    **svg_kwargs,
635) -> List[str]:
636    """Render `tokengraph` (`tokengraph_to_displacy_svg()`) and write it to
637    `path` as one self-contained HTML page -- unlike mermaid.py's `.mmd` or
638    dot.py's `.dot` output, this needs no separate rendering step: open the
639    file directly in a browser.
640
641    `caption`, if given, is shown as a heading above the diagram; if
642    omitted (the default), it's `rendering.tokengraph_to_text(tokengraph)`
643    -- the sentence's own reconstructed plain text -- so the page is
644    self-explanatory without also having the original passage on hand.
645    Pass `caption=""` for no heading at all.
646
647    `**svg_kwargs` are passed straight through to
648    `tokengraph_to_displacy_svg()` (e.g. `aat_depth=`, `word_gap=`,
649    `stroke_color=`, and so on) for any filtering/layout/color tweaks
650    beyond `show_root`/`color_by_verbal_unit`, which are named here
651    explicitly since they're the two options most callers actually reach
652    for.
653
654    Returns `tokengraph_to_displacy_svg()`'s own warnings.
655    """
656    svg, warnings = tokengraph_to_displacy_svg(
657        tokengraph,
658        show_root=show_root,
659        color_by_verbal_unit=color_by_verbal_unit,
660        **svg_kwargs,
661    )
662    if caption is None:
663        caption = tokengraph_to_text(tokengraph)
664
665    heading = f"<h1>{_xml_escape(caption)}</h1>\n" if caption else ""
666    html = (
667        "<!DOCTYPE html>\n"
668        '<html lang="en">\n<head>\n<meta charset="utf-8">\n'
669        f"<title>{_xml_escape(caption) if caption else 'Dependency diagram'}</title>\n"
670        "<style>body { font-family: Helvetica, Arial, sans-serif; margin: 2rem; }</style>\n"
671        "</head>\n<body>\n"
672        f"{heading}"
673        f"{svg}\n"
674        "</body>\n</html>\n"
675    )
676    with open(path, "w", encoding="utf-8") as f:
677        f.write(html)
678    return warnings

Render tokengraph (tokengraph_to_displacy_svg()) and write it to path as one self-contained HTML page -- unlike mermaid.py's .mmd or dot.py's .dot output, this needs no separate rendering step: open the file directly in a browser.

caption, if given, is shown as a heading above the diagram; if omitted (the default), it's rendering.tokengraph_to_text(tokengraph) -- the sentence's own reconstructed plain text -- so the page is self-explanatory without also having the original passage on hand. Pass caption="" for no heading at all.

**svg_kwargs are passed straight through to tokengraph_to_displacy_svg() (e.g. aat_depth=, word_gap=, stroke_color=, and so on) for any filtering/layout/color tweaks beyond show_root/color_by_verbal_unit, which are named here explicitly since they're the two options most callers actually reach for.

Returns tokengraph_to_displacy_svg()'s own warnings.

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    Flagging ambiguity: when a specific call above is genuinely ambiguous
480    -- the passage plausibly supports more than one defensible reading, not
481    just an ordinary judgment call you're confident in -- say so explicitly
482    in your reasoning, rather than silently picking one reading and moving
483    on. Name the specific token(s) or construction involved, the
484    alternative reading you considered, and briefly why you chose the one
485    you did. The clearest example above is an ambiguous participle's
486    attributive-vs-circumstantial reading: the rule to PREFER the
487    circumstantial reading when genuinely uncertain still applies
488    unchanged, but note in your reasoning that you made that call and why,
489    the same way you would for any other close judgment (e.g. which of two
490    candidate governing verbs an ambiguous dependent clause attaches to,
491    or whether a participle is predicate or purely attributive in the
492    first place). This does not change any tie-breaking rule given above --
493    it only adds a requirement to surface that a close call was made at
494    all, so a human reviewing this analysis later knows exactly where to
495    look rather than having to independently re-derive every judgment call
496    to find the uncertain ones. Do not flag routine analysis you're
497    confident in just to pad your reasoning with hedges -- this is for
498    genuine, specific ambiguity only.
499    """
500 
501    passage: str = dspy.InputField(desc="The Latin passage to analyze, exactly as written.")
502    tokens: List[Token] = dspy.InputField(
503        desc="Pre-segmented tokens of the passage, in order, with fixed ids. Reference these ids in your output; do not create new ones."
504    )
505    verbalunits: List[VerbalExpression] = dspy.OutputField(
506        desc="One entry per verbal expression (finite verb; infinitive used in indirect speech; or predicate-sense participle) in the passage."
507    )
508    tokengraph: List[TokenAnalysis] = dspy.OutputField(
509        desc=(
510            "One entry per token in `tokens`, in the same order, with its "
511            "type and any relations -- PLUS one additional entry for each "
512            "implied/elided token you add (see this signature's docstring), "
513            "positioned where that token's clause falls in reading order."
514        )
515    )

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.

Flagging ambiguity: when a specific call above is genuinely ambiguous -- the passage plausibly supports more than one defensible reading, not just an ordinary judgment call you're confident in -- say so explicitly in your reasoning, rather than silently picking one reading and moving on. Name the specific token(s) or construction involved, the alternative reading you considered, and briefly why you chose the one you did. The clearest example above is an ambiguous participle's attributive-vs-circumstantial reading: the rule to PREFER the circumstantial reading when genuinely uncertain still applies unchanged, but note in your reasoning that you made that call and why, the same way you would for any other close judgment (e.g. which of two candidate governing verbs an ambiguous dependent clause attaches to, or whether a participle is predicate or purely attributive in the first place). This does not change any tie-breaking rule given above -- it only adds a requirement to surface that a close call was made at all, so a human reviewing this analysis later knows exactly where to look rather than having to independently re-derive every judgment call to find the uncertain ones. Do not flag routine analysis you're confident in just to pad your reasoning with hedges -- this is for genuine, specific ambiguity only.

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.\n\nFlagging ambiguity: when a specific call above is genuinely ambiguous\n-- the passage plausibly supports more than one defensible reading, not\njust an ordinary judgment call you\'re confident in -- say so explicitly\nin your reasoning, rather than silently picking one reading and moving\non. Name the specific token(s) or construction involved, the\nalternative reading you considered, and briefly why you chose the one\nyou did. The clearest example above is an ambiguous participle\'s\nattributive-vs-circumstantial reading: the rule to PREFER the\ncircumstantial reading when genuinely uncertain still applies\nunchanged, but note in your reasoning that you made that call and why,\nthe same way you would for any other close judgment (e.g. which of two\ncandidate governing verbs an ambiguous dependent clause attaches to,\nor whether a participle is predicate or purely attributive in the\nfirst place). This does not change any tie-breaking rule given above --\nit only adds a requirement to surface that a close call was made at\nall, so a human reviewing this analysis later knows exactly where to\nlook rather than having to independently re-derive every judgment call\nto find the uncertain ones. Do not flag routine analysis you\'re\nconfident in just to pad your reasoning with hedges -- this is for\ngenuine, specific ambiguity only.' 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]:
153def analyze_string(passage: str, citation: str = "") -> Tuple[List[Sentence], list]:
154    """Convenience wrapper for the common case of a single string rather
155    than a list of citation-labeled CitedText sources -- kept here so
156    existing callers (syntaxer_main.py, the marimo notebook) have a
157    one-string entry point rather than needing to build a CitedText list
158    themselves for the ordinary case of one passage from one source.
159 
160    Wraps `passage` as one CitedText (using `citation` if given, else an
161    empty string -- fine for callers that don't track citations) and runs
162    it through analyze_sources(). Returns (sentences, results) -- the exact
163    same shape analyze_sources() returns, one entry per sentence
164    segmentation finds in `passage`, in order.
165 
166    `passage` may contain any number of sentences: each is segmented and
167    analyzed successively, same as if you'd called analyze_sources() with
168    one CitedText yourself. (An earlier version of this function raised
169    ValueError on multi-sentence input and returned a single (tokens,
170    result) pair for exactly one sentence; callers written against that
171    contract need to change to unpack (sentences, results) and iterate.)
172    """
173    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]:
525def validate(tokens: List[Token], result) -> List[str]:
526    """Check that every id the LM produced actually exists among `tokens`
527    -- OR is a legitimately new implied token (tokentype in
528    IMPLIED_TOKENTYPES -- 'implied sum', 'continued discourse', or
529    'implied subject'; see SentenceAnalysis's docstring) -- and that implied
530    tokens themselves are well-formed. Returns a list of human-readable
531    problem descriptions (empty if clean).
532
533    'root' is a special sentinel value for an independent verb's own
534    relatedtoken1 (see SentenceAnalysis's docstring) -- it is never treated as
535    an unknown id, but syntax_model.md also requires that no actual token
536    ever be assigned the id 'root', so that's checked here too.
537
538    Implied tokens get their own, narrower checks: a tokengraph entry
539    claiming an IMPLIED_TOKENTYPES value must use a genuinely NEW id (not
540    one already in `tokens`) and must leave `token` unset (None) -- getting
541    either wrong is exactly the kind of malformed output this function
542    exists to catch, not a legitimate implied token. A non-implied entry,
543    conversely, must use one of `tokens`' own ids and must NOT have
544    `token=None` -- only an IMPLIED_TOKENTYPES tokentype may omit real
545    surface text. This check is purely structural either way (new id,
546    empty text) -- it does NOT also require an 'implied sum'/'continued
547    discourse' token to have a matching `verbalunits` entry, or an
548    'implied subject' token to lack one; that distinction is documented
549    behavior (see SentenceAnalysis's docstring), not something this function
550    enforces."""
551    valid_ids = {t.id for t in tokens}
552    problems = []
553
554    if "root" in valid_ids:
555        problems.append(
556            "token id 'root' is reserved as the sentinel relatedtoken1 "
557            "value for independent verbs and must not be assigned to an "
558            "actual token"
559        )
560
561    implied_ids = {tok.id for tok in result.tokengraph if tok.tokentype in IMPLIED_TOKENTYPES}
562    known_ids = valid_ids | implied_ids
563
564    for tok in result.tokengraph:
565        if tok.tokentype in IMPLIED_TOKENTYPES:
566            if tok.id in valid_ids:
567                problems.append(
568                    f"tokengraph entry {tok.id!r} is tokentype={tok.tokentype!r} but "
569                    "reuses an id already in the input `tokens` list -- an "
570                    "implied token must use a new id"
571                )
572            if tok.token is not None:
573                problems.append(
574                    f"tokengraph entry {tok.id!r} is tokentype={tok.tokentype!r} but "
575                    f"has a non-None token value {tok.token!r} -- an implied "
576                    "token's text must be left unset"
577                )
578        else:
579            if tok.id not in valid_ids:
580                problems.append(f"tokengraph entry has unknown id {tok.id!r}")
581            if tok.token is None:
582                allowed = "/".join(repr(t) for t in sorted(IMPLIED_TOKENTYPES))
583                problems.append(
584                    f"tokengraph entry {tok.id!r} has token=None but "
585                    f"tokentype={tok.tokentype!r} -- only {allowed} may "
586                    "omit surface text"
587                )
588        for field in ("relatedtoken1", "relatedtoken2"):
589            val = getattr(tok, field)
590            if val is not None and val != "root" and val not in known_ids:
591                problems.append(f"token {tok.id!r} {field}={val!r} is not a known token id")
592
593    for vu in result.verbalunits:
594        if vu.id not in known_ids:
595            problems.append(f"verbal expression id {vu.id!r} is not a known token id")
596
597    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], *, progress_callback: Callable[[int, int, Sentence], NoneType] | None = None, on_sentence_error: Callable[[Sentence, Exception], NoneType] | None = None) -> Tuple[List[Sentence], list]:
 38def analyze_sources(
 39    sources: List[CitedText],
 40    *,
 41    progress_callback: Optional[Callable[[int, int, Sentence], None]] = None,
 42    on_sentence_error: Optional[Callable[[Sentence, Exception], None]] = None,
 43) -> Tuple[List[Sentence], list]:
 44    """Segment `sources` into citation-aware sentences, run each sentence's
 45    tokens through SentenceAnalysis, and validate each result.
 46
 47    Returns (sentences, results): results[i] is the SentenceAnalysis result
 48    for sentences[i], same order, one entry per sentence.
 49
 50    Each sentence's SentenceAnalysis call goes through
 51    `token_budget.analyze_with_retry()` rather than calling `analyze()`
 52    directly, so a sentence whose analysis needs more output than a fixed
 53    `max_tokens` would allow (a long or deeply subordinated sentence) gets
 54    an estimated, appropriately-sized budget up front, and a retry with a
 55    larger one if it still comes back truncated -- see token_budget.py's
 56    module docstring for the full design.
 57
 58    `progress_callback`, if given, is called as `progress_callback(index,
 59    total, sentence)` immediately BEFORE each sentence's own
 60    SentenceAnalysis call starts -- `index` is that sentence's own 0-based
 61    position, `total` is `len(sentences)` (already known at that point,
 62    since segmentation has already finished), and `sentence` is that
 63    Sentence itself (e.g. for its own first token's citation, to report
 64    something more specific than a bare counter). This is purely a
 65    reporting hook for a caller that wants to show progress across a
 66    potentially long run of per-sentence LM calls -- see
 67    `utilities/analyze_ctsdata_to_files.py`'s own use of it -- and never
 68    affects this function's own behavior; omitted (the default), this
 69    function's return value and every other observable effect are exactly
 70    as they were before this parameter existed.
 71
 72    `on_sentence_error`, if given, is called as `on_sentence_error(sentence,
 73    exc)` whenever that one sentence's own `analyze_with_retry()` call
 74    raises -- even after that function's own retries are exhausted --
 75    instead of letting the exception propagate out of THIS call entirely.
 76    That one sentence is then left out of both returned lists (`sentences`
 77    and `results` stay parallel and the same length as each other, just
 78    possibly shorter than `segment_sources()`'s own output, by however many
 79    sentences failed); every OTHER sentence in `sources` -- including ones
 80    segmented into the SAME call as the one that failed -- still gets
 81    analyzed normally. Omitted (the default, `None`), this function's
 82    behavior is exactly what it always was: a failing sentence's exception
 83    propagates immediately, and this call returns nothing at all. Existing
 84    callers that don't pass it (`analyze_string()`,
 85    `analyze_selected_passages()`, `analyze_ctsdata()`, `syntaxer_main.py`,
 86    every marimo notebook) are unaffected by this parameter's existence --
 87    that's the right default for a single hand-typed passage in an
 88    interactive session, where the real exception should surface right
 89    away rather than being swallowed into a report nobody's watching yet.
 90    Only a script wide enough to want to survive one bad passage out of
 91    many should pass this -- see `utilities/analyze_ctsdata_to_files.py`'s
 92    own use of it, and `arsgrammatica.FailedPassage`/`write_warnings_report()`
 93    for a ready-made way to collect and report what it catches.
 94
 95    Before any SentenceAnalysis call, every token whose citation is a CTS
 96    URN (`token_ids.assign_passage_scoped_ids()`) has its id rewritten to a
 97    passage-scoped composite id (e.g. `1.1.t0`) instead of whatever bare id
 98    `segment_sources()` gave it. This makes a given passage's own tokens
 99    get the SAME ids every time it's analyzed, regardless of what other
100    passages happened to be segmented alongside it in this particular
101    call -- which in turn means the SentenceAnalysis prompt for that
102    passage is identical across runs/callers, so DSPy's own LM response
103    cache can actually be shared between them (see that module's own
104    docstring for the full rationale). A token with no citation, or a
105    citation that isn't a 5-part CTS URN, keeps whatever id
106    `segment_sources()` assigned it.
107    """
108    sentences = assign_passage_scoped_ids(segment_sources(sources))
109
110    kept_sentences = []
111    results = []
112    for index, sentence in enumerate(sentences):
113        if progress_callback is not None:
114            progress_callback(index, len(sentences), sentence)
115
116        try:
117            result = analyze_with_retry(passage=_render_sentence_text(sentence), tokens=sentence.tokens)
118        except Exception as exc:
119            if on_sentence_error is None:
120                raise
121            on_sentence_error(sentence, exc)
122            continue
123
124        problems = validate(sentence.tokens, result)
125        if problems:
126            first_id = sentence.tokens[0].id if sentence.tokens else "?"
127            # To stderr, not stdout -- same convention every CLI script
128            # built on this function keeps for its OWN output (e.g.
129            # utilities/analyze_ctsdata_to_files.py's "Wrote ..." lines),
130            # so a problem surfacing here can never corrupt a caller's own
131            # piped/redirected stdout.
132            print(f"Validation warnings (sentence starting at {first_id}):", file=sys.stderr)
133            for p in problems:
134                print(f"  - {p}", file=sys.stderr)
135
136        kept_sentences.append(sentence)
137        results.append(result)
138
139    return kept_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.

progress_callback, if given, is called as progress_callback(index, total, sentence) immediately BEFORE each sentence's own SentenceAnalysis call starts -- index is that sentence's own 0-based position, total is len(sentences) (already known at that point, since segmentation has already finished), and sentence is that Sentence itself (e.g. for its own first token's citation, to report something more specific than a bare counter). This is purely a reporting hook for a caller that wants to show progress across a potentially long run of per-sentence LM calls -- see utilities/analyze_ctsdata_to_files.py's own use of it -- and never affects this function's own behavior; omitted (the default), this function's return value and every other observable effect are exactly as they were before this parameter existed.

on_sentence_error, if given, is called as on_sentence_error(sentence, exc) whenever that one sentence's own analyze_with_retry() call raises -- even after that function's own retries are exhausted -- instead of letting the exception propagate out of THIS call entirely. That one sentence is then left out of both returned lists (sentences and results stay parallel and the same length as each other, just possibly shorter than segment_sources()'s own output, by however many sentences failed); every OTHER sentence in sources -- including ones segmented into the SAME call as the one that failed -- still gets analyzed normally. Omitted (the default, None), this function's behavior is exactly what it always was: a failing sentence's exception propagates immediately, and this call returns nothing at all. Existing callers that don't pass it (analyze_string(), analyze_selected_passages(), analyze_ctsdata(), syntaxer_main.py, every marimo notebook) are unaffected by this parameter's existence -- that's the right default for a single hand-typed passage in an interactive session, where the real exception should surface right away rather than being swallowed into a report nobody's watching yet. Only a script wide enough to want to survive one bad passage out of many should pass this -- see utilities/analyze_ctsdata_to_files.py's own use of it, and arsgrammatica.FailedPassage/write_warnings_report() for a ready-made way to collect and report what it catches.

Before any SentenceAnalysis call, every token whose citation is a CTS URN (token_ids.assign_passage_scoped_ids()) has its id rewritten to a passage-scoped composite id (e.g. 1.1.t0) instead of whatever bare id segment_sources() gave it. This makes a given passage's own tokens get the SAME ids every time it's analyzed, regardless of what other passages happened to be segmented alongside it in this particular call -- which in turn means the SentenceAnalysis prompt for that passage is identical across runs/callers, so DSPy's own LM response cache can actually be shared between them (see that module's own docstring for the full rationale). A token with no citation, or a citation that isn't a 5-part CTS URN, keeps whatever id segment_sources() assigned it.

def analyze_selected_passages( passage_ids: List[str], cited_texts: List[CitedText]) -> Tuple[List[Sentence], list]:
176def analyze_selected_passages(
177    passage_ids: List[str], cited_texts: List[CitedText]
178) -> Tuple[List[Sentence], list]:
179    """Select the entries of `cited_texts` whose `citation` is in
180    `passage_ids`, then run exactly those through analyze_sources() --
181    e.g. after read_ctsdata() has loaded a whole source file but only some
182    of its passages are wanted for this run.
183
184    Selected passages are analyzed in `cited_texts`' OWN order, not
185    `passage_ids`' order -- the same convention
186    marimo/latin_syntaxer_ctsdata.py's own `selected_rows` cell already
187    uses, and for the same reason: segment_sources() (inside
188    analyze_sources()) treats consecutive sources as potentially sharing a
189    sentence, so an out-of-file-order source list could segment
190    incorrectly, or produce citations in a confusing order.
191    `passage_ids` therefore acts purely as a filter -- which passages to
192    include -- never as a sort key.
193
194    Raises ValueError, naming every missing id at once, if any entry of
195    `passage_ids` doesn't match any `cited_texts` citation -- a typo'd or
196    stale passage id fails loudly here rather than silently analyzing
197    fewer passages than asked for.
198
199    Returns (sentences, results) -- the exact same shape analyze_sources()
200    returns, spanning only the selected passages.
201    """
202    wanted = set(passage_ids)
203    selected = [ct for ct in cited_texts if ct.citation in wanted]
204
205    found = {ct.citation for ct in selected}
206    missing = sorted(pid for pid in wanted if pid not in found)
207    if missing:
208        raise ValueError(
209            f"passage id(s) not found in cited_texts: {missing!r}"
210        )
211
212    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]:
215def analyze_ctsdata(path: str, delimiter: str = "|") -> Tuple[List[Sentence], list]:
216    """Convenience wrapper for the common case of a whole `#!ctsdata` (CEX)
217    source file on disk, rather than an in-memory `List[CitedText]` --
218    reads `path` with `read_ctsdata()` (ctsdata.py) and runs the result
219    straight through `analyze_sources()`, the same "read a CEX corpus,
220    then analyze it" pair every entry point in this codebase that starts
221    from a CEX file already does by hand (`utilities/tokenize_ctsdata.py`,
222    `utilities/analyze_ctsdata_to_files.py`, `utilities/
223    group_ctsdata_by_sentence.py`, each of the marimo ctsdata notebooks).
224
225    `delimiter` is passed straight through to `read_ctsdata()` -- it's the
226    SOURCE file's own column delimiter ('|' by default, matching every
227    other serialized format in this codebase), not related to anything
228    `analyze_sources()` itself does.
229
230    Returns `(sentences, results)` -- the exact same shape
231    `analyze_sources()` returns, spanning every passage in `path`, in the
232    file's own order. Every passage in the file is analyzed; use
233    `analyze_selected_passages()` instead if only some of them are wanted.
234
235    Propagates `read_ctsdata()`'s own `ValueError`/`OSError` as-is for a
236    missing file or a malformed `#!ctsdata` block (see that function's own
237    docstring for exactly what's checked) -- raised before any LM call is
238    made, same as every CLI entry point that reads a CEX file up front for
239    the same reason.
240    """
241    cited_texts = read_ctsdata(path, delimiter=delimiter)
242    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:
142def combined_tokengraph(results) -> list:
143    """Concatenate every sentence result's tokengraph, in order, into one
144    flat list spanning the whole input -- since token ids are global,
145    tokengraph_to_mermaid() (mermaid.py) needs no changes at all to render
146    this as one diagram for a multi-sentence, multi-citation passage."""
147    combined = []
148    for result in results:
149        combined.extend(result.tokengraph)
150    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 ([], []).

def assign_passage_scoped_ids( sentences: List[Sentence]) -> List[Sentence]:
 71def assign_passage_scoped_ids(sentences: List[Sentence]) -> List[Sentence]:
 72    """Return a copy of `sentences` with every CTS-URN-citation token's `id`
 73    replaced by a passage-scoped composite id (`f"{passage}.t{n}"` -- see
 74    this module's own docstring), and every other token's id left exactly
 75    as it was.
 76
 77    Each distinct passage segment gets its own `t0, t1, t2, ...` counter,
 78    assigned in the order that passage's tokens are first encountered
 79    across ALL of `sentences` (not per-sentence -- a passage whose text
 80    turns out to span more than one sentence keeps counting up rather than
 81    restarting). Two different tokens can therefore never collide: either
 82    they're in different passages (different composite id prefix) or the
 83    same passage (same prefix, but a strictly increasing local index).
 84
 85    Raises ValueError, naming both work prefixes involved, if the same
 86    passage segment is ever seen under two different CTS works -- the one
 87    case where taking just a citation's final URN segment as the whole key
 88    would silently produce a real collision instead of a stable id. This
 89    checks the actual input rather than assuming every passage shares one
 90    work.
 91
 92    `text` and `citation` are copied unchanged; only `id` changes, and only
 93    for tokens whose citation is a 5-part CTS URN.
 94    """
 95    work_by_passage: Dict[str, str] = {}
 96    next_index_by_passage: Dict[str, int] = {}
 97
 98    renumbered: List[Sentence] = []
 99    for sentence in sentences:
100        new_tokens = []
101        for tok in sentence.tokens:
102            key = _cts_passage_key(tok.citation)
103            if key is None:
104                new_tokens.append(tok)
105                continue
106
107            work_prefix, passage = key
108            seen_work = work_by_passage.get(passage)
109            if seen_work is None:
110                work_by_passage[passage] = work_prefix
111            elif seen_work != work_prefix:
112                raise ValueError(
113                    f"passage {passage!r} appears under two different CTS "
114                    f"works ({seen_work!r} and {work_prefix!r}) -- "
115                    "assign_passage_scoped_ids() assumes every passage in "
116                    "one run belongs to the same work, so a passage's own "
117                    "final URN segment can be trusted as its unique key; "
118                    "that assumption doesn't hold here"
119                )
120
121            local_index = next_index_by_passage.get(passage, 0)
122            next_index_by_passage[passage] = local_index + 1
123            new_tokens.append(tok.model_copy(update={"id": f"{passage}.t{local_index}"}))
124
125        renumbered.append(Sentence(tokens=new_tokens))
126
127    return renumbered

Return a copy of sentences with every CTS-URN-citation token's id replaced by a passage-scoped composite id (f"{passage}.t{n}" -- see this module's own docstring), and every other token's id left exactly as it was.

Each distinct passage segment gets its own t0, t1, t2, ... counter, assigned in the order that passage's tokens are first encountered across ALL of sentences (not per-sentence -- a passage whose text turns out to span more than one sentence keeps counting up rather than restarting). Two different tokens can therefore never collide: either they're in different passages (different composite id prefix) or the same passage (same prefix, but a strictly increasing local index).

Raises ValueError, naming both work prefixes involved, if the same passage segment is ever seen under two different CTS works -- the one case where taking just a citation's final URN segment as the whole key would silently produce a real collision instead of a stable id. This checks the actual input rather than assuming every passage shares one work.

text and citation are copied unchanged; only id changes, and only for tokens whose citation is a 5-part CTS URN.

class LMInfo(typing.NamedTuple):
231class LMInfo(NamedTuple):
232    """One sentence's '#!lm' entry (see the module docstring) -- which
233    model produced that sentence's analysis, a sentence-style identifier
234    for what it was given to analyze ('CONTEXT1.ID1-CONTEXT2.ID2', its
235    first and last token's own citation and id -- see
236    `_sentence_context_identifier()`), and its own reasoning. Any of the
237    three may be None, the same as an empty column elsewhere in this
238    format (e.g. `model` is None whenever serialize_analyses()/
239    write_analyses() were called without a `model` argument)."""
240
241    model: Optional[str]
242    context: Optional[str]
243    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]]:
321def serialize_analyses(
322    sentences: List[Sentence],
323    verbalunits: List[VerbalExpression],
324    tokengraph: List[TokenAnalysis],
325    *,
326    model: Optional[str] = None,
327    reasoning: Optional[List[Optional[str]]] = None,
328) -> Tuple[str, List[str]]:
329    """Build the exact text write_analyses() would write to a file, and
330    return it directly as `(content, warnings)` instead of writing it
331    anywhere -- see the module docstring for why this exists alongside
332    write_analyses(). All three positional lists are flat and span however
333    many sentences/citation sources were analyzed -- the same shape
334    analyze_sources() (for `sentences`) and combined_tokengraph() (for
335    `tokengraph`; `verbalunits` needs the analogous concatenation, which
336    this function does not do for you) already produce.
337
338    `model`/`reasoning` are optional and control the '#!lm' block (see the
339    module docstring's "The #!lm block"): omit both (the default) to skip
340    '#!lm' entirely, exactly reproducing this function's pre-'#!lm'
341    output. Passing `reasoning` -- one entry per sentence, in the same
342    order as `sentences`, each either that sentence's own reasoning text
343    or None -- turns it on; `model` is then written as every entry's own
344    MODEL= value (typically `os.environ["MODEL"]`, but this module has no
345    opinion on where it comes from). `reasoning` must have exactly one
346    entry per sentence; a mismatched length raises ValueError immediately,
347    before anything is written.
348
349    `content` is the complete file body, including its trailing newline,
350    exactly as write_analyses() would have written it. `warnings` is a
351    list of warning strings (empty if nothing looks wrong), matching this
352    codebase's "degrade visibly, don't raise" convention for warnings
353    distinct from hard errors:
354
355    - a tokengraph or verbalunits entry whose id isn't found among any
356      given sentence's tokens (so no citation is known for it -- an empty
357      context is written, same as a token that legitimately has no
358      citation at all, but this case specifically means the id wasn't
359      found anywhere in `sentences` -- EXCEPT for an implied token
360      (tokentype in IMPLIED_TOKENTYPES), which never appears in any sentence's own
361      `tokens` by design, so this warning is suppressed for those
362      specifically rather than flagged as an anomaly);
363    - a sentence whose own tokens don't form a contiguous, matching-order
364      run in `tokengraph`'s given order -- see the module docstring for
365      why this matters for read_analyses() to recover sentence boundaries
366      correctly.
367
368    Raises ValueError for a sentence with no tokens at all (nothing to
369    derive first_token/last_token from, or -- when `reasoning` is given --
370    nothing to derive '#!lm's CONTEXT= from either), if any field value
371    contains '|' or a newline (see `_field`), or if `reasoning` is given
372    with a different number of entries than `sentences`.
373    """
374    warnings: List[str] = []
375
376    if reasoning is not None and len(reasoning) != len(sentences):
377        raise ValueError(
378            f"`reasoning` has {len(reasoning)} entr"
379            f"{'y' if len(reasoning) == 1 else 'ies'}, but there "
380            f"{'is' if len(sentences) == 1 else 'are'} {len(sentences)} "
381            "sentence(s) -- '#!lm' needs exactly one reasoning entry per "
382            "sentence"
383        )
384
385    id_to_citation: Dict[str, Optional[str]] = {}
386    for sentence in sentences:
387        for tok in sentence.tokens:
388            id_to_citation[tok.id] = tok.citation
389
390    # Implied tokens (tokentype in IMPLIED_TOKENTYPES) never appear in any sentence's
391    # own `tokens` list by design (see the module docstring's note above)
392    # -- so having no recorded citation is expected and correct for them,
393    # not the kind of anomaly the "not found among the given sentences'
394    # tokens" warning below exists to flag.
395    implied_ids = {tok.id for tok in tokengraph if tok.tokentype in IMPLIED_TOKENTYPES}
396
397    tg_index = {tok.id: i for i, tok in enumerate(tokengraph)}
398
399    lines: List[str] = []
400
401    if reasoning is not None:
402        lines.append(LM_LABEL)
403        for s_idx, sentence in enumerate(sentences):
404            if not sentence.tokens:
405                raise ValueError(
406                    f"sentence at index {s_idx} has no tokens -- cannot "
407                    "derive the '#!lm' block's CONTEXT= for an empty "
408                    "sentence"
409                )
410            where = f"'#!lm' entry for sentence {s_idx}"
411            context_value = _sentence_context_identifier(sentence)
412            reasoning_value = reasoning[s_idx]
413            collapsed_reasoning = (
414                _collapse_to_single_line(reasoning_value) if reasoning_value is not None else None
415            )
416            lines.append(_LM_MODEL_PREFIX + _lm_field(model, where=where))
417            lines.append(_LM_CONTEXT_PREFIX + _lm_field(context_value, where=where))
418            lines.append(_LM_REASONING_PREFIX + _lm_field(collapsed_reasoning, where=where))
419        lines.append("")
420
421    lines.append(SENTENCES_LABEL)
422    lines.append(SENTENCES_HEADER)
423    for s_idx, sentence in enumerate(sentences):
424        if not sentence.tokens:
425            raise ValueError(
426                f"sentence at index {s_idx} has no tokens -- cannot derive "
427                "first_token/last_token for an empty sentence"
428            )
429        first_tok = sentence.tokens[0]
430        last_tok = sentence.tokens[-1]
431
432        first_pos = tg_index.get(first_tok.id)
433        last_pos = tg_index.get(last_tok.id)
434        if first_pos is None or last_pos is None:
435            warnings.append(
436                f"sentence at index {s_idx} (tokens {first_tok.id!r}.."
437                f"{last_tok.id!r}) has a boundary token not present in the "
438                "given tokengraph -- reading this file back may not "
439                "reconstruct this sentence's tokens correctly"
440            )
441        else:
442            expected_ids = [t.id for t in sentence.tokens]
443            # Implied tokens (tokentype in IMPLIED_TOKENTYPES) were never part of the
444            # original per-sentence `tokens` list -- they're synthesized by
445            # analysis itself -- so exclude them here before comparing, or
446            # every sentence containing one would spuriously warn.
447            actual_ids = [
448                tok.id
449                for tok in tokengraph[first_pos : last_pos + 1]
450                if tok.tokentype not in IMPLIED_TOKENTYPES
451            ]
452            if actual_ids != expected_ids:
453                warnings.append(
454                    f"sentence at index {s_idx} (tokens {first_tok.id!r}.."
455                    f"{last_tok.id!r}) is not a contiguous, matching-order "
456                    "run in the given tokengraph -- reading this file back "
457                    "may not reconstruct this sentence's tokens correctly"
458                )
459
460        where = f"#!sentences row for sentence {s_idx}"
461        lines.append(
462            "|".join(
463                [
464                    _field(first_tok.citation, where=where),
465                    _field(first_tok.id, where=where),
466                    _field(last_tok.citation, where=where),
467                    _field(last_tok.id, where=where),
468                ]
469            )
470        )
471
472    lines.append("")
473    lines.append(VERBAL_UNITS_LABEL)
474    lines.append(VERBAL_UNITS_HEADER)
475    for vu in verbalunits:
476        if vu.id not in id_to_citation and vu.id not in implied_ids:
477            warnings.append(
478                f"verbal expression {vu.id!r} not found among the given "
479                "sentences' tokens -- writing an empty context for it"
480            )
481        where = f"#!verbal_units row for {vu.id}"
482        lines.append(
483            "|".join(
484                [
485                    _field(id_to_citation.get(vu.id), where=where),
486                    _field(vu.id, where=where),
487                    _field(vu.syntactic_type, where=where),
488                    _field(vu.semantic_type, where=where),
489                ]
490            )
491        )
492
493    lines.append("")
494    lines.append(TOKENS_LABEL)
495    lines.append(TOKENS_HEADER)
496    for tok in tokengraph:
497        if tok.id not in id_to_citation and tok.id not in implied_ids:
498            warnings.append(
499                f"token {tok.id!r} not found among the given sentences' "
500                "tokens -- writing an empty context for it"
501            )
502        where = f"#!tokens row for {tok.id}"
503        lines.append(
504            "|".join(
505                [
506                    _field(id_to_citation.get(tok.id), where=where),
507                    _field(tok.id, where=where),
508                    _field(tok.tokentype, where=where),
509                    _field(tok.token, where=where),
510                    _field(tok.lemma, where=where),
511                    _field(tok.verbalunitid, where=where),
512                    _field(tok.relatedtoken1, where=where),
513                    _field(tok.relationship1, where=where),
514                    _field(tok.relatedtoken2, where=where),
515                    _field(tok.relationship2, where=where),
516                ]
517            )
518        )
519
520    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]:
523def write_analyses(
524    sentences: List[Sentence],
525    verbalunits: List[VerbalExpression],
526    tokengraph: List[TokenAnalysis],
527    path: str,
528    *,
529    model: Optional[str] = None,
530    reasoning: Optional[List[Optional[str]]] = None,
531) -> List[str]:
532    """Write `sentences`/`verbalunits`/`tokengraph` to `path` in the format
533    this module's docstring describes -- see serialize_analyses() (which
534    this is a thin wrapper around) for what's actually written and for the
535    full list of warnings this can return. `model`/`reasoning` are passed
536    straight through to serialize_analyses() and control the optional
537    '#!lm' block exactly as described there -- omit both to skip it.
538
539    Returns a list of warning strings (empty if nothing looks wrong); see
540    serialize_analyses()'s docstring for what each one means. Raises
541    ValueError for a sentence with no tokens at all (nothing to derive
542    first_token/last_token from), if any field value contains '|' or a
543    newline (see `_field`), or if `reasoning` is given with a different
544    number of entries than `sentences` -- all raised by serialize_analyses()
545    before this function ever opens `path`.
546    """
547    content, warnings = serialize_analyses(
548        sentences, verbalunits, tokengraph, model=model, reasoning=reasoning
549    )
550    with open(path, "w", encoding="utf-8") as f:
551        f.write(content)
552    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]]:
555def read_analyses(
556    path: str,
557) -> Tuple[List[TokenAnalysis], List[VerbalExpression], List[Sentence], List[LMInfo]]:
558    """Read `path` (as written by write_analyses()/serialize_analyses()) and
559    reconstruct `(tokengraph, verbalunits, sentences, lm_infos)` -- in that
560    order, matching the order these types are usually discussed in this
561    codebase (the token-level graph, then the verbal-expression table,
562    then the sentence/citation structure that supplies context for both,
563    then the optional per-sentence '#!lm' record of what produced them).
564
565    Each of the three required block labels may appear more than once in
566    `path` (see the module docstring) -- every instance contributes its
567    own rows, in file order, to that label's combined row list, as if the
568    file were the concatenation of however many separate write_analyses()/
569    serialize_analyses() outputs it actually is. '#!lm' may repeat the
570    same way.
571
572    `lm_infos` is `[]` if `path` has no '#!lm' block at all (every file
573    written before this block existed, or any file written without
574    passing `reasoning` to write_analyses()/serialize_analyses(), reads
575    back exactly as before) -- otherwise it's one `LMInfo` per sentence,
576    aligned with `sentences` by position, same as `tokengraph`/
577    `verbalunits` are conceptually aligned with `sentences` via token ids.
578
579    Raises ValueError, naming the offending line and problem, for anything
580    that isn't a faithful, internally-consistent file written by
581    write_analyses() -- see this module's own docstring for exactly what's
582    checked, including '#!lm's own narrower checks (a line count that
583    isn't a multiple of 3, a line missing its expected prefix, or a number
584    of entries that doesn't match the number of sentences). This function
585    does not accept a file with warnings-worthy inconsistencies silently
586    patched over; if write_analyses() returned warnings when the file was
587    written, fix the input and re-write it rather than expecting
588    read_analyses() to compensate.
589    """
590    with open(path, "r", encoding="utf-8") as f:
591        raw_lines = f.read().splitlines()
592
593    # blocks[label] accumulates (line_no, line) data rows across every
594    # instance of that label found in the file, in file order. A label
595    # line always starts a new instance and must be immediately followed
596    # by that label's header line (`awaiting_header` tracks this) before
597    # any more data rows can be appended to it -- this holds per instance,
598    # not just for the label's first appearance, so every repeated block
599    # must repeat its own header line too. '#!lm' is the one exception: it
600    # has no header line at all (see the module docstring), so a '#!lm'
601    # label line goes straight to accepting data rows -- `awaiting_header`
602    # is never set for it.
603    blocks: Dict[str, List[Tuple[int, str]]] = {label: [] for label in _EXPECTED_HEADERS}
604    blocks[LM_LABEL] = []
605    seen_labels = set()
606    current_label: Optional[str] = None
607    awaiting_header = False
608
609    for line_no, line in enumerate(raw_lines, start=1):
610        if line.strip() == "":
611            continue
612
613        if line == LM_LABEL or line == _LM_LABEL_LEGACY or line in _EXPECTED_HEADERS:
614            if awaiting_header:
615                raise ValueError(
616                    f"line {line_no}: block {current_label!r} has a label "
617                    "line but no header line before the next block starts"
618                )
619            # Normalize the legacy '#!LM' spelling to LM_LABEL immediately,
620            # so every downstream use of current_label/blocks/seen_labels
621            # only ever has to know about the one canonical label -- a file
622            # mixing both spellings across its '#!lm' blocks (e.g. from
623            # concatenating an old and a new write_analyses() output) still
624            # merges into a single entry, the same as two same-spelling
625            # blocks already do.
626            current_label = LM_LABEL if line == _LM_LABEL_LEGACY else line
627            seen_labels.add(current_label)
628            awaiting_header = current_label != LM_LABEL
629            continue
630
631        if current_label is None:
632            raise ValueError(
633                f"line {line_no}: data line {line!r} appears before any "
634                "'#!' block label"
635            )
636
637        if awaiting_header:
638            expected = _EXPECTED_HEADERS[current_label]
639            if line != expected:
640                raise ValueError(
641                    f"line {line_no}: expected header {expected!r} for "
642                    f"block {current_label!r}, got {line!r}"
643                )
644            awaiting_header = False
645            continue
646
647        blocks[current_label].append((line_no, line))
648
649    missing = sorted(set(_EXPECTED_HEADERS) - seen_labels)
650    if missing:
651        raise ValueError(f"file is missing required block(s): {missing}")
652    if awaiting_header:
653        raise ValueError(
654            f"block {current_label!r} has a label line but no header line "
655            "(and no data) -- the file ends too early"
656        )
657
658    # --- #!tokens: build the TokenAnalysis list, the id->citation map,
659    # and the row-order index sentence reconstruction relies on. ---
660    tokengraph: List[TokenAnalysis] = []
661    id_to_citation: Dict[str, Optional[str]] = {}
662    row_order: List[str] = []
663
664    for line_no, line in blocks[TOKENS_LABEL]:
665        parts = line.split("|")
666        if len(parts) != 10:
667            raise ValueError(
668                f"line {line_no}: #!tokens row has {len(parts)} columns, "
669                f"expected 10: {line!r}"
670            )
671        (
672            context,
673            tok_id,
674            tokentype,
675            text,
676            lemma,
677            verbalunit,
678            related1,
679            relationship1,
680            related2,
681            relationship2,
682        ) = parts
683        if tok_id == "":
684            raise ValueError(f"line {line_no}: #!tokens row has an empty id")
685        if tok_id in id_to_citation:
686            raise ValueError(f"line {line_no}: duplicate token id {tok_id!r} in #!tokens")
687
688        tokengraph.append(
689            TokenAnalysis(
690                id=tok_id,
691                token=_parse_optional(text),
692                tokentype=tokentype,
693                lemma=_parse_optional(lemma),
694                verbalunitid=_parse_optional(verbalunit),
695                relatedtoken1=_parse_optional(related1),
696                relationship1=_parse_optional(relationship1),
697                relatedtoken2=_parse_optional(related2),
698                relationship2=_parse_optional(relationship2),
699            )
700        )
701        id_to_citation[tok_id] = _parse_optional(context)
702        row_order.append(tok_id)
703
704    id_position = {tid: i for i, tid in enumerate(row_order)}
705
706    # --- #!verbal_units ---
707    verbalunits: List[VerbalExpression] = []
708    for line_no, line in blocks[VERBAL_UNITS_LABEL]:
709        parts = line.split("|")
710        if len(parts) != 4:
711            raise ValueError(
712                f"line {line_no}: #!verbal_units row has {len(parts)} "
713                f"columns, expected 4: {line!r}"
714            )
715        context, vu_id, syntactic_type, semantic_type = parts
716        if vu_id == "":
717            raise ValueError(f"line {line_no}: #!verbal_units row has an empty token id")
718        if vu_id not in id_to_citation:
719            raise ValueError(
720                f"line {line_no}: #!verbal_units references token id "
721                f"{vu_id!r}, which does not appear in the #!tokens block"
722            )
723        recorded_context = _parse_optional(context)
724        expected_context = id_to_citation[vu_id]
725        if recorded_context != expected_context:
726            raise ValueError(
727                f"line {line_no}: #!verbal_units row's context "
728                f"{recorded_context!r} for token {vu_id!r} does not match "
729                f"the #!tokens block's recorded context {expected_context!r} "
730                "for the same id"
731            )
732
733        verbalunits.append(
734            VerbalExpression(
735                id=vu_id,
736                syntactic_type=syntactic_type,
737                semantic_type=semantic_type,
738            )
739        )
740
741    # --- #!sentences ---
742    sentences: List[Sentence] = []
743    for line_no, line in blocks[SENTENCES_LABEL]:
744        parts = line.split("|")
745        if len(parts) != 4:
746            raise ValueError(
747                f"line {line_no}: #!sentences row has {len(parts)} "
748                f"columns, expected 4: {line!r}"
749            )
750        context_begin, first_id, context_end, last_id = parts
751        if first_id == "" or last_id == "":
752            raise ValueError(
753                f"line {line_no}: #!sentences row is missing first_token "
754                f"or last_token: {line!r}"
755            )
756        if first_id not in id_position or last_id not in id_position:
757            raise ValueError(
758                f"line {line_no}: #!sentences references a first_token/"
759                "last_token id not found in the #!tokens block"
760            )
761
762        start = id_position[first_id]
763        end = id_position[last_id]
764        if start > end:
765            raise ValueError(
766                f"line {line_no}: #!sentences row's first_token "
767                f"{first_id!r} comes after last_token {last_id!r} in the "
768                "#!tokens block's row order"
769            )
770
771        parsed_begin = _parse_optional(context_begin)
772        parsed_end = _parse_optional(context_end)
773        if parsed_begin != id_to_citation[first_id]:
774            raise ValueError(
775                f"line {line_no}: #!sentences row's context_begin "
776                f"{parsed_begin!r} does not match the #!tokens block's "
777                f"recorded context {id_to_citation[first_id]!r} for token "
778                f"{first_id!r}"
779            )
780        if parsed_end != id_to_citation[last_id]:
781            raise ValueError(
782                f"line {line_no}: #!sentences row's context_end "
783                f"{parsed_end!r} does not match the #!tokens block's "
784                f"recorded context {id_to_citation[last_id]!r} for token "
785                f"{last_id!r}"
786            )
787
788        sentence_ids = [
789            tid
790            for tid in row_order[start : end + 1]
791            if tokengraph[id_position[tid]].tokentype not in IMPLIED_TOKENTYPES
792        ]
793        sentences.append(
794            Sentence(
795                tokens=[
796                    Token(
797                        id=tid,
798                        text=tokengraph[id_position[tid]].token,
799                        citation=id_to_citation[tid],
800                    )
801                    for tid in sentence_ids
802                ]
803            )
804        )
805
806    # --- #!lm (optional) ---
807    lm_raw = blocks[LM_LABEL]
808    lm_infos: List[LMInfo] = []
809    if lm_raw:
810        if len(lm_raw) % 3 != 0:
811            first_line_no = lm_raw[0][0]
812            raise ValueError(
813                f"line {first_line_no}: '#!lm' block has {len(lm_raw)} "
814                "line(s), which is not a multiple of 3 -- each entry needs "
815                "exactly a MODEL=, CONTEXT=, and REASONING= line, in that "
816                "order"
817            )
818        for i in range(0, len(lm_raw), 3):
819            model_line_no, model_line = lm_raw[i]
820            context_line_no, context_line = lm_raw[i + 1]
821            reasoning_line_no, reasoning_line = lm_raw[i + 2]
822            if not model_line.startswith(_LM_MODEL_PREFIX):
823                raise ValueError(
824                    f"line {model_line_no}: expected a line starting with "
825                    f"{_LM_MODEL_PREFIX!r} in the '#!lm' block, got "
826                    f"{model_line!r}"
827                )
828            if not context_line.startswith(_LM_CONTEXT_PREFIX):
829                raise ValueError(
830                    f"line {context_line_no}: expected a line starting "
831                    f"with {_LM_CONTEXT_PREFIX!r} in the '#!lm' block, got "
832                    f"{context_line!r}"
833                )
834            if not reasoning_line.startswith(_LM_REASONING_PREFIX):
835                raise ValueError(
836                    f"line {reasoning_line_no}: expected a line starting "
837                    f"with {_LM_REASONING_PREFIX!r} in the '#!lm' block, "
838                    f"got {reasoning_line!r}"
839                )
840            lm_infos.append(
841                LMInfo(
842                    model=_parse_optional(model_line[len(_LM_MODEL_PREFIX):]),
843                    context=_parse_optional(context_line[len(_LM_CONTEXT_PREFIX):]),
844                    reasoning=_parse_optional(
845                        reasoning_line[len(_LM_REASONING_PREFIX):]
846                    ),
847                )
848            )
849
850        if len(lm_infos) != len(sentences):
851            raise ValueError(
852                f"'#!lm' block has {len(lm_infos)} "
853                f"entr{'y' if len(lm_infos) == 1 else 'ies'}, but "
854                f"#!sentences reconstructed {len(sentences)} sentence(s) -- "
855                "'#!lm' entries are recorded one per sentence, so these "
856                "must match"
857            )
858
859    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]]]:
862def split_analysis_by_sentence(
863    tokengraph: List[TokenAnalysis],
864    verbalunits: List[VerbalExpression],
865    sentences: List[Sentence],
866) -> List[Tuple[List[TokenAnalysis], List[VerbalExpression]]]:
867    """The inverse of what write_analyses()/serialize_analyses() flatten
868    together: given the same `(tokengraph, verbalunits, sentences)` triple
869    read_analyses() returns (or that analyze_sources()/combined_tokengraph()
870    produce before ever being written to a file), split `tokengraph` and
871    `verbalunits` back into one slice per sentence.
872
873    Returns a list the same length and order as `sentences` -- entry i is
874    `(sentence_tokengraph, sentence_verbalunits)` for `sentences[i]`. Useful
875    for anything that wants to review or render one sentence's analysis at
876    a time (e.g. a sentence-picker UI, like marimo/latin_syntaxer_review.py)
877    without re-running analysis or re-deriving the same id-position
878    bookkeeping read_analyses()/write_analyses() already do internally.
879
880    Relies on the same invariant read_analyses() and write_analyses()
881    already depend on: a sentence's own tokens form a contiguous,
882    matching-order run in `tokengraph` (see this module's own docstring).
883    `sentence_tokengraph` is the slice of `tokengraph` between that
884    sentence's first and last token's positions, inclusive -- which also
885    picks up any implied/elided tokens (tokentype in IMPLIED_TOKENTYPES)
886    interspersed within that range, since those were never part of
887    `sentence.tokens` to begin with but do belong to that sentence's own
888    analysis. `sentence_verbalunits` is every VerbalExpression whose id
889    falls within that same slice.
890
891    One consequence of using [first, last] *real* token positions as the
892    slice boundary, shared with read_analyses()'s own sentence
893    reconstruction: an implied token placed AFTER a sentence's last real
894    token (rather than nested between two real tokens) falls just outside
895    that slice, since there's no further real token of the same sentence
896    to bound it from above -- e.g. a one-real-token sentence like "Rara
897    [sunt]." (see tests/test_serialization.py's
898    test_split_excludes_a_trailing_implied_token_past_the_sentences_last_real_token).
899    An implied token nested between two real tokens of the same sentence
900    is included as expected; only this specific trailing case isn't.
901
902    Raises ValueError for a sentence with no tokens at all, or whose first
903    or last token id isn't present in `tokengraph` -- both should be
904    impossible for a triple that actually came from read_analyses(), which
905    already guarantees this by construction, but this function checks
906    explicitly anyway rather than trusting the caller, since nothing stops
907    it being called with a hand-built triple too.
908    """
909    id_position: Dict[str, int] = {tok.id: i for i, tok in enumerate(tokengraph)}
910
911    result: List[Tuple[List[TokenAnalysis], List[VerbalExpression]]] = []
912    for s_idx, sentence in enumerate(sentences):
913        if not sentence.tokens:
914            raise ValueError(f"sentence at index {s_idx} has no tokens")
915
916        first_id = sentence.tokens[0].id
917        last_id = sentence.tokens[-1].id
918        if first_id not in id_position or last_id not in id_position:
919            raise ValueError(
920                f"sentence at index {s_idx} (tokens {first_id!r}.."
921                f"{last_id!r}) has a boundary token not present in the "
922                "given tokengraph"
923            )
924
925        start = id_position[first_id]
926        end = id_position[last_id]
927        sentence_tokengraph = tokengraph[start : end + 1]
928        sentence_ids = {tok.id for tok in sentence_tokengraph}
929        sentence_verbalunits = [vu for vu in verbalunits if vu.id in sentence_ids]
930        result.append((sentence_tokengraph, sentence_verbalunits))
931
932    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 = 900, ceiling: int = 32000) -> int:
225def estimate_max_tokens(
226    num_tokens: int,
227    *,
228    safety_margin: float = DEFAULT_SAFETY_MARGIN,
229    floor: int = DEFAULT_FLOOR,
230    ceiling: int = DEFAULT_CEILING,
231) -> int:
232    """Estimate a `max_tokens` budget for a SentenceAnalysis call over a
233    sentence with `num_tokens` input tokens.
234
235    `raw = intercept + slope * num_tokens` comes from the calibrated (or
236    fallback) linear fit (see _load_calibration()); `safety_margin`
237    multiplies that to leave room for the reasoning field's length being
238    only roughly, not exactly, a function of passage length. The result is
239    clamped to `[floor, ceiling]` -- `floor` guards against a degenerate
240    tiny estimate for a 1-2 token sentence, `ceiling` is a hard cap you
241    should set to your actual model's real max-output-tokens limit (see
242    DEFAULT_CEILING's docstring note).
243
244    Raises ValueError if `num_tokens` is negative.
245    """
246    if num_tokens < 0:
247        raise ValueError(f"num_tokens must be >= 0, got {num_tokens}")
248
249    calibration = _load_calibration()
250    raw = calibration["intercept"] + calibration["slope"] * num_tokens
251    budget = math.ceil(raw * safety_margin)
252    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 = 3, growth_factor: float = 2.0, safety_margin: float = 1.4, floor: int = 900, ceiling: int = 32000, initial_max_tokens: int | None = None):
338def analyze_with_retry(
339    passage: str,
340    tokens: List[Token],
341    *,
342    max_retries: int = 3,
343    growth_factor: float = 2.0,
344    safety_margin: float = DEFAULT_SAFETY_MARGIN,
345    floor: int = DEFAULT_FLOOR,
346    ceiling: int = DEFAULT_CEILING,
347    initial_max_tokens: Optional[int] = None,
348):
349    """Call `analyze()`, detecting truncation and retrying with a larger
350    `max_tokens` budget instead of either crashing or silently returning an
351    incomplete result.
352
353    `max_retries` default raised from an earlier 1 to 3: with
354    `growth_factor=2.0`, 1 retry only ever climbs the budget 2x before
355    giving up (e.g. ~1200 -> ~2400) -- nowhere close to `ceiling`
356    regardless of how generous `ceiling` itself is (see DEFAULT_CEILING's
357    own comment for a real incident this caused). 3 retries reaches 8x
358    instead (e.g. ~2000 -> ~16000), which comfortably absorbs how much a
359    single sentence's `reasoning` field length varies from call to call --
360    a real, reproducible case needed a second doubling because one call's
361    `reasoning` came back noticeably longer (more bulleted commentary)
362    than another's for the exact same short sentence, eating further into
363    an identical starting budget. Each retry is one more live LM call, so
364    this does raise the worst-case cost/latency of a single sentence --
365    still bounded by `ceiling`, and still far better than surfacing a
366    truncated result or a raised exception to the caller.
367
368    The starting budget is `initial_max_tokens` if given, else
369    `estimate_max_tokens(len(tokens), safety_margin=safety_margin,
370    floor=floor, ceiling=ceiling)`.
371
372    After each attempt, truncation is checked two ways: `_missing_token_ids`
373    against the result (the primary, LM-independent signal -- works
374    whenever a result exists at all, parsed or not, including under
375    DummyLM in tests) and, if the call raised `AdapterParseError` instead
376    of returning a result (the JSON was cut off badly enough to not parse
377    at all), EITHER `_finish_reason_was_length()` OR `_looks_truncated()`
378    (a bracket/brace-balance check on the raw response text -- see that
379    function's own docstring) as corroborating checks. Both are best-effort
380    heuristics rather than a single authoritative signal, because
381    `finish_reason` alone isn't reliable enough across every
382    provider/proxy (see `_looks_truncated()`'s docstring for why that
383    matters here specifically) -- either one alone is enough to treat the
384    failure as truncation.
385
386    If truncation is detected and there's still a retry available (fewer
387    than `max_retries` attempts so far, and the budget hasn't already hit
388    `ceiling`), the budget is multiplied by `growth_factor` (capped at
389    `ceiling`) and the call is retried. `max_tokens` is part of DSPy's own
390    LM cache key, so a retry with a different budget always reaches the LM
391    again rather than replaying a cached truncated response.
392
393    An `AdapterParseError` that neither signal above flags as truncation
394    means the response was well-terminated but still malformed somewhere --
395    e.g. one `tokengraph` entry coming back as a bare `["id"]` list instead
396    of a full TokenAnalysis object. A bigger budget wouldn't have fixed
397    that, but the malformation itself is very often a one-off sampling
398    glitch rather than a systematic prompt/schema problem, so it's retried
399    once too (still counted against `max_retries`, at the SAME budget) with
400    dspy's own response cache explicitly bypassed for that one attempt
401    (`config={"cache": False, ...}`) -- without that, an identical request
402    would just replay the identical broken response, retrying nothing. If
403    that retry also fails to parse, or `max_retries` is already exhausted,
404    the exception propagates.
405
406    Once retries are exhausted: if the last attempt raised, that exception
407    propagates (there's no result to fall back to). If the last attempt
408    returned a still-incomplete result, it's returned anyway -- with a
409    `UserWarning` naming the missing token ids -- rather than raising,
410    matching this codebase's existing convention of surfacing analysis
411    problems as warnings (see pipeline.py's own validate() warning-printing
412    and this module's docstring) instead of treating an imperfect LM
413    result as fatal.
414    """
415    budget = initial_max_tokens if initial_max_tokens is not None else estimate_max_tokens(
416        len(tokens), safety_margin=safety_margin, floor=floor, ceiling=ceiling
417    )
418
419    attempt = 0
420    bypass_cache = False
421    while True:
422        old_budget = budget
423        call_config = {"max_tokens": budget}
424        if bypass_cache:
425            call_config["cache"] = False
426        bypass_cache = False  # only meant for the one attempt it was set for
427        try:
428            result = analyze(passage=passage, tokens=tokens, config=call_config)
429        except AdapterParseError as exc:
430            if attempt >= max_retries:
431                raise
432            # Either signal alone is enough to treat this as truncation --
433            # see this function's own docstring and _looks_truncated()'s
434            # docstring for why relying on _finish_reason_was_length()
435            # alone isn't safe enough here (some providers/proxies don't
436            # forward litellm's own finish_reason="length" faithfully).
437            if budget < ceiling and (_finish_reason_was_length() or _looks_truncated(exc.lm_response)):
438                attempt += 1
439                budget = min(ceiling, math.ceil(budget * growth_factor))
440                warnings.warn(
441                    f"SentenceAnalysis call truncated at max_tokens={old_budget} before it "
442                    f"could be parsed at all; retrying with max_tokens={budget} "
443                    f"(attempt {attempt}/{max_retries}).",
444                    stacklevel=2,
445                )
446                continue
447            # Not a (detectable) truncation -- the response finished
448            # normally but was malformed somewhere (see this function's own
449            # docstring). Retry once more at the SAME budget, but with
450            # dspy's cache explicitly bypassed for that one attempt, so a
451            # retry is a genuinely fresh LM call rather than a replay of
452            # the same broken response -- otherwise, if this exact request
453            # was already served from cache (e.g. a repeat of an earlier,
454            # already-broken run), simply calling analyze() again would
455            # just return the identical malformed result again and again,
456            # even with dspy's cache enabled as normal for every other call.
457            attempt += 1
458            bypass_cache = True
459            warnings.warn(
460                f"SentenceAnalysis call at max_tokens={old_budget} returned output that "
461                f"failed to parse, but doesn't look like a truncation (finish_reason "
462                f"wasn't 'length'): {exc} Retrying once at the same budget with the LM "
463                f"cache bypassed, in case this was a one-off malformed-output glitch "
464                f"(attempt {attempt}/{max_retries}).",
465                stacklevel=2,
466            )
467            continue
468
469        missing = _missing_token_ids(tokens, result)
470        truncated = bool(missing) or _finish_reason_was_length()
471        if truncated and attempt < max_retries and budget < ceiling:
472            attempt += 1
473            budget = min(ceiling, math.ceil(budget * growth_factor))
474            warnings.warn(
475                f"SentenceAnalysis call at max_tokens={old_budget} returned a tokengraph "
476                f"missing {len(missing)} input token id(s) ({sorted(missing)}); retrying "
477                f"with a larger max_tokens={budget} (attempt {attempt}/{max_retries}).",
478                stacklevel=2,
479            )
480            continue
481
482        if truncated:
483            missing_desc = sorted(missing) if missing else "(finish_reason indicated truncation, but no ids are directly missing)"
484            warnings.warn(
485                f"SentenceAnalysis call still looks truncated after {attempt} retry(ies) "
486                f"(max_tokens={old_budget}) -- returning it anyway. Missing input token "
487                f"id(s): {missing_desc}.",
488                stacklevel=2,
489            )
490
491        return result

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

max_retries default raised from an earlier 1 to 3: with growth_factor=2.0, 1 retry only ever climbs the budget 2x before giving up (e.g. ~1200 -> ~2400) -- nowhere close to ceiling regardless of how generous ceiling itself is (see DEFAULT_CEILING's own comment for a real incident this caused). 3 retries reaches 8x instead (e.g. ~2000 -> ~16000), which comfortably absorbs how much a single sentence's reasoning field length varies from call to call -- a real, reproducible case needed a second doubling because one call's reasoning came back noticeably longer (more bulleted commentary) than another's for the exact same short sentence, eating further into an identical starting budget. Each retry is one more live LM call, so this does raise the worst-case cost/latency of a single sentence -- still bounded by ceiling, and still far better than surfacing a truncated result or a raised exception to the caller.

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), EITHER _finish_reason_was_length() OR _looks_truncated() (a bracket/brace-balance check on the raw response text -- see that function's own docstring) as corroborating checks. Both are best-effort heuristics rather than a single authoritative signal, because finish_reason alone isn't reliable enough across every provider/proxy (see _looks_truncated()'s docstring for why that matters here specifically) -- either one alone is enough to treat the failure as truncation.

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 that neither signal above flags as truncation 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:
217def get_calibration() -> dict:
218    """Public introspection: what (intercept, slope) is estimate_max_tokens()
219    currently using, and did it come from calibrate_max_tokens.py's fit or
220    from this module's untuned fallback? See _load_calibration()'s
221    docstring for the shape returned."""
222    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 FailedPassage(typing.NamedTuple):
46class FailedPassage(NamedTuple):
47    """One passage a multi-passage analysis script never managed to
48    analyze at all.
49
50    `description` is a short, human-readable label for which one -- e.g.
51    "sentence starting at 'urn:cts:...:1.1'", optionally prefixed with an
52    input file's own stem for a multi-file script (`analyze_tokendata_to_files.py`'s
53    own convention), or "passage 'urn:cts:...:1.1'" for a whole CTS passage
54    whose GROUP failed before it was even segmented into sentences
55    (`analyze_ctsdata_to_files.py`'s own group-level fallback -- see that
56    module's docstring). `error` is `str(exc)` for whatever exception
57    ultimately stopped it: `token_budget.analyze_with_retry()`'s own,
58    once its retries are exhausted, for a single sentence; `segment_sources()`'s
59    own, for a whole group that never got that far."""
60
61    description: str
62    error: str

One passage a multi-passage analysis script never managed to analyze at all.

description is a short, human-readable label for which one -- e.g. "sentence starting at 'urn:cts:...:1.1'", optionally prefixed with an input file's own stem for a multi-file script (analyze_tokendata_to_files.py's own convention), or "passage 'urn:cts:...:1.1'" for a whole CTS passage whose GROUP failed before it was even segmented into sentences (analyze_ctsdata_to_files.py's own group-level fallback -- see that module's docstring). error is str(exc) for whatever exception ultimately stopped it: token_budget.analyze_with_retry()'s own, once its retries are exhausted, for a single sentence; segment_sources()'s own, for a whole group that never got that far.

FailedPassage(description: str, error: str)

Create new instance of FailedPassage(description, error)

description: str

Alias for field number 0

error: str

Alias for field number 1

def format_warnings_report( failed: List[FailedPassage], lm_history: List) -> str:
65def format_warnings_report(failed: List[FailedPassage], lm_history: List) -> str:
66    """Render `failed` plus `lm_history`'s own total cost
67    (`lm_cost.summarize_lm_cost()`/`format_lm_cost()`) as one short,
68    human-readable report -- see this module's own docstring for what
69    "failed" means here specifically (not `write_analyses()`'s or
70    `validate()`'s own, unrelated notions of "warnings"/"problems", which
71    this report never repeats).
72
73    Always ends with a cost line, and always says explicitly when there
74    were no failures at all rather than leaving that to be inferred from
75    an empty list -- matching the "always prints, no flag to suppress"
76    convention `format_lm_cost()`'s own callers already follow for the
77    same total, so a clean run and a run that never happened both leave
78    behind an unambiguous file instead of requiring the reader to already
79    know "no failure lines means it went fine".
80    """
81    lines: List[str] = []
82    if failed:
83        word = "passage" if len(failed) == 1 else "passages"
84        lines.append(f"{len(failed)} {word} failed to analyze:")
85        for fp in failed:
86            lines.append(f"  - {fp.description}: {fp.error}")
87    else:
88        lines.append("No passages failed to analyze.")
89
90    lines.append("")
91    lines.append(f"LM cost: {format_lm_cost(summarize_lm_cost(lm_history))}")
92    return "\n".join(lines) + "\n"

Render failed plus lm_history's own total cost (lm_cost.summarize_lm_cost()/format_lm_cost()) as one short, human-readable report -- see this module's own docstring for what "failed" means here specifically (not write_analyses()'s or validate()'s own, unrelated notions of "warnings"/"problems", which this report never repeats).

Always ends with a cost line, and always says explicitly when there were no failures at all rather than leaving that to be inferred from an empty list -- matching the "always prints, no flag to suppress" convention format_lm_cost()'s own callers already follow for the same total, so a clean run and a run that never happened both leave behind an unambiguous file instead of requiring the reader to already know "no failure lines means it went fine".

def write_warnings_report( path: str | pathlib.Path, failed: List[FailedPassage], lm_history: List) -> pathlib.Path:
 95def write_warnings_report(
 96    path: Union[str, Path], failed: List[FailedPassage], lm_history: List
 97) -> Path:
 98    """Write `format_warnings_report(failed, lm_history)` to `path`
 99    (created, or overwritten if it already exists), returning `path` as a
100    `Path` -- so a caller can print its own "Wrote ..." line the same way
101    it already does for every other output file, e.g.:
102
103        warnings_path = write_warnings_report(out_dir / "warnings.txt", failed, lm.history)
104        print(f"Wrote {warnings_path}")
105    """
106    out_path = Path(path)
107    out_path.write_text(format_warnings_report(failed, lm_history), encoding="utf-8")
108    return out_path

Write format_warnings_report(failed, lm_history) to path (created, or overwritten if it already exists), returning path as a Path -- so a caller can print its own "Wrote ..." line the same way it already does for every other output file, e.g.:

warnings_path = write_warnings_report(out_dir / "warnings.txt", failed, lm.history)
print(f"Wrote {warnings_path}")
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 aatgraph( sentences: List[Sentence], results: list) -> Tuple[aat.core.graph.AATGraph, List[str]]:
179def aatgraph(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.