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]
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.
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().
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.
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.
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.
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.)
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).
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
verbalunitsentry -- 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.
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.
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.
'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.
If this token anchors a verbal expression in verbalunits, repeat its own id here; otherwise omit.
The primary relation type, if any.
The secondary relation type, if any.
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).
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.
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.
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.
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).
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.
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().
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_chainis 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.
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)
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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).
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.
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
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 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
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.
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.
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.
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.
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.
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.)
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.
600def print_analysis(tokens: List[Token], result): 601 print("Tokens:") 602 for t in tokens: 603 print(f" {t.id:>4} {t.text}") 604 605 print("\nVerbal expressions:") 606 for vu in result.verbalunits: 607 print(f" id={vu.id} syntactic_type={vu.syntactic_type} semantic_type={vu.semantic_type}") 608 609 print("\nToken graph:") 610 for tok in result.tokengraph: 611 rels = [] 612 if tok.relationship1: 613 rels.append(f"{tok.relationship1} -> {tok.relatedtoken1}") 614 if tok.relationship2: 615 rels.append(f"{tok.relationship2} -> {tok.relatedtoken2}") 616 rel_str = "; ".join(rels) if rels else "-" 617 vu_str = f" [verbal unit {tok.verbalunitid}]" if tok.verbalunitid else "" 618 token_str = tok.token if tok.token is not None else f"({tok.tokentype})" 619 print(f" {tok.id:>4} {token_str:<15} type={tok.tokentype:<11} lemma={tok.lemma or '-':<15} {rel_str}{vu_str}")
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
sourcesagain must produce the same ids for the same tokens.
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.
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.
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.
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.
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.
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 ([], []).
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.
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).
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 owntokensby 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.
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.
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.
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.
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".
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()).
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().
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.
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.
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.
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.
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).
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.
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
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.
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".
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}")
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.
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).
Create new instance of LewisShortMatch(entry, score)
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.
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())
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.
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.
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.
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).
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.
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.
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.