arsgrammatica
arsgrammatica: a DSPy program analyzing the syntax of Latin passages according to the scheme documented in syntax_model.md.
1"""arsgrammatica: a DSPy program analyzing the syntax of Latin passages 2according to the scheme documented in syntax_model.md. 3""" 4 5from .models import Token, CitedText, Sentence, VerbalExpression, TokenAnalysis, RelationLabel 6from .mermaid import tokengraph_to_mermaid, token_label, save_mermaid 7from .dot import tokengraph_to_dot, compute_graph_depths, max_graph_depth, save_dot 8from .graphs import GraphMetrics, tokengraph_to_networkx, graph_metrics 9from .verbal_units import ( 10 assign_verbal_units, 11 assign_verbal_unit_colors, 12 compute_aat_depths, 13 compute_subordination_depths, 14 find_governing_verbal_expression, 15 max_subordination_depth, 16 find_unanchored_coordinated_verbs, 17) 18from .rendering import tokengraph_to_text, tokengraph_to_html, tokengraph_to_depth_html 19from .latin_syntax_dspy import ( 20 SentenceAnalysis, 21 analyze, 22 validate, 23 print_analysis, 24) 25from .segmentation_dspy import SegmentPassage, segment_sources 26from .pipeline import ( 27 analyze_string, 28 analyze_sources, 29 analyze_selected_passages, 30 analyze_ctsdata, 31 combined_tokengraph, 32) 33from .passage_grouping import group_passages_by_sentence_boundary 34from .serialization import ( 35 LMInfo, 36 serialize_analyses, 37 write_analyses, 38 read_analyses, 39 split_analysis_by_sentence, 40) 41from .ctsdata import read_ctsdata 42from .segmentation_serialization import ( 43 serialize_segmentation, 44 write_segmentation, 45 read_segmentation, 46) 47from .token_budget import estimate_max_tokens, analyze_with_retry, get_calibration, DEFAULT_CEILING 48from .lm_cost import LMCostSummary, summarize_lm_cost, format_lm_cost 49from .lewis_short import ( 50 LewisShortEntry, 51 LewisShortMatch, 52 LewisShortLexicon, 53 LEWIS_SHORT_URL, 54 read_lewis_short, 55 read_lewis_short_from_url, 56) 57 58# attgraph() depends on the separate `aat` package, which most callers of 59# arsgrammatica have no need to install at all -- not on PyPI, so 60# `pip install git+https://github.com/neelsmith/aat.git` (not a bare 61# `pip install aat`) is what actually installs it; pyproject.toml's "aat" 62# extra is only reachable if arsgrammatica itself is pip-installed 63# (`pip install '.[aat]'` from a checkout, or an editable install) rather 64# than just run from a checkout on sys.path, which is how this project is 65# normally used. Importing it lazily/defensively here, rather than 66# unconditionally like every other submodule above, means `import 67# arsgrammatica` still succeeds without `aat` installed; only actually 68# calling `arsgrammatica.attgraph(...)` without it raises, with a message 69# naming the missing package and how to get it. 70try: 71 from .aat_bridge import attgraph 72except ImportError as _exc: # pragma: no cover -- exercised only when `aat` isn't installed 73 # `except ... as name` implicitly deletes `name` once this block ends 74 # (a Python gotcha, not specific to this code) -- reassign to a plain 75 # variable first so attgraph(), called later, can still reference it. 76 _aat_import_error = _exc 77 78 def attgraph(*args, **kwargs): 79 raise ImportError( 80 "attgraph() needs the separate 'aat' package " 81 "(https://github.com/neelsmith/aat), which isn't installed. " 82 "Install it with: pip install git+https://github.com/" 83 "neelsmith/aat.git -- (if you've also `pip install`ed " 84 "arsgrammatica itself, rather than just running it from a " 85 "checkout, `pip install '.[aat]'` from its own directory " 86 "does the same thing via this package's 'aat' extra)." 87 ) from _aat_import_error 88 89__all__ = [ 90 "Token", 91 "CitedText", 92 "Sentence", 93 "VerbalExpression", 94 "TokenAnalysis", 95 "RelationLabel", 96 "tokengraph_to_mermaid", 97 "token_label", 98 "save_mermaid", 99 "tokengraph_to_dot", 100 "compute_graph_depths", 101 "max_graph_depth", 102 "save_dot", 103 "GraphMetrics", 104 "tokengraph_to_networkx", 105 "graph_metrics", 106 "assign_verbal_units", 107 "assign_verbal_unit_colors", 108 "compute_aat_depths", 109 "compute_subordination_depths", 110 "find_governing_verbal_expression", 111 "max_subordination_depth", 112 "find_unanchored_coordinated_verbs", 113 "tokengraph_to_text", 114 "tokengraph_to_html", 115 "tokengraph_to_depth_html", 116 "SentenceAnalysis", 117 "analyze", 118 "analyze_string", 119 "validate", 120 "print_analysis", 121 "SegmentPassage", 122 "segment_sources", 123 "analyze_sources", 124 "analyze_selected_passages", 125 "analyze_ctsdata", 126 "combined_tokengraph", 127 "group_passages_by_sentence_boundary", 128 "LMInfo", 129 "serialize_analyses", 130 "write_analyses", 131 "read_analyses", 132 "split_analysis_by_sentence", 133 "read_ctsdata", 134 "serialize_segmentation", 135 "write_segmentation", 136 "read_segmentation", 137 "estimate_max_tokens", 138 "analyze_with_retry", 139 "get_calibration", 140 "DEFAULT_CEILING", 141 "LMCostSummary", 142 "summarize_lm_cost", 143 "format_lm_cost", 144 "LewisShortEntry", 145 "LewisShortMatch", 146 "LewisShortLexicon", 147 "LEWIS_SHORT_URL", 148 "read_lewis_short", 149 "read_lewis_short_from_url", 150 "attgraph", 151]
30class Token(BaseModel): 31 """A single pre-segmented token with a stable id. 32 33 `citation` is optional so this model still works for citation-free 34 callers -- e.g. a test fixture built directly from a canned tokengraph, 35 with no CitedText source at all -- as well as for the citation-aware 36 segmentation stage (segmentation_dspy.py), which is the only thing that 37 actually populates it, knowing which CitedText source unit each token 38 came from.""" 39 40 id: str = Field(description="Stable token id, globally unique and sequential across the whole input, e.g. 't0', 't1', ...") 41 text: str = Field(description="The token's surface text, exactly as it appears in the source.") 42 citation: Optional[str] = Field( 43 default=None, 44 description="Citation label of the source unit this token came from (e.g. 'Aeneid 1.1'), if known.", 45 )
A single pre-segmented token with a stable id.
citation is optional so this model still works for citation-free
callers -- e.g. a test fixture built directly from a canned tokengraph,
with no CitedText source at all -- as well as for the citation-aware
segmentation stage (segmentation_dspy.py), which is the only thing that
actually populates it, knowing which CitedText source unit each token
came from.
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). Token ids are 51 global across the whole passage -- numbering continues across sentence 52 boundaries rather than restarting at t0 for each sentence -- so a 53 Sentence is a contiguous slice of the passage's id sequence, not an 54 independently-numbered unit.""" 55 56 tokens: List[Token] = Field( 57 description="This sentence's tokens, in reading order, using the passage's global token ids." 58 )
One sentence's worth of tokens, in reading order, as produced by the LLM-driven segmentation stage (segmentation_dspy.py). Token ids are global across the whole passage -- numbering continues across sentence boundaries rather than restarting at t0 for each sentence -- so a Sentence is a contiguous slice of the passage's id sequence, not an independently-numbered unit.
61class VerbalExpression(BaseModel): 62 """One entry in the table of verbal expressions (syntax_model.md, 'Table 63 of verbal expressions'). Three constructions count as a verbal 64 expression: finite verbs, infinitives (when part of indirect speech), 65 and participles (when they have a *predicate* sense -- e.g. an 66 ablative-absolute-like "Anco regnante...", 'while Ancus was reigning' 67 -- rather than a purely *attributive* sense, like an ordinary adjective, 68 e.g. "consentiens laus", 'universal praise': that case is NOT a verbal 69 expression at all). 70 71 Each construction has its own set of allowed `syntactic_type` values: 72 a finite verb is 'independent', 'dependent', 'direct quote' (occurring 73 in directly quoted speech, e.g. "est" in `"Tuum est," inquit,`), or 74 'aside' (a verbal expression that interrupts the surrounding syntax, 75 e.g. "dixerim" in "pace dixerim deum"); an infinitive anchoring an 76 indirect statement is always 'indirect statement'. syntax_model.md 77 doesn't specify a dedicated syntactic_type value for participles -- 78 this codebase's convention is to use 'dependent' for a predicate 79 participle's verbal expression, since a circumstantial participle / 80 ablative absolute functions like a subordinate clause; flag it if you 81 intended something else.""" 82 83 id: str = Field( 84 description=( 85 "The token id (from the input `tokens` list) of the finite verb, " 86 "infinitive, or predicate-sense participle that anchors this " 87 "verbal expression. For a compound perfect/pluperfect passive or " 88 "future-infinitive form (participle + a form of *sum*), use the id " 89 "of the participle or infinitive itself, NOT the auxiliary form of " 90 "*sum* (which instead relates to it via the 'auxiliary' relation). " 91 "For an implied/elided verbal expression (see " 92 "TokenAnalysis's 'implied sum'/'continued discourse' tokentypes, " 93 "IMPLIED_TOKENTYPES), use the new implied token's id instead. " 94 "(A third IMPLIED_TOKENTYPES value, 'implied subject', is NOT " 95 "a verbal expression and never gets an entry here -- see " 96 "TokenAnalysis's own docstring.)" 97 ) 98 ) 99 syntactic_type: Literal[ 100 "independent", "dependent", "direct quote", "aside", "indirect statement" 101 ] = Field( 102 description=( 103 "For a finite verb: 'independent' (main/principal), 'dependent' " 104 "(subordinate/secondary), 'direct quote' (occurring in directly " 105 "quoted speech), or 'aside' (interrupts the surrounding syntax). " 106 "For an infinitive anchoring an indirect statement: 'indirect " 107 "statement'. For a predicate-sense participle: 'dependent' (this " 108 "codebase's convention; syntax_model.md doesn't specify)." 109 ) 110 ) 111 semantic_type: Literal[ 112 "transitive active", "transitive passive", "intransitive", "linking verb" 113 ] = Field(description="The verb's semantic/voice type.")
One entry in the table of verbal expressions (syntax_model.md, 'Table of verbal expressions'). Three constructions count as a verbal expression: finite verbs, infinitives (when part of indirect speech), and participles (when they have a predicate sense -- e.g. an ablative-absolute-like "Anco regnante...", 'while Ancus was reigning' -- rather than a purely attributive sense, like an ordinary adjective, e.g. "consentiens laus", 'universal praise': that case is NOT a verbal expression at all).
Each construction has its own set of allowed syntactic_type values:
a finite verb is 'independent', 'dependent', 'direct quote' (occurring
in directly quoted speech, e.g. "est" in "Tuum est," inquit,), or
'aside' (a verbal expression that interrupts the surrounding syntax,
e.g. "dixerim" in "pace dixerim deum"); an infinitive anchoring an
indirect statement is always 'indirect statement'. syntax_model.md
doesn't specify a dedicated syntactic_type value for participles --
this codebase's convention is to use 'dependent' for a predicate
participle's verbal expression, since a circumstantial participle /
ablative absolute functions like a subordinate clause; flag it if you
intended something else.
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).
241class TokenAnalysis(BaseModel): 242 """One entry per token in the dependency graph (syntax_model.md, 243 'Token-level table of dependencies'). Per syntax_model.md's 'Incomplete 244 status' section, not every token will have a relation -- leave the 245 relatedtoken*/relationship* fields unset when none of the documented 246 relations apply. 247 248 Most entries correspond 1:1 to an entry in the input `tokens` list. The 249 exceptions are the three IMPLIED_TOKENTYPES values below, which fall 250 into two DIFFERENT groups. syntax_model.md's 'understood or implied 251 verbal expressions' section documents two situations where a VERBAL 252 EXPRESSION exists grammatically but has no surface realization at all 253 in the passage, and this codebase distinguishes them with two distinct 254 tokentype values rather than one generic 'implied': 255 256 - 'implied sum': an elided form of *sum* -- syntax_model.md's three 257 elided-sum sub-cases (a bare predicate construction, a compound 258 perfect passive/future infinitive missing its auxiliary, or the 259 always-implied present participle of *sum*, which doesn't exist in 260 Latin at all) all use this one value. 261 - 'continued discourse': a governing verb of indirect discourse left 262 unrepeated across several continuation clauses. 263 264 'continued discourse' always anchors its own entry in `verbalunits`, 265 same as any other verbal expression -- and so do two of 'implied 266 sum's own three sub-cases (the bare predicate construction, and the 267 always-implied present participle of *sum*). The THIRD 'implied sum' 268 sub-case -- a compound perfect passive/future infinitive with its 269 auxiliary omitted (e.g. 'facti' for 'facti sunt') -- is the one 270 exception: there, a real, already-present participle/infinitive 271 anchors the verbal expression instead (per VerbalExpression.id's own 272 docstring), and the implied token merely relates to IT via 273 'auxiliary', exactly as a written-out auxiliary would; it gets no 274 `verbalunits` entry of its own in that sub-case. The third 275 IMPLIED_TOKENTYPES value is a DIFFERENT kind of gap entirely -- not a 276 missing verb, but a missing NOUN or pronoun: 277 278 - 'implied subject': a participle's own antecedent (the noun/pronoun 279 it agrees with via 'circumstantial participle') can itself be 280 unexpressed, most often when the participle agrees with a governing 281 verb's own unexpressed subject. This token never anchors a 282 `verbalunits` entry -- it isn't a verbal expression at all -- and 283 instead takes a normal 'subject' relation into the verb whose 284 subject it stands in for, exactly as if that subject had been 285 written out; the participle then relates to THIS token via 286 'circumstantial participle', same as it would to any real noun. See 287 latin_syntax_dspy.SentenceAnalysis's docstring for the worked example. 288 289 For any of the three, add a NEW entry here -- with a NEW id, not 290 present in `tokens` -- rather than skipping the construction entirely; 291 see latin_syntax_dspy.SentenceAnalysis's docstring for the full rules and 292 the id-naming convention.""" 293 294 id: str = Field( 295 description=( 296 "For an ordinary entry, must match the id of the corresponding " 297 "entry in the input `tokens` list. For an implied token " 298 "(tokentype in IMPLIED_TOKENTYPES -- 'implied sum', " 299 "'continued discourse', or 'implied subject'), a NEW id not " 300 "used by any entry in `tokens` or elsewhere in this " 301 "tokengraph -- see SentenceAnalysis's docstring for the naming " 302 "convention." 303 ) 304 ) 305 token: Optional[str] = Field( 306 default=None, 307 description=( 308 "The token's surface text; should match the `text` of the input " 309 "token with this id. Leave as None ONLY for an implied token " 310 "(tokentype 'implied sum', 'continued discourse', or 'implied " 311 "subject') -- one with no surface realization in the passage at " 312 "all; every other tokentype must have real text." 313 ), 314 ) 315 tokentype: Literal[ 316 "lexical", "enclitic", "punctuation", "numeral", "praenomen", "abbreviation", 317 "implied sum", "continued discourse", "implied subject", 318 ] = Field( 319 description=( 320 "'numeral' is a number written NUMERICALLY -- Roman (e.g. " 321 "'XXV') or Arabic -- rather than spelled out as a word; a " 322 "number spelled out as an ordinary word (e.g. 'decem' for " 323 "'ten') is 'lexical' instead, even though it's semantically a " 324 "number -- e.g. in 'fratres Joseph decem', all three tokens " 325 "are 'lexical', not 'numeral'. " 326 "'praenomen' is specifically an abbreviated Roman first name " 327 "(e.g. 'M.' for Marcus), including its period; 'abbreviation' is " 328 "any other abbreviation, including its period (e.g. 'f.' for " 329 "filius, 'cos.' for consul) -- syntax_model.md's tokenization " 330 "section documents these as two distinct token types, not one. " 331 "'implied sum', 'continued discourse', and 'implied subject' " 332 "each mark a token with NO surface realization at all (see " 333 "this model's own docstring for the distinction) -- the only " 334 "three tokentypes whose `token` field is None and whose `id` " 335 "is not one of the input `tokens`' own ids. 'continued discourse' " 336 "always anchors its own verbal expression (an entry in " 337 "`verbalunits`), and so does 'implied sum' in two of its three " 338 "sub-cases -- EXCEPT the omitted-auxiliary sub-case (e.g. 'facti' " 339 "for 'facti sunt'), where the real, already-present participle " 340 "anchors instead and this implied token only relates to it via " 341 "'auxiliary' (see this model's own docstring). 'implied subject' " 342 "never anchors a verbal expression -- it stands in for an " 343 "unexpressed NOUN or pronoun, not a verb." 344 ) 345 ) 346 347 lemma: Optional[str] = Field(default=None, description="Dictionary headword, for lexical tokens. Omit for punctuation.") 348 verbalunitid: Optional[str] = Field( 349 default=None, 350 description="If this token anchors a verbal expression in `verbalunits`, repeat its own id here; otherwise omit.", 351 ) 352 353 relatedtoken1: Optional[str] = Field( 354 default=None, 355 description=( 356 "Token id this token relates to (primary relation). For an " 357 "INDEPENDENT verb's own 'unit verb' relation, use the special " 358 "sentinel string 'root' instead of a token id -- 'root' is " 359 "reserved and must never be assigned as an actual token's id." 360 ), 361 ) 362 relationship1: Optional[RelationLabel] = Field(default=None, description="The primary relation type, if any.") 363 364 relatedtoken2: Optional[str] = Field(default=None, description="Token id this token relates to (secondary relation, used when relation1 is already occupied).") 365 relationship2: Optional[RelationLabel] = Field(default=None, description="The secondary relation type, if any.")
One entry per token in the dependency graph (syntax_model.md, 'Token-level table of dependencies'). Per syntax_model.md's 'Incomplete status' section, not every token will have a relation -- leave the relatedtoken*/relationship* fields unset when none of the documented relations apply.
Most entries correspond 1:1 to an entry in the input tokens list. The
exceptions are the three IMPLIED_TOKENTYPES values below, which fall
into two DIFFERENT groups. syntax_model.md's 'understood or implied
verbal expressions' section documents two situations where a VERBAL
EXPRESSION exists grammatically but has no surface realization at all
in the passage, and this codebase distinguishes them with two distinct
tokentype values rather than one generic 'implied':
- 'implied sum': an elided form of sum -- syntax_model.md's three elided-sum sub-cases (a bare predicate construction, a compound perfect passive/future infinitive missing its auxiliary, or the always-implied present participle of sum, which doesn't exist in Latin at all) all use this one value.
- 'continued discourse': a governing verb of indirect discourse left unrepeated across several continuation clauses.
'continued discourse' always anchors its own entry in verbalunits,
same as any other verbal expression -- and so do two of 'implied
sum's own three sub-cases (the bare predicate construction, and the
always-implied present participle of sum). The THIRD 'implied sum'
sub-case -- a compound perfect passive/future infinitive with its
auxiliary omitted (e.g. 'facti' for 'facti sunt') -- is the one
exception: there, a real, already-present participle/infinitive
anchors the verbal expression instead (per VerbalExpression.id's own
docstring), and the implied token merely relates to IT via
'auxiliary', exactly as a written-out auxiliary would; it gets no
verbalunits entry of its own in that sub-case. The third
IMPLIED_TOKENTYPES value is a DIFFERENT kind of gap entirely -- not a
missing verb, but a missing NOUN or pronoun:
- 'implied subject': a participle's own antecedent (the noun/pronoun
it agrees with via 'circumstantial participle') can itself be
unexpressed, most often when the participle agrees with a governing
verb's own unexpressed subject. This token never anchors a
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.
131def tokengraph_to_mermaid( 132 tokengraph: List[TokenAnalysis], 133 orientation: str = "BT", 134 color_by_verbal_unit: bool = True, 135 rank_by_depth: bool = True, 136) -> Tuple[str, List[str]]: 137 """Build a Mermaid `graph` diagram from a tokengraph. 138 139 `orientation` is Mermaid's own flowchart orientation code -- `BT` 140 (bottom-to-top, the default here), `TB`, `LR`, or `RL` -- used verbatim 141 in the diagram's opening line (`graph BT`, `graph LR`, etc.). See 142 https://mermaid.js.org/syntax/flowchart.html for what each value looks 143 like; this function doesn't validate it, so a typo just becomes invalid 144 Mermaid syntax in the output rather than an error here. 145 146 `color_by_verbal_unit` (default True) colors every node by the verbal 147 unit it belongs to, per verbal_units.assign_verbal_units() -- so each 148 clause is visually distinguishable. Verbal units are assigned colors 149 from `_VERBAL_UNIT_PALETTE` in the order their tokens first appear in 150 `tokengraph`; a token assigned to no verbal unit is left with Mermaid's 151 default node styling. The one exception is an implied/elided token 152 (models.py's IMPLIED_TOKENTYPES) -- it always gets its own dedicated 153 `implied` class, colored with `verbal_units._IMPLIED_TOKEN_COLOR`, 154 instead of whatever `_VERBAL_UNIT_PALETTE` color its own verbal unit 155 would otherwise get (see this module's own docstring for why). Pass 156 False to skip coloring and get a plain diagram, as before this 157 parameter existed. 158 159 `rank_by_depth` (default True) makes the diagram's layout respect each 160 verbal expression's own depth in the `aat` package's Agent-Action- 161 Target model (see verbal_units.compute_aat_depths()) -- the same depth 162 aat_bridge.attgraph() would give that verbal expression's own AAT 163 action node, walking related_node chains to an independent (depth 0) 164 action: every verbal-unit anchor node (any token with `verbalunitid` 165 set to its own id, implied tokens included) at the SAME depth gets 166 chained together with Mermaid's invisible-link syntax (`~~~`), e.g. 167 `t1 ~~~ t6 ~~~ t9` for three anchors all at depth 2. This draws no 168 visible edge and adds no relation of its own -- it only nudges 169 Mermaid's layout engine to keep same-depth verbal expressions level 170 with each other, the same way independent clauses, the dependent 171 clauses one level below them, and so on, end up visually aligned by 172 rank rather than scattered -- and lines this diagram's layout up with 173 an AAT graph of the same sentence (see compute_aat_depths()'s own 174 docstring for exactly how its numbers relate to arsgrammatica's own, 175 richer subordination-depth notion, which this does NOT use). A depth 176 with only one anchor gets no chain at all (nothing to link); unlike 177 the old depth-of-subordination ranking, no anchor is ever excluded for 178 having an unresolved depth -- compute_aat_depths() never leaves one 179 unresolved (see its own docstring for the one malformed-input case, 180 a relation cycle, where that means an arbitrary rather than a 181 meaningful depth, rather than an excluded one). Pass False to skip 182 this and get the diagram's previous, unranked layout. 183 184 Returns (diagram_text, warnings). `warnings` lists any edges that were 185 skipped because they referenced a punctuation token or an id not present 186 in `tokengraph` -- worth checking, since it usually means the id came 187 from a validation problem upstream (see latin_syntax_dspy.validate) -- 188 plus, if `color_by_verbal_unit` is True and the passage has more than 8 189 verbal units, one warning that colors are repeating rather than staying 190 distinct (the palette has 8 slots; see _VERBAL_UNIT_PALETTE). 191 `rank_by_depth` itself never adds a warning -- see compute_aat_depths(). 192 """ 193 node_ids = {tok.id for tok in tokengraph if tok.tokentype != "punctuation"} 194 195 lines = [f"graph {orientation}"] 196 for tok in tokengraph: 197 if tok.id not in node_ids: 198 continue 199 # An implied/elided token (see models.py's IMPLIED_TOKENTYPES) has 200 # no surface text at all -- tok.token is None -- so it needs a 201 # placeholder label rather than crashing _escape_label() on None; 202 # token_label() supplies that. The node's color (below) is what 203 # actually marks it as an implied token, not the label text. 204 label = token_label(tok) 205 # An implied/elided token (models.py's IMPLIED_TOKENTYPES) gets a 206 # rounded-corner rectangle -- Mermaid's `(...)` node shape -- instead 207 # of the plain `[...]` rectangle every other node uses, as a second, 208 # shape-based signal (on top of the dedicated amber color below) 209 # that this node stands in for a word that isn't actually there. 210 open_bracket, close_bracket = ( 211 ("(", ")") if tok.tokentype in IMPLIED_TOKENTYPES else ("[", "]") 212 ) 213 lines.append(f' {tok.id}{open_bracket}"{_escape_label(label)}"{close_bracket}') 214 215 warnings = [] 216 for tok in tokengraph: 217 if tok.id not in node_ids: 218 continue 219 for related_field, label_field in ( 220 ("relatedtoken1", "relationship1"), 221 ("relatedtoken2", "relationship2"), 222 ): 223 related_id = getattr(tok, related_field) 224 label = getattr(tok, label_field) 225 if related_id is None or label is None: 226 continue 227 if related_id == "root": 228 # An independent verb's own unit-verb relation, per 229 # syntax_model.md -- intentionally not a real node, so not 230 # a warning-worthy gap. Just draw no edge for it. 231 continue 232 if related_id not in node_ids: 233 warnings.append( 234 f"skipped edge {tok.id} -[{label}]-> {related_id}: " 235 f"target is punctuation or not in tokengraph" 236 ) 237 continue 238 lines.append(f' {tok.id} -->|{_escape_label(label)}| {related_id}') 239 240 if rank_by_depth: 241 depths = compute_aat_depths(tokengraph) 242 243 # Group every verbal-unit anchor node still in the diagram by its 244 # own AAT-graph depth, preserving tokengraph's own (first- 245 # appearance) order within each group. Unlike the old depth-of- 246 # subordination ranking, compute_aat_depths() never leaves an 247 # anchor's depth unresolved -- depths.get() is None here only for a 248 # non-anchor token (it only ever keys its result by anchor id), so 249 # this `is None` check is purely "is this token an anchor at all", 250 # not "did its depth fail to resolve". 251 depth_groups: dict = {} 252 for tok in tokengraph: 253 if tok.id not in node_ids: 254 continue 255 depth = depths.get(tok.id) 256 if depth is None: 257 continue 258 depth_groups.setdefault(depth, []).append(tok.id) 259 260 rank_lines = [ 261 " " + " ~~~ ".join(ids) 262 for depth in sorted(depth_groups) 263 for ids in (depth_groups[depth],) 264 if len(ids) > 1 265 ] 266 if rank_lines: 267 lines.append("") 268 lines.extend(rank_lines) 269 270 if color_by_verbal_unit: 271 assignment = assign_verbal_units(tokengraph) 272 colors, color_warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment) 273 warnings.extend(color_warnings) 274 275 # Implied tokens (models.py's IMPLIED_TOKENTYPES) always get a 276 # dedicated "caution" amber (_IMPLIED_TOKEN_COLOR) instead of 277 # whatever color their own verbal unit would otherwise get -- 278 # regardless of which unit they anchor -- so they're excluded from 279 # every per-unit `member_ids` group below and given their own 280 # classDef/class pair instead. See rendering.py's 281 # tokengraph_to_html() docstring for the matching HTML behavior. 282 implied_ids = [ 283 tok.id 284 for tok in tokengraph 285 if tok.id in node_ids and tok.tokentype in IMPLIED_TOKENTYPES 286 ] 287 288 if colors or implied_ids: 289 lines.append("") 290 class_names = {} 291 for i, (unit_id, (fill, stroke, text)) in enumerate(colors.items()): 292 class_name = f"vu{i}" 293 class_names[unit_id] = class_name 294 lines.append( 295 f" classDef {class_name} fill:{fill},stroke:{stroke},color:{text};" 296 ) 297 for unit_id in colors: 298 member_ids = [ 299 tok.id 300 for tok in tokengraph 301 if tok.id in node_ids 302 and assignment.get(tok.id) == unit_id 303 and tok.id not in implied_ids 304 ] 305 if member_ids: 306 lines.append(f" class {','.join(member_ids)} {class_names[unit_id]};") 307 if implied_ids: 308 fill, stroke, text = _IMPLIED_TOKEN_COLOR 309 lines.append( 310 f" classDef implied fill:{fill},stroke:{stroke},color:{text};" 311 ) 312 lines.append(f" class {','.join(implied_ids)} implied;") 313 314 return "\n".join(lines), warnings
Build a Mermaid graph diagram from a tokengraph.
orientation is Mermaid's own flowchart orientation code -- BT
(bottom-to-top, the default here), TB, LR, or RL -- used verbatim
in the diagram's opening line (graph BT, graph LR, etc.). See
https://mermaid.js.org/syntax/flowchart.html for what each value looks
like; this function doesn't validate it, so a typo just becomes invalid
Mermaid syntax in the output rather than an error here.
color_by_verbal_unit (default True) colors every node by the verbal
unit it belongs to, per verbal_units.assign_verbal_units() -- so each
clause is visually distinguishable. Verbal units are assigned colors
from _VERBAL_UNIT_PALETTE in the order their tokens first appear in
tokengraph; a token assigned to no verbal unit is left with Mermaid's
default node styling. The one exception is an implied/elided token
(models.py's IMPLIED_TOKENTYPES) -- it always gets its own dedicated
implied class, colored with verbal_units._IMPLIED_TOKEN_COLOR,
instead of whatever _VERBAL_UNIT_PALETTE color its own verbal unit
would otherwise get (see this module's own docstring for why). Pass
False to skip coloring and get a plain diagram, as before this
parameter existed.
rank_by_depth (default True) makes the diagram's layout respect each
verbal expression's own depth in the aat package's Agent-Action-
Target model (see verbal_units.compute_aat_depths()) -- the same depth
aat_bridge.attgraph() would give that verbal expression's own AAT
action node, walking related_node chains to an independent (depth 0)
action: every verbal-unit anchor node (any token with verbalunitid
set to its own id, implied tokens included) at the SAME depth gets
chained together with Mermaid's invisible-link syntax (~~~), e.g.
t1 ~~~ t6 ~~~ t9 for three anchors all at depth 2. This draws no
visible edge and adds no relation of its own -- it only nudges
Mermaid's layout engine to keep same-depth verbal expressions level
with each other, the same way independent clauses, the dependent
clauses one level below them, and so on, end up visually aligned by
rank rather than scattered -- and lines this diagram's layout up with
an AAT graph of the same sentence (see compute_aat_depths()'s own
docstring for exactly how its numbers relate to arsgrammatica's own,
richer subordination-depth notion, which this does NOT use). A depth
with only one anchor gets no chain at all (nothing to link); unlike
the old depth-of-subordination ranking, no anchor is ever excluded for
having an unresolved depth -- compute_aat_depths() never leaves one
unresolved (see its own docstring for the one malformed-input case,
a relation cycle, where that means an arbitrary rather than a
meaningful depth, rather than an excluded one). Pass False to skip
this and get the diagram's previous, unranked layout.
Returns (diagram_text, warnings). warnings lists any edges that were
skipped because they referenced a punctuation token or an id not present
in tokengraph -- worth checking, since it usually means the id came
from a validation problem upstream (see latin_syntax_dspy.validate) --
plus, if color_by_verbal_unit is True and the passage has more than 8
verbal units, one warning that colors are repeating rather than staying
distinct (the palette has 8 slots; see _VERBAL_UNIT_PALETTE).
rank_by_depth itself never adds a warning -- see compute_aat_depths().
109def token_label(tok: TokenAnalysis) -> str: 110 """The display label for one token: its own surface text (`tok.token`), 111 or -- for an implied/elided token (tokentype in IMPLIED_TOKENTYPES, 112 which has no surface text of its own; `tok.token` is None) -- a 113 placeholder from `_IMPLIED_TOKEN_LABELS` ("elided sum" for "implied 114 sum", "continued discourse" verbatim), falling back to the token's own 115 tokentype string verbatim for any IMPLIED_TOKENTYPES value not listed 116 there (e.g. "implied subject"). 117 118 This is the exact label `tokengraph_to_mermaid()` puts in each node's 119 diagram box; it's pulled out as its own function so anything else that 120 needs "the same label the Mermaid diagram uses" for a token -- 121 graphs.py's tokengraph_to_networkx(), for one -- can reuse it directly 122 rather than re-deriving (and risking drifting from) the same fallback 123 logic.""" 124 return ( 125 tok.token 126 if tok.token is not None 127 else _IMPLIED_TOKEN_LABELS.get(tok.tokentype, tok.tokentype) 128 )
The display label for one token: its own surface text (tok.token),
or -- for an implied/elided token (tokentype in IMPLIED_TOKENTYPES,
which has no surface text of its own; tok.token is None) -- a
placeholder from _IMPLIED_TOKEN_LABELS ("elided sum" for "implied
sum", "continued discourse" verbatim), falling back to the token's own
tokentype string verbatim for any IMPLIED_TOKENTYPES value not listed
there (e.g. "implied subject").
This is the exact label tokengraph_to_mermaid() puts in each node's
diagram box; it's pulled out as its own function so anything else that
needs "the same label the Mermaid diagram uses" for a token --
graphs.py's tokengraph_to_networkx(), for one -- can reuse it directly
rather than re-deriving (and risking drifting from) the same fallback
logic.
317def save_mermaid( 318 tokengraph: List[TokenAnalysis], 319 path: str, 320 orientation: str = "BT", 321 color_by_verbal_unit: bool = True, 322 rank_by_depth: bool = True, 323) -> List[str]: 324 """Write the diagram to `path` (e.g. 'analysis.mmd') and return any 325 warnings from tokengraph_to_mermaid.""" 326 diagram, warnings = tokengraph_to_mermaid( 327 tokengraph, 328 orientation=orientation, 329 color_by_verbal_unit=color_by_verbal_unit, 330 rank_by_depth=rank_by_depth, 331 ) 332 with open(path, "w", encoding="utf-8") as f: 333 f.write(diagram + "\n") 334 return warnings
Write the diagram to path (e.g. 'analysis.mmd') and return any
warnings from tokengraph_to_mermaid.
212def tokengraph_to_dot( 213 tokengraph: List[TokenAnalysis], 214 orientation: str = "BT", 215 color_by_verbal_unit: bool = True, 216 rank_by_depth: bool = True, 217 depth: Optional[int] = None, 218) -> Tuple[str, List[str]]: 219 """Build a Graphviz DOT `digraph` from a tokengraph -- the same 220 diagram tokengraph_to_mermaid() draws (same nodes, same edges, same 221 coloring), as DOT source instead of Mermaid source. See this module's 222 own docstring for why this exists alongside tokengraph_to_mermaid() 223 and what actually rendering the result requires. 224 225 `orientation` maps directly onto DOT's `rankdir` graph attribute -- 226 `BT` (bottom-to-top, the default here, matching 227 tokengraph_to_mermaid()'s own default), `TB`, `LR`, or `RL`. Not 228 validated here, same as tokengraph_to_mermaid()'s `orientation` -- a 229 typo just becomes an attribute value Graphviz itself will reject. 230 231 `color_by_verbal_unit` (default True) colors every node by the verbal 232 unit it belongs to, per verbal_units.assign_verbal_units() -- the 233 exact same colors (and the same >8-verbal-units warning) as 234 tokengraph_to_mermaid(), just written as `fillcolor`/`color`/ 235 `fontcolor` attributes directly on each node line instead of Mermaid's 236 separate `classDef`/`class` statements (DOT has no equivalent of a 237 named, reusable class -- inline per-node attributes are the idiomatic 238 way to do this). An implied/elided token (IMPLIED_TOKENTYPES) always 239 gets its own dedicated amber (verbal_units._IMPLIED_TOKEN_COLOR) 240 instead of whatever color its own verbal unit would otherwise get, 241 same as tokengraph_to_mermaid(). Pass False for a plain, uncolored 242 diagram. 243 244 `rank_by_depth` (default True) is the reason this module exists 245 alongside tokengraph_to_mermaid() -- see the module docstring. Every 246 verbal-unit anchor node (any token with `verbalunitid` set to its own 247 id, implied tokens included) at the same depth in 248 verbal_units.compute_aat_depths() gets listed together in one 249 `{rank=same; id1; id2; ...}` subgraph statement, which *forces* 250 Graphviz's layout engine to place them on the same rank -- not a nudge, 251 a hard constraint. A depth with only one anchor gets no `rank=same` 252 statement (nothing to align it WITH; unlike Mermaid's `~~~` chain, 253 which genuinely needs 2+ nodes to have anything to link, a 254 single-member `rank=same` would be harmless here too, just an inert 255 statement -- it's skipped for output cleanliness, not necessity). Pass 256 False to skip this and let Graphviz's own layout heuristics place 257 every node. 258 259 `depth`, if given, caps the diagram to nodes at or within that many 260 edges of a root/independent verbal-unit anchor -- compute_graph_depths() 261 above, a plain GRAPH distance along the same relatedtoken1/ 262 relatedtoken2 edges drawn as `->` lines below, NOT 263 verbal_units.compute_subordination_depths() (the CLAUSE-level notion 264 behind tokengraph_to_depth_html()'s own indented-HTML `depth` slider -- 265 a whole clause's subject, object, and other ordinary dependents share 266 ONE subordination depth with their verb, but each is its own hop of 267 GRAPH depth) and NOT verbal_units.compute_aat_depths() (`rank_by_depth` 268 above). `depth=0` shows ONLY root anchors -- an independent verb with 269 no dependents at all; `depth=1` adds every token one edge away from a 270 root anchor (its subject, object, adverbials, ...); and so on. A token 271 farther than `depth` is dropped entirely: omitted as a node, exactly as 272 if it had never been in `tokengraph`. Omit `depth` (or pass `None`, the 273 default) to show every node, same as before this parameter existed. A 274 `depth` at or beyond max_graph_depth()'s own return value for this 275 `tokengraph` shows everything too; a negative `depth` raises 276 ValueError. 277 278 Dropping a node can leave a KEPT node's edge pointing at a now-excluded 279 one. Such an edge is skipped, with the same combined warning already 280 used for an edge targeting punctuation or a genuinely absent id (see 281 Returns below) -- `depth` filtering degrades visibly rather than 282 emitting a dangling `->` line Graphviz would reject. 283 284 Returns `(dot_source, warnings)` -- same shape and same warnings as 285 tokengraph_to_mermaid(): an edge skipped because it targets a 286 punctuation token, a token excluded by the `depth` cutoff, or an id not 287 present in `tokengraph` (except the 'root' sentinel, skipped silently, 288 same as there); if `color_by_verbal_unit` is True and the passage has 289 more than 8 verbal units, one warning that colors are repeating. 290 `depth` filtering itself never adds a warning (compute_graph_depths() 291 has no unresolved state -- an unrelated or cyclic token just defaults 292 to depth 0), same as `rank_by_depth` -- see compute_aat_depths(). 293 """ 294 if depth is not None and depth < 0: 295 raise ValueError(f"depth must be >= 0 (root nodes only), got {depth!r}") 296 297 node_ids = {tok.id for tok in tokengraph if tok.tokentype != "punctuation"} 298 299 warnings: List[str] = [] 300 if depth is not None: 301 graph_depths = compute_graph_depths(tokengraph) 302 depth_excluded_ids = {tok_id for tok_id, d in graph_depths.items() if d > depth} 303 node_ids -= depth_excluded_ids 304 305 colors_by_unit = {} 306 implied_ids: set = set() 307 if color_by_verbal_unit: 308 assignment = assign_verbal_units(tokengraph) 309 colors_by_unit, color_warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment) 310 warnings.extend(color_warnings) 311 implied_ids = { 312 tok.id 313 for tok in tokengraph 314 if tok.id in node_ids and tok.tokentype in IMPLIED_TOKENTYPES 315 } 316 else: 317 assignment = {} 318 319 lines = ["digraph tokengraph {", f" rankdir={orientation};", " node [shape=box];", ""] 320 for tok in tokengraph: 321 if tok.id not in node_ids: 322 continue 323 color = None 324 if color_by_verbal_unit: 325 if tok.id in implied_ids: 326 color = _IMPLIED_TOKEN_COLOR 327 else: 328 unit_id = assignment.get(tok.id) 329 color = colors_by_unit.get(unit_id) if unit_id is not None else None 330 lines.append(f" {tok.id} [{_node_attrs(tok, color)}];") 331 332 lines.append("") 333 for tok in tokengraph: 334 if tok.id not in node_ids: 335 continue 336 for related_field, label_field in ( 337 ("relatedtoken1", "relationship1"), 338 ("relatedtoken2", "relationship2"), 339 ): 340 related_id = getattr(tok, related_field) 341 label = getattr(tok, label_field) 342 if related_id is None or label is None: 343 continue 344 if related_id == "root": 345 # An independent verb's own unit-verb relation, per 346 # syntax_model.md -- intentionally not a real node, so not 347 # a warning-worthy gap. Just draw no edge for it. 348 continue 349 if related_id not in node_ids: 350 warnings.append( 351 f"skipped edge {tok.id} -[{label}]-> {related_id}: " 352 f"target is punctuation, excluded by the depth cutoff, " 353 f"or not in tokengraph" 354 ) 355 continue 356 lines.append(f' {tok.id} -> {related_id} [label="{_escape_label(label)}"];') 357 358 if rank_by_depth: 359 aat_depths = compute_aat_depths(tokengraph) 360 361 # Same grouping tokengraph_to_mermaid() builds for its `~~~` 362 # chains -- see that function's own comment for why 363 # aat_depths.get() being None here means "not an anchor", never 364 # "unresolved depth" (compute_aat_depths() has no such state). 365 # Named aat_depths (not `depths`, and this loop's own variable not 366 # `depth`) to avoid shadowing the `depth` PARAMETER above -- a 367 # different depth notion entirely, see this function's own 368 # docstring. 369 depth_groups: dict = {} 370 for tok in tokengraph: 371 if tok.id not in node_ids: 372 continue 373 aat_depth = aat_depths.get(tok.id) 374 if aat_depth is None: 375 continue 376 depth_groups.setdefault(aat_depth, []).append(tok.id) 377 378 rank_lines = [ 379 " {rank=same; " + "; ".join(ids) + ";}" 380 for aat_depth in sorted(depth_groups) 381 for ids in (depth_groups[aat_depth],) 382 if len(ids) > 1 383 ] 384 if rank_lines: 385 lines.append("") 386 lines.extend(rank_lines) 387 388 lines.append("}") 389 return "\n".join(lines), warnings
Build a Graphviz DOT digraph from a tokengraph -- the same
diagram tokengraph_to_mermaid() draws (same nodes, same edges, same
coloring), as DOT source instead of Mermaid source. See this module's
own docstring for why this exists alongside tokengraph_to_mermaid()
and what actually rendering the result requires.
orientation maps directly onto DOT's rankdir graph attribute --
BT (bottom-to-top, the default here, matching
tokengraph_to_mermaid()'s own default), TB, LR, or RL. Not
validated here, same as tokengraph_to_mermaid()'s orientation -- a
typo just becomes an attribute value Graphviz itself will reject.
color_by_verbal_unit (default True) colors every node by the verbal
unit it belongs to, per verbal_units.assign_verbal_units() -- the
exact same colors (and the same >8-verbal-units warning) as
tokengraph_to_mermaid(), just written as fillcolor/color/
fontcolor attributes directly on each node line instead of Mermaid's
separate classDef/class statements (DOT has no equivalent of a
named, reusable class -- inline per-node attributes are the idiomatic
way to do this). An implied/elided token (IMPLIED_TOKENTYPES) always
gets its own dedicated amber (verbal_units._IMPLIED_TOKEN_COLOR)
instead of whatever color its own verbal unit would otherwise get,
same as tokengraph_to_mermaid(). Pass False for a plain, uncolored
diagram.
rank_by_depth (default True) is the reason this module exists
alongside tokengraph_to_mermaid() -- see the module docstring. Every
verbal-unit anchor node (any token with verbalunitid set to its own
id, implied tokens included) at the same depth in
verbal_units.compute_aat_depths() gets listed together in one
{rank=same; id1; id2; ...} subgraph statement, which forces
Graphviz's layout engine to place them on the same rank -- not a nudge,
a hard constraint. A depth with only one anchor gets no rank=same
statement (nothing to align it WITH; unlike Mermaid's ~~~ chain,
which genuinely needs 2+ nodes to have anything to link, a
single-member rank=same would be harmless here too, just an inert
statement -- it's skipped for output cleanliness, not necessity). Pass
False to skip this and let Graphviz's own layout heuristics place
every node.
depth, if given, caps the diagram to nodes at or within that many
edges of a root/independent verbal-unit anchor -- compute_graph_depths()
above, a plain GRAPH distance along the same relatedtoken1/
relatedtoken2 edges drawn as -> lines below, NOT
verbal_units.compute_subordination_depths() (the CLAUSE-level notion
behind tokengraph_to_depth_html()'s own indented-HTML depth slider --
a whole clause's subject, object, and other ordinary dependents share
ONE subordination depth with their verb, but each is its own hop of
GRAPH depth) and NOT verbal_units.compute_aat_depths() (rank_by_depth
above). depth=0 shows ONLY root anchors -- an independent verb with
no dependents at all; depth=1 adds every token one edge away from a
root anchor (its subject, object, adverbials, ...); and so on. A token
farther than depth is dropped entirely: omitted as a node, exactly as
if it had never been in tokengraph. Omit depth (or pass None, the
default) to show every node, same as before this parameter existed. A
depth at or beyond max_graph_depth()'s own return value for this
tokengraph shows everything too; a negative depth raises
ValueError.
Dropping a node can leave a KEPT node's edge pointing at a now-excluded
one. Such an edge is skipped, with the same combined warning already
used for an edge targeting punctuation or a genuinely absent id (see
Returns below) -- depth filtering degrades visibly rather than
emitting a dangling -> line Graphviz would reject.
Returns (dot_source, warnings) -- same shape and same warnings as
tokengraph_to_mermaid(): an edge skipped because it targets a
punctuation token, a token excluded by the depth cutoff, or an id not
present in tokengraph (except the 'root' sentinel, skipped silently,
same as there); if color_by_verbal_unit is True and the passage has
more than 8 verbal units, one warning that colors are repeating.
depth filtering itself never adds a warning (compute_graph_depths()
has no unresolved state -- an unrelated or cyclic token just defaults
to depth 0), same as rank_by_depth -- see compute_aat_depths().
115def compute_graph_depths(tokengraph: List[TokenAnalysis]) -> Dict[str, int]: 116 """Each non-punctuation token's *graph depth*: the number of edges 117 separating it from the nearest root/independent verbal-unit anchor, 118 following the exact same relatedtoken1/relatedtoken2 edges 119 tokengraph_to_dot() itself draws as `->` lines (dependent -> governor) 120 -- the depth notion behind tokengraph_to_dot()'s own `depth` parameter. 121 See this module's own docstring for how this differs from 122 verbal_units.compute_subordination_depths() (rendering. 123 tokengraph_to_depth_html()'s clause-level notion) and 124 verbal_units.compute_aat_depths() (`rank_by_depth`'s notion). 125 126 A root anchor (relatedtoken1 == 'root') is depth 0. Every other 127 token's depth is one more than its PARENT's -- relatedtoken1, falling 128 back to relatedtoken2 only when relatedtoken1 itself doesn't resolve 129 to a usable parent (None, or an id not in `tokengraph`) -- the SAME 130 "relatedtoken1, fall back to relatedtoken2" preference 131 verbal_units.compute_subordination_depths() already uses to chase a 132 verbal expression's own governor. This matters for a token that plays 133 two roles at once, most notably a relative pronoun: e.g. "qui" 134 pointing at its antecedent via relatedtoken1 ('relative pronoun') AND 135 at the dependent verb it's ALSO the subject of via relatedtoken2 136 ('subject') -- that second edge points forward, toward a token that in 137 turn points back at the pronoun itself (its own 'unit verb' relation), 138 a genuine two-way link the data model allows. Taking the shallower of 139 BOTH edges (rather than preferring relatedtoken1) would let that 140 forward edge "cheat" the pronoun's own depth down to whatever the 141 dependent verb's -- itself only computable FROM the pronoun -- happens 142 to resolve to first, collapsing what should be a deeper chain. Only 143 ever falling back to relatedtoken2, never averaging or taking a 144 minimum over both, avoids that: relatedtoken1 alone already resolves 145 to the antecedent here, so relatedtoken2 is simply never consulted for 146 depth (it's still drawn as its own edge below, same as always -- this 147 only affects which relation DEPTH follows). 148 149 A token whose relatedtoken1 AND any fallback relatedtoken2 both fail 150 to resolve (neither set, or pointing at ids not in `tokengraph`), or 151 which is caught in a relation cycle even after preferring 152 relatedtoken1, defaults to depth 0 -- the same "can't determine, 153 default to root level" fallback verbal_units.compute_subordination_ 154 depths() and rendering.tokengraph_to_depth_html() both use for their 155 own unresolved cases, rather than raising. 156 157 Returns `{token id: depth}`, one entry per non-punctuation token in 158 `tokengraph` (punctuation is never part of the diagram, so never 159 included here either). 160 """ 161 by_id = {tok.id: tok for tok in tokengraph} 162 depths: Dict[str, int] = {} 163 in_progress: set = set() 164 165 def depth_of(tok_id: str) -> int: 166 if tok_id in depths: 167 return depths[tok_id] 168 tok = by_id[tok_id] 169 if tok.relatedtoken1 == "root": 170 depths[tok_id] = 0 171 return 0 172 173 if tok_id in in_progress: 174 # A relation cycle -- fall back to 0 rather than recursing 175 # forever; NOT cached, so a non-cyclic call further up the 176 # stack still computes (and caches) this token's real depth if 177 # some other path reaches it. 178 return 0 179 in_progress.add(tok_id) 180 181 parent_id = None 182 if tok.relatedtoken1 is not None and tok.relatedtoken1 != "root" and tok.relatedtoken1 in by_id: 183 parent_id = tok.relatedtoken1 184 elif tok.relatedtoken2 is not None and tok.relatedtoken2 in by_id: 185 parent_id = tok.relatedtoken2 186 187 result = 1 + depth_of(parent_id) if parent_id is not None else 0 188 189 in_progress.discard(tok_id) 190 depths[tok_id] = result 191 return result 192 193 for tok in tokengraph: 194 if tok.tokentype == "punctuation": 195 continue 196 depth_of(tok.id) 197 198 return depths
Each non-punctuation token's graph depth: the number of edges
separating it from the nearest root/independent verbal-unit anchor,
following the exact same relatedtoken1/relatedtoken2 edges
tokengraph_to_dot() itself draws as -> lines (dependent -> governor)
-- the depth notion behind tokengraph_to_dot()'s own depth parameter.
See this module's own docstring for how this differs from
verbal_units.compute_subordination_depths() (rendering.
tokengraph_to_depth_html()'s clause-level notion) and
verbal_units.compute_aat_depths() (rank_by_depth's notion).
A root anchor (relatedtoken1 == 'root') is depth 0. Every other
token's depth is one more than its PARENT's -- relatedtoken1, falling
back to relatedtoken2 only when relatedtoken1 itself doesn't resolve
to a usable parent (None, or an id not in tokengraph) -- the SAME
"relatedtoken1, fall back to relatedtoken2" preference
verbal_units.compute_subordination_depths() already uses to chase a
verbal expression's own governor. This matters for a token that plays
two roles at once, most notably a relative pronoun: e.g. "qui"
pointing at its antecedent via relatedtoken1 ('relative pronoun') AND
at the dependent verb it's ALSO the subject of via relatedtoken2
('subject') -- that second edge points forward, toward a token that in
turn points back at the pronoun itself (its own 'unit verb' relation),
a genuine two-way link the data model allows. Taking the shallower of
BOTH edges (rather than preferring relatedtoken1) would let that
forward edge "cheat" the pronoun's own depth down to whatever the
dependent verb's -- itself only computable FROM the pronoun -- happens
to resolve to first, collapsing what should be a deeper chain. Only
ever falling back to relatedtoken2, never averaging or taking a
minimum over both, avoids that: relatedtoken1 alone already resolves
to the antecedent here, so relatedtoken2 is simply never consulted for
depth (it's still drawn as its own edge below, same as always -- this
only affects which relation DEPTH follows).
A token whose relatedtoken1 AND any fallback relatedtoken2 both fail
to resolve (neither set, or pointing at ids not in tokengraph), or
which is caught in a relation cycle even after preferring
relatedtoken1, defaults to depth 0 -- the same "can't determine,
default to root level" fallback verbal_units.compute_subordination_
depths() and rendering.tokengraph_to_depth_html() both use for their
own unresolved cases, rather than raising.
Returns {token id: depth}, one entry per non-punctuation token in
tokengraph (punctuation is never part of the diagram, so never
included here either).
201def max_graph_depth(tokengraph: List[TokenAnalysis]) -> Optional[int]: 202 """The highest value compute_graph_depths() assigns to any token in 203 `tokengraph` -- the upper end of the meaningful range for 204 tokengraph_to_dot()'s own `depth` parameter, the same role 205 verbal_units.max_subordination_depth() plays for 206 tokengraph_to_depth_html()'s unrelated depth notion. Returns None for 207 an empty tokengraph, or one with only punctuation.""" 208 depths = compute_graph_depths(tokengraph) 209 return max(depths.values()) if depths else None
The highest value compute_graph_depths() assigns to any token in
tokengraph -- the upper end of the meaningful range for
tokengraph_to_dot()'s own depth parameter, the same role
verbal_units.max_subordination_depth() plays for
tokengraph_to_depth_html()'s unrelated depth notion. Returns None for
an empty tokengraph, or one with only punctuation.
392def save_dot( 393 tokengraph: List[TokenAnalysis], 394 path: str, 395 orientation: str = "BT", 396 color_by_verbal_unit: bool = True, 397 rank_by_depth: bool = True, 398 depth: Optional[int] = None, 399) -> List[str]: 400 """Write the diagram to `path` (e.g. 'analysis.dot') and return any 401 warnings from tokengraph_to_dot().""" 402 diagram, warnings = tokengraph_to_dot( 403 tokengraph, 404 orientation=orientation, 405 color_by_verbal_unit=color_by_verbal_unit, 406 rank_by_depth=rank_by_depth, 407 depth=depth, 408 ) 409 with open(path, "w", encoding="utf-8") as f: 410 f.write(diagram + "\n") 411 return warnings
Write the diagram to path (e.g. 'analysis.dot') and return any
warnings from tokengraph_to_dot().
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.attgraph()`) and walked each action node's own 509 `related_node` chain to the top -- an independent action (no governing 510 action) is depth 0, one it governs is depth 1, and so on -- WITHOUT 511 actually building that graph or depending on `aat` being installed at 512 all: `attgraph()` populates every action's `related_node` from this 513 same module's `find_governing_verbal_expression()`, so walking that 514 map directly here reproduces the identical numbers `graph. 515 governing_action()` chains would. 516 517 Returns `{anchor id: depth}` -- one entry per verbal-expression anchor 518 (same set `compute_subordination_depths()` covers), and, unlike that 519 function, EVERY anchor gets a plain `int`, never `None`, and this never 520 returns any warnings. That's not an oversight: an AATNode's 521 `related_node` is either "points at a real governing action" or 522 `None` -- there's no third state for "a governing expression should 523 exist here but the chase couldn't find one" (compute_subordination_ 524 depths()'s "unresolved, needs a warning" case). So this function folds 525 that case into the same bucket as a genuinely independent verb (depth 526 0) instead of excluding it, exactly as an AATGraph itself would have 527 no way to tell the two apart. For every WELL-FORMED sentence (which is 528 to say: every one of this codebase's own gold fixtures) the two 529 functions agree exactly, hop for hop -- they're driven by the same 530 underlying chase. They can only diverge on malformed input: an anchor 531 whose own chase never reaches another anchor at all (relatedtoken1 not 532 'root', but nothing resolvable) is `None`/excluded/warned-about from 533 `compute_subordination_depths()`, but depth 0 here. 534 535 A mutual cycle -- two anchors relating directly to each other, so each 536 resolves to "the other" as its own governing expression (see 537 `find_governing_verbal_expression()`'s own docstring for why its local 538 chase can't detect this as a cycle at all) -- is the one case where 539 this function's numbers are not just "collapsed" but genuinely 540 arbitrary: walking the chain here still has to terminate somewhere, so 541 whichever anchor's own resolution happens to be demanded FIRST ends up 542 one level shallower than the other, an artifact of iteration order 543 rather than anything meaningful in the relation graph. This is the 544 same malformed-LM-output scenario `compute_subordination_depths()` 545 detects and warns about explicitly (leaving both anchors' depth 546 `None`) -- a caller that needs to tell "confidently ranked" apart from 547 "arbitrarily broke a tie in a cycle" should use that function instead, 548 or run `find_unanchored_coordinated_verbs()`/`validate()` upstream, 549 since a real cycle like this only comes from malformed relations to 550 begin with. 551 552 Used by `mermaid.tokengraph_to_mermaid()`'s `rank_by_depth` option, so 553 the invisible same-depth layout links in the full syntax diagram line 554 up with the depth an AAT graph of the same sentence would show, rather 555 than arsgrammatica's own (richer, but AAT-incompatible on unresolved 556 anchors) subordination-depth notion -- see that function's own 557 docstring. `compute_subordination_depths()`/`max_subordination_depth()` 558 /`tokengraph_to_depth_html()`'s depth-indented HTML view are unaffected 559 by this function and keep using the original notion, unchanged. 560 """ 561 governing = find_governing_verbal_expression(tokengraph) 562 563 depths: Dict[str, int] = {} 564 in_progress: set = set() 565 566 def depth_of(anchor_id: str) -> int: 567 if anchor_id in depths: 568 return depths[anchor_id] 569 if anchor_id in in_progress: 570 # A cycle this function's own local walk can't resolve 571 # meaningfully (see this function's own docstring) -- treat as 572 # "no governing expression found", same as a genuinely 573 # independent action, rather than recursing forever. 574 return 0 575 in_progress.add(anchor_id) 576 577 parent = governing.get(anchor_id) 578 result = 0 if parent is None else depth_of(parent) + 1 579 580 in_progress.discard(anchor_id) 581 depths[anchor_id] = result 582 return result 583 584 for anchor_id in governing: 585 depth_of(anchor_id) 586 587 return depths
Compute each verbal expression's depth the way it would come out if
you built an aat package AATGraph from this same tokengraph (via
aat_bridge.attgraph()) and walked each action node's own
related_node chain to the top -- an independent action (no governing
action) is depth 0, one it governs is depth 1, and so on -- WITHOUT
actually building that graph or depending on aat being installed at
all: attgraph() populates every action's related_node from this
same module's find_governing_verbal_expression(), so walking that
map directly here reproduces the identical numbers graph.
governing_action() chains would.
Returns {anchor id: depth} -- one entry per verbal-expression anchor
(same set compute_subordination_depths() covers), and, unlike that
function, EVERY anchor gets a plain int, never None, and this never
returns any warnings. That's not an oversight: an AATNode's
related_node is either "points at a real governing action" or
None -- there's no third state for "a governing expression should
exist here but the chase couldn't find one" (compute_subordination_
depths()'s "unresolved, needs a warning" case). So this function folds
that case into the same bucket as a genuinely independent verb (depth
0) instead of excluding it, exactly as an AATGraph itself would have
no way to tell the two apart. For every WELL-FORMED sentence (which is
to say: every one of this codebase's own gold fixtures) the two
functions agree exactly, hop for hop -- they're driven by the same
underlying chase. They can only diverge on malformed input: an anchor
whose own chase never reaches another anchor at all (relatedtoken1 not
'root', but nothing resolvable) is None/excluded/warned-about from
compute_subordination_depths(), but depth 0 here.
A mutual cycle -- two anchors relating directly to each other, so each
resolves to "the other" as its own governing expression (see
find_governing_verbal_expression()'s own docstring for why its local
chase can't detect this as a cycle at all) -- is the one case where
this function's numbers are not just "collapsed" but genuinely
arbitrary: walking the chain here still has to terminate somewhere, so
whichever anchor's own resolution happens to be demanded FIRST ends up
one level shallower than the other, an artifact of iteration order
rather than anything meaningful in the relation graph. This is the
same malformed-LM-output scenario compute_subordination_depths()
detects and warns about explicitly (leaving both anchors' depth
None) -- a caller that needs to tell "confidently ranked" apart from
"arbitrarily broke a tie in a cycle" should use that function instead,
or run find_unanchored_coordinated_verbs()/validate() upstream,
since a real cycle like this only comes from malformed relations to
begin with.
Used by mermaid.tokengraph_to_mermaid()'s rank_by_depth option, so
the invisible same-depth layout links in the full syntax diagram line
up with the depth an AAT graph of the same sentence would show, rather
than arsgrammatica's own (richer, but AAT-incompatible on unresolved
anchors) subordination-depth notion -- see that function's own
docstring. compute_subordination_depths()/max_subordination_depth()
/tokengraph_to_depth_html()'s depth-indented HTML view are unaffected
by this function and keep using the original notion, unchanged.
405def compute_subordination_depths( 406 tokengraph: List[TokenAnalysis], 407) -> Tuple[Dict[str, Optional[int]], List[str]]: 408 """Compute each verbal expression's *depth of subordination*: the 409 number of verbal expressions it is removed from an independent ("root") 410 clause. An independent verb is depth 0; a verb it introduces (a 411 dependent clause, a direct quote, an aside) is depth 1; a verbal 412 expression THAT verb in turn introduces (e.g. an indirect statement 413 inside a dependent clause) is depth 2; and so on. 414 415 A "verbal expression" here is any token that anchors one -- i.e. any 416 token with `verbalunitid` set to its own id (the same convention 417 `assign_verbal_units()` relies on). For each anchor, this function 418 finds its *parent* anchor -- the verbal expression it's subordinate to 419 -- by following the anchor's own relatedtoken1 (falling back to 420 relatedtoken2), through as many intermediate non-anchor tokens as 421 necessary, until it lands on another anchor. This one chase handles 422 every documented case uniformly, without needing to special-case by 423 relationship label, because they all eventually resolve to another 424 anchor via forward pointers already in the graph: 425 426 - unit verb (independent): relatedtoken1 == 'root' -> no parent, depth 0. 427 - unit verb (dependent): relatedtoken1 -> a subordinating conjunction or 428 relative pronoun (not itself an anchor) -> ITS relatedtoken1 -> the 429 superior verb (a conjunction) or an antecedent noun (a relative 430 pronoun), the latter requiring one more hop through the noun's own 431 relation to reach the verb it depends on. 432 - direct quote / aside / indirect statement: relatedtoken1 -> the verb 433 of the clause it interrupts, is framed by, or (for an indirect- 434 statement infinitive) governs it, directly (no intermediate token). 435 - circumstantial participle: relatedtoken1 -> the noun/pronoun it 436 agrees with (not itself an anchor) -> that noun's own relation, 437 either its normal role in the surrounding clause (one more hop to a 438 verb) or, for a true ablative absolute, 'ablative absolute' pointing 439 directly at the main verb. 440 441 Returns `({anchor id: depth or None}, warnings)`. A depth of `None` 442 means the chase from that anchor never reached another anchor (a 443 malformed or genuinely disconnected verbal expression -- e.g. an 444 indirect-statement infinitive predating this convention, with no 445 relatedtoken1 of its own at all) or a cycle was detected; `warnings` 446 names which anchor(s) and why, mirroring `tokengraph_to_mermaid()`'s 447 warnings-list convention rather than raising. 448 """ 449 by_id = {tok.id: tok for tok in tokengraph} 450 anchor_ids = {tok.id for tok in tokengraph if tok.verbalunitid == tok.id} 451 452 warnings: List[str] = [] 453 454 # The chase itself -- following relatedtoken1/relatedtoken2 forward 455 # until another anchor is reached -- now lives in 456 # find_governing_verbal_expression(), shared with aat_bridge.py's 457 # attgraph(). Computed once, up front, for every anchor; this is a 458 # pure function of `tokengraph` with no dependency on `depths`' 459 # memoization state, so precomputing it here for all anchors (instead 460 # of the original code's lazy per-call `parent_of()`) changes nothing 461 # about the result. 462 governing = find_governing_verbal_expression(tokengraph) 463 464 depths: Dict[str, Optional[int]] = {} 465 in_progress: set = set() 466 467 def depth_of(anchor_id: str) -> Optional[int]: 468 if anchor_id in depths: 469 return depths[anchor_id] 470 tok = by_id[anchor_id] 471 if tok.relatedtoken1 == "root": 472 depths[anchor_id] = 0 473 return 0 474 475 if anchor_id in in_progress: 476 warnings.append( 477 f"cycle detected resolving the governing verbal expression " 478 f"for {anchor_id!r} -- leaving its depth (and its parent's) " 479 f"unresolved" 480 ) 481 return None 482 in_progress.add(anchor_id) 483 484 parent = governing.get(anchor_id) 485 if parent is None: 486 warnings.append( 487 f"could not find a governing verbal expression for " 488 f"{anchor_id!r} -- leaving its depth unresolved" 489 ) 490 result = None 491 else: 492 parent_depth = depth_of(parent) 493 result = None if parent_depth is None else parent_depth + 1 494 495 in_progress.discard(anchor_id) 496 depths[anchor_id] = result 497 return result 498 499 for anchor_id in anchor_ids: 500 depth_of(anchor_id) 501 502 return depths, warnings
Compute each verbal expression's depth of subordination: the number of verbal expressions it is removed from an independent ("root") clause. An independent verb is depth 0; a verb it introduces (a dependent clause, a direct quote, an aside) is depth 1; a verbal expression THAT verb in turn introduces (e.g. an indirect statement inside a dependent clause) is depth 2; and so on.
A "verbal expression" here is any token that anchors one -- i.e. any
token with verbalunitid set to its own id (the same convention
assign_verbal_units() relies on). For each anchor, this function
finds its parent anchor -- the verbal expression it's subordinate to
-- by following the anchor's own relatedtoken1 (falling back to
relatedtoken2), through as many intermediate non-anchor tokens as
necessary, until it lands on another anchor. This one chase handles
every documented case uniformly, without needing to special-case by
relationship label, because they all eventually resolve to another
anchor via forward pointers already in the graph:
- unit verb (independent): relatedtoken1 == 'root' -> no parent, depth 0.
- unit verb (dependent): relatedtoken1 -> a subordinating conjunction or relative pronoun (not itself an anchor) -> ITS relatedtoken1 -> the superior verb (a conjunction) or an antecedent noun (a relative pronoun), the latter requiring one more hop through the noun's own relation to reach the verb it depends on.
- direct quote / aside / indirect statement: relatedtoken1 -> the verb of the clause it interrupts, is framed by, or (for an indirect- statement infinitive) governs it, directly (no intermediate token).
- circumstantial participle: relatedtoken1 -> the noun/pronoun it agrees with (not itself an anchor) -> that noun's own relation, either its normal role in the surrounding clause (one more hop to a verb) or, for a true ablative absolute, 'ablative absolute' pointing directly at the main verb.
Returns ({anchor id: depth or None}, warnings). A depth of None
means the chase from that anchor never reached another anchor (a
malformed or genuinely disconnected verbal expression -- e.g. an
indirect-statement infinitive predating this convention, with no
relatedtoken1 of its own at all) or a cycle was detected; warnings
names which anchor(s) and why, mirroring tokengraph_to_mermaid()'s
warnings-list convention rather than raising.
315def find_governing_verbal_expression( 316 tokengraph: List[TokenAnalysis], 317) -> Dict[str, Optional[str]]: 318 """For every verbal expression anchor in `tokengraph` (any token with 319 `verbalunitid` set to its own id -- the same convention 320 `assign_verbal_units()` relies on), find the *governing* verbal 321 expression it is subordinate to: the anchor id its own relatedtoken1 322 (falling back to relatedtoken2) chain eventually leads to, following 323 through as many intermediate non-anchor tokens as necessary. 324 325 Returns `{anchor id: governing anchor id, or None}`. `None` covers 326 BOTH of two different situations, deliberately not distinguished here: 327 an independent verb (relatedtoken1 == 'root', nothing to chase) and a 328 chase that dead-ends or cycles through non-anchor tokens before ever 329 reaching another anchor (a malformed or genuinely disconnected verbal 330 expression) -- either way, "no governing verbal expression" is the 331 right answer for a caller that just wants "is this subordinate to 332 something, and if so what" (e.g. aat_bridge.py's `attgraph()`, 333 building an AAT action node's `related_node`, where both cases alike 334 mean `related_node = None`). A caller that needs to tell those two 335 apart, or wants a warning when the chase genuinely fails, should use 336 `compute_subordination_depths()` instead -- it consumes this same 337 chase (via this function) but adds exactly that distinction, plus 338 warnings, on top. 339 340 One malformed-input case this function does NOT resolve to None: 341 two anchors whose own relatedtoken1/2 point directly at EACH OTHER 342 (rather than through intermediate tokens). The chase from either one 343 hits the OTHER anchor immediately -- an anchor is a hit the moment 344 it's reached, before its own further relations are ever followed -- 345 so each resolves to "the other" as its governing expression, a 346 locally self-consistent but globally nonsensical mutual cycle. This 347 is unchanged from the original private helper this function was 348 extracted from; catching it requires the joint, cross-anchor 349 resolution `compute_subordination_depths()`'s own `in_progress` 350 bookkeeping does (see its "cycle detected" warning), which a single 351 anchor's local chase has no way to see on its own. A caller building 352 an AATGraph from a relation graph with this specific defect (an 353 actual LM error, not a normal input) would get a graph with two 354 actions each listing the other as its own governing action -- 355 referentially valid (aat.core.validate.validate() has no cycle 356 check either) but logically circular. 357 358 The chase itself handles every documented case uniformly, without 359 needing to special-case by relationship label, because they all 360 eventually resolve to another anchor via forward pointers already in 361 the graph -- see `compute_subordination_depths()`'s own docstring for 362 the full worked-out case list (unit verb, direct quote/aside/indirect 363 statement, circumstantial participle). 364 """ 365 by_id = {tok.id: tok for tok in tokengraph} 366 anchor_ids = {tok.id for tok in tokengraph if tok.verbalunitid == tok.id} 367 368 def chase(token_id: str, visited: set) -> Optional[str]: 369 """Follow relatedtoken1 (then relatedtoken2) forward from 370 `token_id`, returning the first anchor id reached, or None if the 371 chain dead-ends or cycles before reaching one. `token_id` itself 372 counts as a hit if it's already an anchor (the direct-link cases: 373 direct quote, aside, indirect statement).""" 374 if token_id in visited: 375 return None 376 visited.add(token_id) 377 if token_id in anchor_ids: 378 return token_id 379 tok = by_id.get(token_id) 380 if tok is None: 381 return None 382 for field in ("relatedtoken1", "relatedtoken2"): 383 target = getattr(tok, field) 384 if target is None or target == "root": 385 continue 386 result = chase(target, visited) 387 if result is not None: 388 return result 389 return None 390 391 def parent_of(anchor_id: str) -> Optional[str]: 392 tok = by_id[anchor_id] 393 for field in ("relatedtoken1", "relatedtoken2"): 394 target = getattr(tok, field) 395 if target is None or target == "root": 396 continue 397 result = chase(target, visited=set()) 398 if result is not None and result != anchor_id: 399 return result 400 return None 401 402 return {anchor_id: parent_of(anchor_id) for anchor_id in anchor_ids}
For every verbal expression anchor in tokengraph (any token with
verbalunitid set to its own id -- the same convention
assign_verbal_units() relies on), find the governing verbal
expression it is subordinate to: the anchor id its own relatedtoken1
(falling back to relatedtoken2) chain eventually leads to, following
through as many intermediate non-anchor tokens as necessary.
Returns {anchor id: governing anchor id, or None}. None covers
BOTH of two different situations, deliberately not distinguished here:
an independent verb (relatedtoken1 == 'root', nothing to chase) and a
chase that dead-ends or cycles through non-anchor tokens before ever
reaching another anchor (a malformed or genuinely disconnected verbal
expression) -- either way, "no governing verbal expression" is the
right answer for a caller that just wants "is this subordinate to
something, and if so what" (e.g. aat_bridge.py's attgraph(),
building an AAT action node's related_node, where both cases alike
mean related_node = None). A caller that needs to tell those two
apart, or wants a warning when the chase genuinely fails, should use
compute_subordination_depths() instead -- it consumes this same
chase (via this function) but adds exactly that distinction, plus
warnings, on top.
One malformed-input case this function does NOT resolve to None:
two anchors whose own relatedtoken1/2 point directly at EACH OTHER
(rather than through intermediate tokens). The chase from either one
hits the OTHER anchor immediately -- an anchor is a hit the moment
it's reached, before its own further relations are ever followed --
so each resolves to "the other" as its governing expression, a
locally self-consistent but globally nonsensical mutual cycle. This
is unchanged from the original private helper this function was
extracted from; catching it requires the joint, cross-anchor
resolution compute_subordination_depths()'s own in_progress
bookkeeping does (see its "cycle detected" warning), which a single
anchor's local chase has no way to see on its own. A caller building
an AATGraph from a relation graph with this specific defect (an
actual LM error, not a normal input) would get a graph with two
actions each listing the other as its own governing action --
referentially valid (aat.core.validate.validate() has no cycle
check either) but logically circular.
The chase itself handles every documented case uniformly, without
needing to special-case by relationship label, because they all
eventually resolve to another anchor via forward pointers already in
the graph -- see compute_subordination_depths()'s own docstring for
the full worked-out case list (unit verb, direct quote/aside/indirect
statement, circumstantial participle).
590def max_subordination_depth( 591 tokengraph: List[TokenAnalysis], 592 depths: Optional[Dict[str, Optional[int]]] = None, 593) -> Optional[int]: 594 """Return the deepest level of subordination reached anywhere in 595 `tokengraph` -- the highest value `compute_subordination_depths()` 596 assigns to any verbal expression. Root/independent clauses are depth 597 0, so this is also the upper end of the valid `depth` range for 598 `rendering.tokengraph_to_depth_html()`'s own `depth` parameter (whose 599 valid range is 0, root clauses only, through this function's return 600 value, everything). 601 602 Pass `depths` (the first element of `compute_subordination_depths()`'s 603 return value) if the caller already computed it, to avoid re-deriving 604 it here; otherwise it's computed internally (any resolution warnings 605 are silently dropped in that case -- call 606 `compute_subordination_depths()` directly first if the caller also 607 needs those). 608 609 Returns `None` if `tokengraph` has no verbal expressions at all (an 610 empty passage, or one with none of the three constructions 611 syntax_model.md counts as one), or if every anchor's own depth came 612 back unresolved (see `compute_subordination_depths()`'s own 613 warnings for why an anchor might be unresolved -- a relation cycle, or 614 a governing verbal expression that couldn't be found). Otherwise 615 returns the maximum of every RESOLVED anchor's depth, ignoring 616 unresolved ones rather than letting a single bad anchor blank out the 617 whole result. 618 """ 619 if depths is None: 620 depths, _warnings = compute_subordination_depths(tokengraph) 621 622 resolved = [d for d in depths.values() if d is not None] 623 if not resolved: 624 return None 625 return max(resolved)
Return the deepest level of subordination reached anywhere in
tokengraph -- the highest value compute_subordination_depths()
assigns to any verbal expression. Root/independent clauses are depth
0, so this is also the upper end of the valid depth range for
rendering.tokengraph_to_depth_html()'s own depth parameter (whose
valid range is 0, root clauses only, through this function's return
value, everything).
Pass depths (the first element of compute_subordination_depths()'s
return value) if the caller already computed it, to avoid re-deriving
it here; otherwise it's computed internally (any resolution warnings
are silently dropped in that case -- call
compute_subordination_depths() directly first if the caller also
needs those).
Returns None if tokengraph has no verbal expressions at all (an
empty passage, or one with none of the three constructions
syntax_model.md counts as one), or if every anchor's own depth came
back unresolved (see compute_subordination_depths()'s own
warnings for why an anchor might be unresolved -- a relation cycle, or
a governing verbal expression that couldn't be found). Otherwise
returns the maximum of every RESOLVED anchor's depth, ignoring
unresolved ones rather than letting a single bad anchor blank out the
whole result.
628def find_unanchored_coordinated_verbs(tokengraph: List[TokenAnalysis]) -> List[str]: 629 """Heuristic sanity check for a specific, observed live-LM mistake: a 630 coordinating conjunction that pairs two verbal expressions (see 631 latin_syntax_dspy.py's docstring) is supposed to leave BOTH conjuncts 632 anchoring their own verbal unit -- each with its own `verbalunitid` 633 (and its own `verbalunits` entry). In practice, the LM sometimes drops 634 this for the second conjunct, especially when that verb also governs 635 further subordinate structure of its own (a dependent clause, an 636 indirect statement) -- see gold_examples.py's 637 coordinating_conjunction_dedit_et_dixit_esse fixture for a real example 638 (dixit, coordinated with dedit via "et" AND governing its own indirect 639 statement, came back from a live model with no verbalunitid at all). 640 641 This is NOT the same kind of check as validate() (referential id 642 integrity) or compute_subordination_depths()'s warnings (a resolvable- 643 but-broken relation graph) -- both of those only catch a problem if 644 the tokengraph is already self-inconsistent. This function catches a 645 tokengraph that's perfectly well-formed and internally consistent, but 646 still probably WRONG, by looking for an asymmetry a correct analysis 647 should never produce. 648 649 The heuristic: find every "coordinating conjunction" token that uses 650 BOTH relatedtoken1 and relatedtoken2 (the two-conjunct pairing case -- 651 see that relation's own note about the one-sided, sentence-initial 652 exception, which this deliberately ignores since there's only one 653 conjunct to check there). For each such pair, if EXACTLY ONE of the 654 two joined tokens is a recognized verbal-unit anchor (`verbalunitid` 655 set to its own id) and the other is not, that asymmetry is flagged: if 656 the conjunction is genuinely pairing two nouns/adjectives/ 657 prepositional phrases, NEITHER side would be an anchor; if it's 658 correctly pairing two verbal expressions, BOTH sides would be. Only 659 the lopsided case -- one anchored, one not -- is unusual enough to be 660 worth a human look. 661 662 A repeated connector coordinating a series of three or more items 663 (polysyndeton, e.g. 'et...et...et' -- see latin_syntax_dspy.py's 664 docstring) fits this same pairwise shape naturally: a connector 665 strictly between two items relates directly to its flanking items via 666 relatedtoken1/relatedtoken2, exactly like an ordinary two-item pair, so 667 it's checked the same way. Only the introductory connector before the 668 very first item ("et A et B et C") is different -- it sets 669 relatedtoken1 alone, with no relatedtoken2 -- and that shape is already 670 excluded by this function's own requirement (above) that both 671 relatedtoken1 and relatedtoken2 be present, so no special-case is 672 needed for it here. 673 674 Returns a list of warning strings (empty if nothing looks suspicious), 675 the same "degrade visibly, don't raise" convention every other 676 warnings-returning function in this codebase uses. This is a 677 heuristic, not a guarantee: it can only flag the asymmetry itself, not 678 confirm the unanchored side really was meant to be a verb, so a clean 679 result here isn't a substitute for validate() or a human read of the 680 analysis -- and a flagged result deserves a look rather than an 681 automatic "fix," since guessing the right verbalunitid/relation back 682 in could just as easily paper over a different, unrelated mistake. 683 """ 684 by_id = {tok.id: tok for tok in tokengraph} 685 anchor_ids = {tok.id for tok in tokengraph if tok.verbalunitid == tok.id} 686 687 warnings: List[str] = [] 688 seen_pairs = set() 689 690 for tok in tokengraph: 691 if not ( 692 tok.relatedtoken1 is not None 693 and tok.relatedtoken1 != "root" 694 and tok.relationship1 == "coordinating conjunction" 695 and tok.relatedtoken2 is not None 696 and tok.relatedtoken2 != "root" 697 and tok.relationship2 == "coordinating conjunction" 698 ): 699 continue 700 701 pair = (tok.relatedtoken1, tok.relatedtoken2) 702 if pair in seen_pairs: 703 continue 704 seen_pairs.add(pair) 705 706 first_id, second_id = pair 707 first_anchored = first_id in anchor_ids 708 second_anchored = second_id in anchor_ids 709 if first_anchored == second_anchored: 710 # Both anchored (a correctly paired pair of verbs) or neither 711 # (almost certainly a noun/adjective/prepositional-phrase 712 # pair) -- either way, not the asymmetry this check looks for. 713 continue 714 715 anchored_id, unanchored_id = ( 716 (first_id, second_id) if first_anchored else (second_id, first_id) 717 ) 718 anchored_text = by_id[anchored_id].token if anchored_id in by_id else anchored_id 719 unanchored_text = by_id[unanchored_id].token if unanchored_id in by_id else unanchored_id 720 warnings.append( 721 f"{tok.id} ({tok.token!r}) coordinates {anchored_id} " 722 f"({anchored_text!r}), which anchors its own verbal unit, with " 723 f"{unanchored_id} ({unanchored_text!r}), which does not -- if " 724 "this conjunction is meant to join two verbal expressions " 725 "(rather than a noun/adjective/prepositional-phrase pair), " 726 f"{unanchored_id} is likely missing its own verbalunitid and " 727 "'unit verb'/'root' (or dependent-clause) relation." 728 ) 729 730 return warnings
Heuristic sanity check for a specific, observed live-LM mistake: a
coordinating conjunction that pairs two verbal expressions (see
latin_syntax_dspy.py's docstring) is supposed to leave BOTH conjuncts
anchoring their own verbal unit -- each with its own verbalunitid
(and its own verbalunits entry). In practice, the LM sometimes drops
this for the second conjunct, especially when that verb also governs
further subordinate structure of its own (a dependent clause, an
indirect statement) -- see gold_examples.py's
coordinating_conjunction_dedit_et_dixit_esse fixture for a real example
(dixit, coordinated with dedit via "et" AND governing its own indirect
statement, came back from a live model with no verbalunitid at all).
This is NOT the same kind of check as validate() (referential id integrity) or compute_subordination_depths()'s warnings (a resolvable- but-broken relation graph) -- both of those only catch a problem if the tokengraph is already self-inconsistent. This function catches a tokengraph that's perfectly well-formed and internally consistent, but still probably WRONG, by looking for an asymmetry a correct analysis should never produce.
The heuristic: find every "coordinating conjunction" token that uses
BOTH relatedtoken1 and relatedtoken2 (the two-conjunct pairing case --
see that relation's own note about the one-sided, sentence-initial
exception, which this deliberately ignores since there's only one
conjunct to check there). For each such pair, if EXACTLY ONE of the
two joined tokens is a recognized verbal-unit anchor (verbalunitid
set to its own id) and the other is not, that asymmetry is flagged: if
the conjunction is genuinely pairing two nouns/adjectives/
prepositional phrases, NEITHER side would be an anchor; if it's
correctly pairing two verbal expressions, BOTH sides would be. Only
the lopsided case -- one anchored, one not -- is unusual enough to be
worth a human look.
A repeated connector coordinating a series of three or more items (polysyndeton, e.g. 'et...et...et' -- see latin_syntax_dspy.py's docstring) fits this same pairwise shape naturally: a connector strictly between two items relates directly to its flanking items via relatedtoken1/relatedtoken2, exactly like an ordinary two-item pair, so it's checked the same way. Only the introductory connector before the very first item ("et A et B et C") is different -- it sets relatedtoken1 alone, with no relatedtoken2 -- and that shape is already excluded by this function's own requirement (above) that both relatedtoken1 and relatedtoken2 be present, so no special-case is needed for it here.
Returns a list of warning strings (empty if nothing looks suspicious), the same "degrade visibly, don't raise" convention every other warnings-returning function in this codebase uses. This is a heuristic, not a guarantee: it can only flag the asymmetry itself, not confirm the unanchored side really was meant to be a verb, so a clean result here isn't a substitute for validate() or a human read of the analysis -- and a flagged result deserves a look rather than an automatic "fix," since guessing the right verbalunitid/relation back in could just as easily paper over a different, unrelated mistake.
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(tokengraph: List[TokenAnalysis]) -> str: 151 """Render `tokengraph` as an HTML string: the same continuous text 152 `tokengraph_to_text()` produces -- identical spacing rules, and the same 153 punctuation/enclitic/quote-pair handling -- except every **lexical** 154 token, every **praenomen** token, every **numeral** token, and every 155 **coordinating conjunction** (any token with relationship1 or 156 relationship2 == "coordinating conjunction", lexical or not), has its 157 text wrapped in a `<span style="...">` colored by the verbal unit it 158 belongs to. Colors 159 come from `verbal_units.assign_verbal_units()` / 160 `assign_verbal_unit_colors()` -- the same assignment and the same 161 first-appearance palette ordering `tokengraph_to_mermaid()` uses for its 162 node coloring -- so a passage rendered here and the same passage's 163 Mermaid diagram color each verbal unit identically. 164 165 The coordinating-conjunction carve-out exists because a conjunction 166 like "-que" or "-ve" is typically tokentype "enclitic", not "lexical", 167 but `assign_verbal_units()` still resolves it to one of the units it 168 coordinates (see that module's docstring) -- e.g. in "arma virumque 169 cano.", "que" resolves to the same unit as "cano", same as "arma" and 170 "virum" do. Leaving it unwrapped would visually hide that assignment 171 even though it's a real one, unlike the other non-lexical tokentypes 172 below. A subordinating conjunction (e.g. "cum", "ut") doesn't need this 173 carve-out: it's always tokentype "lexical" (a full word, never 174 enclitic), so it's already wrapped. 175 176 The praenomen carve-out is the same idea for a different reason: 177 syntax_model.md's "Praenomina" section gives every `tokentype`= 178 "praenomen" token (e.g. "Sex.") its own real relation -- relatedtoken1 179 -> the lexical name it precedes, relationship1 = "praenomen" -- so 180 `assign_verbal_units()` resolves it to that name's own verbal unit 181 exactly like any other token in the clause (e.g. "Sex." lands in the 182 same unit as "Tarquinius", which is "venit"'s). Unlike the 183 coordinating-conjunction case, this is keyed on `tokentype` directly 184 rather than on the relationship label, matching how the "lexical" half 185 of this check works -- every praenomen, by convention, is eligible for 186 this treatment, not just ones that happen to already carry the 187 relation (a praenomen with nothing to relate to, e.g. "L." in the 188 genitive filiation formula "L. f.", simply has no verbal-unit 189 assignment and so renders unwrapped anyway, same as an unrelated 190 lexical token would). 191 192 The numeral carve-out is for the same reason again: syntax_model.md's 193 tokenization section restricts `tokentype`="numeral" to a number 194 written NUMERICALLY (Roman or Arabic) -- a number spelled out as an 195 ordinary word (e.g. "decem") is "lexical" instead -- but a numeral is 196 otherwise an ordinary participant in the clause, able to carry a real 197 relation like any noun or adjective (e.g. "XII" modifying "filii" via 198 "adjectival", the same relation "decem" would use if spelled out). 199 `assign_verbal_units()` resolves that relation exactly like any other, 200 so a numeral belonging to a verbal unit is wrapped the same way a 201 lexical token would be -- unlike punctuation, a non-conjunction 202 enclitic, or an abbreviation, none of which carry that kind of 203 ordinary syntactic relation under the current scheme. 204 205 Every other non-lexical, non-praenomen, non-numeral token -- 206 punctuation, a non-conjunction enclitic (e.g. the interrogative "-ne"), 207 and abbreviations -- is still emitted as plain (escaped) text even 208 though `assign_verbal_units()` assigns every token, including 209 punctuation, to whichever unit its relations resolve to; this function 210 just doesn't turn that assignment into a span for anything else. A 211 lexical, praenomen, numeral, or coordinating-conjunction token 212 belonging to no verbal unit (assignment is `None`, e.g. a bare 213 accusative of place) is left unwrapped too, as is one whose unit 214 happens to have no non-punctuation member at all and so never got a 215 color slot from `assign_verbal_unit_colors()` (should not occur in 216 practice for a lexical, praenomen, or numeral token, since it's always 217 a non-punctuation member of its own unit, but handled defensively 218 rather than assumed). 219 220 An **implied/elided token** (models.py's IMPLIED_TOKENTYPES: "implied 221 sum", "continued discourse", "implied subject") is omitted entirely -- same as 222 tokengraph_to_text() -- rather than rendered with any span: it has no 223 surface text (`tok.token` is always `None`), and unlike 224 `tokengraph_to_mermaid()`'s diagram (which DOES show these, as their 225 own specially-colored, specially-labeled node -- see that module's own 226 docstring), inserting placeholder text into the middle of reconstructed 227 prose here would misrepresent what the passage actually says. The 228 Mermaid diagram is the one place an implied token's presence is worth 229 seeing at all. 230 231 Every token's text is HTML-escaped (`&`, `<`, `>`, and quote characters) 232 before being emitted, spans or not -- real Latin text can contain a 233 literal `"` or `'` (see the quote-pair handling below), which would 234 otherwise be indistinguishable from markup to anything that re-parses 235 this output. 236 237 The span's inline style sets both `background-color` (the verbal unit's 238 palette `fill`, the same value used as a Mermaid node's `fill`) and 239 `color` (the palette's `text` value, currently black for every slot) -- 240 the latter so the token reads correctly regardless of whatever text 241 color the surrounding page has set, matching the explicit black 242 `color:` every Mermaid node in that unit also gets. 243 """ 244 assignment = assign_verbal_units(tokengraph) 245 colors, _warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment) 246 return _tokens_to_html(tokengraph, assignment, colors)
Render tokengraph as an HTML string: the same continuous text
tokengraph_to_text() produces -- identical spacing rules, and the same
punctuation/enclitic/quote-pair handling -- except every lexical
token, every praenomen token, every numeral token, and every
coordinating conjunction (any token with relationship1 or
relationship2 == "coordinating conjunction", lexical or not), has its
text wrapped in a <span style="..."> colored by the verbal unit it
belongs to. Colors
come from verbal_units.assign_verbal_units() /
assign_verbal_unit_colors() -- the same assignment and the same
first-appearance palette ordering tokengraph_to_mermaid() uses for its
node coloring -- so a passage rendered here and the same passage's
Mermaid diagram color each verbal unit identically.
The coordinating-conjunction carve-out exists because a conjunction
like "-que" or "-ve" is typically tokentype "enclitic", not "lexical",
but assign_verbal_units() still resolves it to one of the units it
coordinates (see that module's docstring) -- e.g. in "arma virumque
cano.", "que" resolves to the same unit as "cano", same as "arma" and
"virum" do. Leaving it unwrapped would visually hide that assignment
even though it's a real one, unlike the other non-lexical tokentypes
below. A subordinating conjunction (e.g. "cum", "ut") doesn't need this
carve-out: it's always tokentype "lexical" (a full word, never
enclitic), so it's already wrapped.
The praenomen carve-out is the same idea for a different reason:
syntax_model.md's "Praenomina" section gives every tokentype=
"praenomen" token (e.g. "Sex.") its own real relation -- relatedtoken1
-> the lexical name it precedes, relationship1 = "praenomen" -- so
assign_verbal_units() resolves it to that name's own verbal unit
exactly like any other token in the clause (e.g. "Sex." lands in the
same unit as "Tarquinius", which is "venit"'s). Unlike the
coordinating-conjunction case, this is keyed on tokentype directly
rather than on the relationship label, matching how the "lexical" half
of this check works -- every praenomen, by convention, is eligible for
this treatment, not just ones that happen to already carry the
relation (a praenomen with nothing to relate to, e.g. "L." in the
genitive filiation formula "L. f.", simply has no verbal-unit
assignment and so renders unwrapped anyway, same as an unrelated
lexical token would).
The numeral carve-out is for the same reason again: syntax_model.md's
tokenization section restricts tokentype="numeral" to a number
written NUMERICALLY (Roman or Arabic) -- a number spelled out as an
ordinary word (e.g. "decem") is "lexical" instead -- but a numeral is
otherwise an ordinary participant in the clause, able to carry a real
relation like any noun or adjective (e.g. "XII" modifying "filii" via
"adjectival", the same relation "decem" would use if spelled out).
assign_verbal_units() resolves that relation exactly like any other,
so a numeral belonging to a verbal unit is wrapped the same way a
lexical token would be -- unlike punctuation, a non-conjunction
enclitic, or an abbreviation, none of which carry that kind of
ordinary syntactic relation under the current scheme.
Every other non-lexical, non-praenomen, non-numeral token --
punctuation, a non-conjunction enclitic (e.g. the interrogative "-ne"),
and abbreviations -- is still emitted as plain (escaped) text even
though assign_verbal_units() assigns every token, including
punctuation, to whichever unit its relations resolve to; this function
just doesn't turn that assignment into a span for anything else. A
lexical, praenomen, numeral, or coordinating-conjunction token
belonging to no verbal unit (assignment is None, e.g. a bare
accusative of place) is left unwrapped too, as is one whose unit
happens to have no non-punctuation member at all and so never got a
color slot from assign_verbal_unit_colors() (should not occur in
practice for a lexical, praenomen, or numeral token, since it's always
a non-punctuation member of its own unit, but handled defensively
rather than assumed).
An implied/elided token (models.py's IMPLIED_TOKENTYPES: "implied
sum", "continued discourse", "implied subject") is omitted entirely -- same as
tokengraph_to_text() -- rather than rendered with any span: it has no
surface text (tok.token is always None), and unlike
tokengraph_to_mermaid()'s diagram (which DOES show these, as their
own specially-colored, specially-labeled node -- see that module's own
docstring), inserting placeholder text into the middle of reconstructed
prose here would misrepresent what the passage actually says. The
Mermaid diagram is the one place an implied token's presence is worth
seeing at all.
Every token's text is HTML-escaped (&, <, >, and quote characters)
before being emitted, spans or not -- real Latin text can contain a
literal " or ' (see the quote-pair handling below), which would
otherwise be indistinguishable from markup to anything that re-parses
this output.
The span's inline style sets both background-color (the verbal unit's
palette fill, the same value used as a Mermaid node's fill) and
color (the palette's text value, currently black for every slot) --
the latter so the token reads correctly regardless of whatever text
color the surrounding page has set, matching the explicit black
color: every Mermaid node in that unit also gets.
328def tokengraph_to_depth_html( 329 tokengraph: List[TokenAnalysis], 330 indent_em: float = _DEFAULT_DEPTH_INDENT_EM, 331 depth: Optional[int] = None, 332) -> Tuple[str, List[str]]: 333 """Render `tokengraph` as HTML illustrating each verbal expression's 334 *depth of subordination* (see verbal_units.compute_subordination_ 335 depths()): tokens are assembled sequentially exactly as 336 tokengraph_to_html() does -- same spacing, escaping, and verbal-unit 337 color highlighting -- but grouped into consecutive-run "blocks" by 338 which verbal unit each token belongs to (per assign_verbal_units()), 339 each rendered as its own <div> indented by a CSS margin-left of 340 `depth * indent_em` em -- 0 for an independent clause, 1 for a clause 341 it introduces (a dependent clause, a direct quote, an aside, a 342 circumstantial participle/ablative absolute, or -- now that indirect- 343 statement infinitives carry their own governing-verb relation -- an 344 indirect statement too), 2 for a verbal expression THAT one in turn 345 introduces, and so on. All layout is CSS (margin-left/margin-bottom on 346 each block's <div>) -- no table or nested-list structure is used to 347 produce the indentation. 348 349 `depth`, if given, caps how deep the rendering goes: ONLY blocks whose 350 own depth of subordination is <= `depth` are included in the output -- 351 a block deeper than that is dropped entirely, not rendered empty or 352 grayed out. `depth=0` shows root/independent clauses only (and direct 353 quotes, asides, and any other depth-0 construction); omit `depth` (or 354 pass `None`, the default) to show every block, same as before this 355 parameter existed. Valid values run from 0 up to 356 verbal_units.max_subordination_depth()'s own return value for this 357 `tokengraph` (that function exists specifically to help a caller pick 358 a sensible value here); a negative `depth` raises ValueError, since 359 there's no clause shallower than root. A `depth` larger than the 360 passage's actual maximum is accepted, not an error -- it just means 361 "show everything," identical to leaving `depth` unset. 362 363 Block boundaries follow assign_verbal_units()'s token-to-unit 364 assignment, with one adjustment: an **enclitic** token never starts a 365 new block, even when its own assignment differs from the block 366 currently open (see tests/test_rendering.py's coordinating-conjunction 367 word-order-mismatch case for exactly this -- an enclitic coordinating 368 conjunction like "-que" can resolve to a DIFFERENT verbal unit than the 369 word it's orthographically glued to, e.g. in "Hermionenque", and 370 starting a new block there would split one Latin word across two 371 <div>s). A token with no verbal-unit assignment at all (None -- 372 typically punctuation, or a token syntax_model.md doesn't document a 373 relation for) likewise never starts a new block; it folds into 374 whichever block is currently open, so a stray comma or postpositive 375 particle doesn't fragment the layout. Leading tokens before the first 376 resolvable verbal-unit token (rare) default to depth 0. 377 378 Note that a circumstantial-participle/ablative-absolute noun (e.g. 379 "Anco" in "Anco regnante...", or "eum" in "Eum advenientem...") 380 resolves, per assign_verbal_units()'s own established convention, to 381 whatever unit ITS OWN relation reaches -- typically the outer clause -- 382 while the participle itself is its own singleton unit; this means a 383 circumstantial-participle phrase renders as the noun staying in the 384 outer clause's block and the bare participle as its own one-word 385 indented block, rather than the whole phrase indenting together. That 386 follows directly from the noun's own documented relation (it fits into 387 the surrounding clause, or points at the main verb as an ablative 388 absolute) and isn't specific to this function. 389 390 A verbal expression whose depth couldn't be resolved (see 391 compute_subordination_depths()) renders at depth 0 rather than 392 raising, with a warning explaining why -- the same "degrade visibly, 393 don't crash" convention tokengraph_to_mermaid() uses. 394 395 Returns (html, warnings), combining assign_verbal_unit_colors()'s 396 warnings (colors repeating past 8 verbal units) and 397 compute_subordination_depths()'s (an unresolved governing verbal 398 expression) -- computed the same way, and returned in full, regardless 399 of whether `depth` filters some blocks out of the rendered `html` 400 itself. 401 """ 402 if depth is not None and depth < 0: 403 raise ValueError(f"depth must be >= 0 (root clauses only), got {depth!r}") 404 405 assignment = assign_verbal_units(tokengraph) 406 colors, color_warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment) 407 depths, depth_warnings = compute_subordination_depths(tokengraph) 408 warnings = color_warnings + depth_warnings 409 410 blocks = [] 411 for tok in tokengraph: 412 unit_id = assignment.get(tok.id) 413 starts_new_block = ( 414 unit_id is not None 415 and tok.tokentype != "enclitic" 416 and (not blocks or blocks[-1][0] != unit_id) 417 ) 418 if starts_new_block: 419 blocks.append((unit_id, [])) 420 elif not blocks: 421 # Leading token(s) with no verbal-unit assignment yet (or a 422 # leading enclitic, in principle) -- open a placeholder block 423 # rather than crashing on an empty blocks list below. 424 blocks.append((None, [])) 425 blocks[-1][1].append(tok) 426 427 lines = [] 428 for unit_id, block_tokens in blocks: 429 block_depth = depths.get(unit_id) if unit_id is not None else 0 430 if block_depth is None: 431 block_depth = 0 432 if depth is not None and block_depth > depth: 433 # This whole block is deeper than the requested cutoff -- 434 # drop it entirely rather than rendering an empty/grayed-out 435 # placeholder for it. 436 continue 437 block_html = _tokens_to_html(block_tokens, assignment, colors) 438 margin_left = block_depth * indent_em 439 lines.append( 440 f'<div style="margin-left: {margin_left}em; margin-bottom: 0.35em;">' 441 f"{block_html}</div>" 442 ) 443 444 return "\n".join(lines), warnings
Render tokengraph as HTML illustrating each verbal expression's
depth of subordination (see verbal_units.compute_subordination_
depths()): tokens are assembled sequentially exactly as
tokengraph_to_html() does -- same spacing, escaping, and verbal-unit
color highlighting -- but grouped into consecutive-run "blocks" by
which verbal unit each token belongs to (per assign_verbal_units()),
each rendered as its own
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.
33class SentenceAnalysis(dspy.Signature): 34 """Analyze the syntax of a passage of Latin according to a two-part scheme: 35 36 (1) a list of verbal expressions. Three constructions count as a verbal 37 expression: finite verbs, infinitives, and participles. 38 39 - A finite verb (including compound perfect/pluperfect passive forms 40 of participle + a form of 'sum') is always a verbal expression. 41 Classify its syntactic type as 'independent' (main/principal), 42 'dependent' (subordinate, introduced by a subordinating word), 43 'direct quote' (occurring in directly quoted speech framed by 44 another verb, e.g. "est" in `"Tuum est," inquit, "Servi regnum."`), 45 or 'aside' (a verbal expression that interrupts the surrounding 46 syntax, e.g. "dixerim" in "pace dixerim deum" interrupting "eos... 47 spero"). 48 - An infinitive is a verbal expression only when part of an indirect 49 statement; its syntactic type is always 'indirect statement'. In a 50 compound future-infinitive form (participle + a form of 'sum', 51 e.g. "facturum...fuisse"), the participle/infinitive itself 52 anchors the verbal expression, same as a compound passive. 53 - A participle is a verbal expression only when it has a *predicate* 54 sense (e.g. an ablative-absolute-like "Anco regnante Lucumo...", 55 'while Ancus was reigning') rather than a purely *attributive* 56 sense (modifying a noun like an ordinary adjective, e.g. 57 "consentiens laus", 'universal praise' -- NOT a verbal expression 58 at all). Use 'dependent' as its syntactic type. When it's 59 genuinely uncertain whether a given participle is attributive or 60 predicate/circumstantial, PREFER the circumstantial reading -- 61 treat it as its own verbal expression rather than folding it into 62 an attributive relation. Example: in "ille moriens, cum sciret 63 sagittas hydrae Lernaeae felle tinctas quantam uim haberent 64 ueneni, sanguinem suum exceptum Deianirae dedit", both "moriens" 65 (agreeing with "ille") and "tinctas" (agreeing with "sagittas") 66 are treated as circumstantial participles, each anchoring its own 67 verbal expression, rather than as ordinary attributive 68 adjectives. 69 70 Classify each verbal expression's semantic type too (transitive 71 active/transitive passive/intransitive/linking verb). 72 73 (2) a token-by-token dependency graph. For each token, record up to two 74 relations to other tokens (by id), using only these relation labels: 75 76 - unit verb (independent): every INDEPENDENT verb has relatedtoken1 77 = the special sentinel string 'root' -- never an actual token id; 78 no real token may be assigned the id 'root' -- and relationship1 = 79 'unit verb'. 80 - unit verb (dependent) / subordinating conjunction / relative 81 pronoun: the verb of a DEPENDENT clause has relatedtoken1 -> the 82 id of its subordinating conjunction or relative pronoun, 83 relationship1 = 'unit verb'. That conjunction or pronoun in turn 84 has relatedtoken1 -> the id of the verb of the clause it is 85 subordinate to, with relationship1 = 'subordinating conjunction' 86 for a conjunction, or relatedtoken1 -> its antecedent's id with 87 relationship1 = 'relative pronoun' for a relative pronoun. 88 Indirect questions are treated as a kind of dependent clause: the 89 interrogative word introducing one (e.g. "quanta" in "Theseus 90 audit quanta calamitate ciuitas afficeretur") is treated the same 91 way as a subordinating conjunction -- it has relatedtoken1 -> the 92 id of the verb it introduces (here "audit"), relationship1 = 93 'subordinating conjunction' (no separate label for this case) -- 94 while the dependent verb itself ("afficeretur") has relatedtoken1 95 -> the interrogative word's id ("quanta"), relationship1 = 'unit 96 verb', exactly like any other dependent clause. 97 - indirect statement (governing verb): an infinitive anchoring an 98 indirect-statement verbal expression ALSO has relatedtoken1 -> 99 the id of the verb that governs the indirect statement (the verb 100 of saying/thinking/perceiving it depends on), relationship1 = 101 'indirect statement' -- matching its own syntactic type, the same 102 convention 'direct quote' and 'aside' verbal expressions use 103 below. There's no separate subordinating-word token to point at 104 first (a Latin accusative-and-infinitive construction has no 105 equivalent of English 'that'), so the infinitive points directly 106 at its governing verb, rather than via a conjunction/pronoun 107 intermediary the way a dependent finite verb's 'unit verb' 108 relation does. In a compound future-infinitive form (participle + 109 a form of 'sum'), this relation belongs on the participle/ 110 infinitive itself, since IT anchors the verbal expression (see 111 'auxiliary' below) -- the form of 'sum' takes no relation of its 112 own into the governing verb. 113 - complementary infinitive: an infinitive that completes the sense 114 of a governing verb like 'volo', 'incipio', 'audeo', 'licet', or 115 'decet' (rather than reporting indirect speech) has relatedtoken1 116 -> the id of that governing verb, relationship1 = 'complementary 117 infinitive'. Unlike an indirect-statement infinitive, this does 118 NOT make the infinitive its own verbal expression -- it gets no 119 `verbalunits` entry of its own; the governing verb is still the 120 only verbal expression here. Example: in "Amphion...cum templum 121 Apollinis expugnare vellet...", "expugnare" completes "vellet" 122 (relatedtoken1 -> "vellet", relationship1 = 'complementary 123 infinitive'); "templum" is still "expugnare"'s own direct object, 124 exactly as if "expugnare" were a finite verb. 125 - infinitive used as a noun: an infinitive can also function as an 126 ordinary noun -- most often a verb's subject or object -- rather 127 than anchoring an indirect statement or completing another verb. 128 Treat it exactly like any other noun in that role: relatedtoken1 129 -> the verb it's the subject/object of, relationship1 = 'subject' 130 or 'direct object' as appropriate (no dedicated label, and again 131 no `verbalunits` entry of its own). Example: in "dolere malum 132 est", "dolere" has relatedtoken1 -> "est", relationship1 = 133 'subject'. Like any verbal form, an infinitive used this way can 134 still take its own object or adverb, related to it the same way 135 they'd relate to a finite verb. 136 - gerunds and gerundives: a gerundive is simply an adjective -- 137 treat it exactly like one (relatedtoken1 -> the noun it agrees 138 with, relationship1 = 'adjectival'; see 'adjectival' below). 139 Example: in "...ad sacrum faciendum", "faciendum" (the gerundive) 140 has relatedtoken1 -> "sacrum", relationship1 = 'adjectival'. A 141 gerund is a noun -- the oblique-case form a verb takes where an 142 infinitive would be needed in the nominative -- so relate it like 143 any other noun (most often 'genitive'); it can still take its own 144 object or adverb, related to it the same way they'd relate to a 145 finite verb or infinitive. Example: in "ars bene disserendi", 146 "disserendi" (the gerund) has relatedtoken1 -> "ars", 147 relationship1 = 'genitive', and "bene" (the adverb modifying it) 148 has relatedtoken1 -> "disserendi", relationship1 = 'adverbial'. 149 Neither a gerund nor a gerundive is a verbal expression in its 150 own right -- no dedicated label, no `verbalunits` entry. 151 - coordinating conjunction: when a coordinating conjunction (e.g. 152 'et', '-que') joins a pair of adjectives, nouns, or prepositional 153 phrases, it has relatedtoken1 -> the id of the first joined 154 token, relatedtoken2 -> the id of the second, with BOTH 155 relationship1 and relationship2 = 'coordinating conjunction' (not 156 an overflow slot here -- this is the one relation that genuinely 157 uses relatedtoken1 and relatedtoken2 for two ends of the same 158 relation at once). When it joins two verbal expressions instead, 159 relatedtoken1/relatedtoken2 are the ids of the two verbs (or, for 160 an infinitive/participle-anchored verbal expression, the id that 161 anchors it) rather than of nearby nouns -- go by which verbal 162 expression the conjunction functionally introduces, NOT by which 163 token it happens to be adjacent to or (for an enclitic like 164 '-que') physically attached to; those can differ (e.g. an 165 enclitic conjunction attached to the second clause's direct 166 object still relates the two VERBS, not the object). If the 167 conjunction opens an entirely new sentence with no explicit verb 168 to its left to pair with, set only relatedtoken1/relationship1 (-> 169 the verb it introduces); do not invent a link to an implied 170 preceding clause. 'et' specifically can also function as a plain 171 adverb ('even', 'also') rather than a conjunction -- when it 172 does, treat it like any other adverb: relatedtoken1 -> the verb 173 or (if there is none, e.g. a verbless exclamation) the nearest 174 token it emphasizes, relationship1 = 'adverbial', not 175 'coordinating conjunction'. IMPORTANT: when the conjunction joins 176 two independent verbs, BOTH still get their own `verbalunits` 177 entry and their own relatedtoken1 = 'root'/relationship1 = 'unit 178 verb' -- this doesn't change just because one of them (usually 179 the second) also governs further subordinate structure of its 180 own (a dependent clause, an indirect statement, etc). A verb 181 that governs an indirect statement or introduces a further 182 clause is NOT thereby demoted to a mere "framing verb" for what 183 follows -- it is still, independently, one of the two 184 coordinated root verbs, and needs its own entry exactly like the 185 first one. Example: in "...dedit et id philtrum esse dixit.", 186 dedit and dixit are both independent verbs coordinated by et; 187 dixit ALSO governs the indirect statement anchored at esse 188 ('id philtrum esse'), but that does not exempt dixit itself from 189 getting relatedtoken1 = 'root', relationship1 = 'unit verb', and 190 its own entry in `verbalunits` -- exactly as if it stood alone. 191 - coordinating conjunction, repeated as a series: a conjunction 192 like 'et' or 'aut' can also be repeated to coordinate a series 193 of three or more items (polysyndeton, e.g. 'et...et...et'), not 194 just used once between a pair. Two shapes occur, and they're 195 annotated differently: 196 - "A et B et C" (a conjunction only BETWEEN items, none before 197 the first): annotate each connector exactly like the simple 198 pairwise case above -- relatedtoken1 -> the item immediately 199 BEFORE it, relatedtoken2 -> the item immediately AFTER it, 200 both relationship1/relationship2 = 'coordinating 201 conjunction'. The first connector (between A and B) has 202 relatedtoken1 -> A, relatedtoken2 -> B; the second (between 203 B and C) has relatedtoken1 -> B, relatedtoken2 -> C; and so 204 on for a longer series. Every connector always relates 205 directly to the two real items on either side of it -- 206 NEVER to another connector. 207 - "et A et B et C" (an introductory connector before the 208 FIRST item too, one connector per item): the introductory 209 connector (before A) has ONLY relatedtoken1 -> A, 210 relationship1 = 'coordinating conjunction' -- there is no 211 preceding item for it to pair with, so relatedtoken2 is left 212 unset, same as the sentence-initial one-sided case above. 213 Every connector AFTER the introductory one is annotated 214 exactly like the "A et B et C" case: relatedtoken1 -> the 215 PRECEDING item, relatedtoken2 -> the FOLLOWING item, both 216 relationship labels 'coordinating conjunction'. So the 217 second connector (before B) has relatedtoken1 -> A, 218 relatedtoken2 -> B; the third (before C) has relatedtoken1 219 -> B, relatedtoken2 -> C. 220 Either way, every connector in a series relates directly to the 221 two real coordinated items flanking it (or, for an introductory 222 connector, the one item following it) -- never to a neighboring 223 connector. Each connected item ALSO keeps its own ordinary 224 relation to the rest of the sentence (subject, ablative, direct 225 object, or whatever fits), completely independent of this 226 chain -- the coordinating-conjunction relation only links a 227 connector to its flanking item(s); it never substitutes for 228 that item's own relation to whatever governs it. Example: in 229 "Tarquinius et assiduitate et varietate et magnificentia omnes 230 antecessit" ("et A et B et C" shape -- an et before every one of 231 the three ablatives, including the first), the first et 232 (introductory) has ONLY relatedtoken1 -> assiduitate; the 233 second et has relatedtoken1 -> assiduitate, relatedtoken2 -> 234 varietate; the third et has relatedtoken1 -> varietate, 235 relatedtoken2 -> magnificentia. assiduitate, varietate, and 236 magnificentia each ALSO have their own relatedtoken1 -> 237 antecessit, relationship1 = 'ablative', same as any other 238 ablative -- unaffected by which connector introduces them. 239 - direct quote / aside: a verbal expression of syntactic type 240 'direct quote' or 'aside' has relatedtoken1 -> the id of the verb 241 of the clause it interrupts or is framed by, relationship1 = 242 'direct quote' or 'aside' respectively (matching its syntactic 243 type). 244 - circumstantial participle / ablative absolute: a participial 245 verbal expression's own relatedtoken1 -> the id of the noun or 246 pronoun it agrees with, relationship1 = 'circumstantial 247 participle'. That noun in turn: if it also fits a normal role in 248 the surrounding clause (e.g. it's already the main verb's direct 249 object), it takes THAT normal relation instead (nothing extra to 250 add). If it's an ablative with no other syntactic connection to 251 the sentence (a true ablative absolute), it instead has 252 relatedtoken1 -> the id of the main verb, relationship1 = 253 'ablative absolute'. Sometimes there is no noun or pronoun at 254 all for the participle to agree with -- most often when it 255 agrees with a governing verb's own unexpressed subject. Do NOT 256 leave the participle's relatedtoken1 pointing at the verb 257 directly in that case (that would misrepresent the participle as 258 relating straight to the verb, the way an ablative-absolute noun 259 does): instead add a new tokentype='implied subject' token (see 260 (3) below) standing in for the missing noun, give IT the normal 261 'subject' relation into the verb, and have the participle relate 262 to THIS new token via 'circumstantial participle' exactly as it 263 would to a real one. 264 - auxiliary: in a compound perfect/pluperfect passive, or compound 265 future-infinitive, verb form (participle + a form of 'sum'), the 266 participle or infinitive itself anchors the verbal expression and 267 is the target of every relation into it (subject, direct object, 268 agent, etc); the accompanying form of 'sum' instead has 269 relatedtoken1 -> the id of that participle/infinitive, 270 relationship1 = 'auxiliary'. The same pattern applies to an 271 impersonal passive of an intransitive verb (e.g. "ventum erat", 272 'there had been a coming'): the form of 'sum' still relates to 273 the participle as its auxiliary, even with no subject. 274 - agent: the preposition 'a'/'ab' introducing the agent of a passive 275 verb has relatedtoken1 -> the passive verb's id (the id of the 276 participle, for a compound form -- NOT the accompanying form of 277 'sum'), relationship1 = 'agent'. The noun/pronoun governed by that 278 'a'/'ab' has relatedtoken1 -> the id of 'a'/'ab', relationship1 = 279 'object of preposition'. 280 - subject / direct object / predicate: a noun or pronoun serving as 281 subject or direct object has relatedtoken1 -> the id of the verb 282 (the id of the participle or infinitive, NOT the accompanying 283 form of 'sum', for a compound passive or future-infinitive form), 284 relationship1 = 'subject' or 'direct object'. This applies to the 285 accusative subject of an infinitive in indirect statement too. A 286 noun or pronoun serving as the predicate complement of a LINKING 287 verb uses relationship1 = 'predicate' instead, same relatedtoken1 288 target. If the token is a relative pronoun already using 289 relatedtoken1/relationship1 for its antecedent link, put this 290 relation in relatedtoken2/relationship2 instead. 291 - adjectival: an adjective (or an attributive participle) modifying 292 a noun has relatedtoken1 -> the noun's id, relationship1 = 293 'adjectival'. An adjective used as a substantive (standing in for 294 a noun) is treated as a noun/pronoun instead, not as adjectival. 295 - genitive / dative / ablative / accusative: a noun in the 296 genitive, dative, ablative, or accusative case that depends on a 297 verb or another noun -- and isn't already covered by a more 298 specific relation above (subject, direct object, object of 299 preposition, ablative absolute, etc) -- has relatedtoken1 -> the 300 id of the verb or noun it depends on, relationship1 = the 301 matching case name ('genitive', 'dative', 'ablative', or 302 'accusative'). These are purely syntactic (case-function) labels, 303 not semantic ones -- don't distinguish e.g. possessive vs. 304 partitive genitive. 'accusative' specifically covers an 305 accusative relation that ISN'T a direct object: a bare 306 accusative of place to which (e.g. "Romam" in "Romam venit", 307 relatedtoken1 -> "venit", even though "venit" is intransitive) 308 or an accusative of extent that modifies another NOUN rather 309 than a verb (e.g. "milia" in "duo milia passuum iter fecerunt", 310 relatedtoken1 -> "iter", the noun it qualifies, not "fecerunt"). 311 The idiomatic construction 'opus est' + an ablative is a special 312 case worth noting for 'ablative': the ablative token relates to 313 "opus" itself, not to "est" -- e.g. in "Collatinus negat verbis 314 opus esse", "verbis" has relatedtoken1 -> "opus", relationship1 = 315 'ablative'. 316 - vocative: a noun in the vocative case (direct address) has 317 relatedtoken1 -> the id of the verb of the clause it's addressed 318 within, relationship1 = 'vocative'. Unlike 'genitive'/'dative'/ 319 'ablative'/'accusative' above, a vocative relates to a verb 320 only, never to another noun. Example: in "Non est ita, domine, 321 sed servi tui venerunt ut emerent cibos.", "domine" has 322 relatedtoken1 -> "est", relationship1 = 'vocative'. 323 - apposition: when one noun stands in apposition to another, the 324 appositive has relatedtoken1 -> the id of the first (the noun it 325 restates or further identifies), relationship1 = 'apposition'. A 326 genitive depending on either noun still gets its own ordinary 327 'genitive' relation, pointing at whichever noun it actually 328 depends on -- apposition doesn't change that. Example: in 329 "Neptunus et Aegeus Pandionis filius...cum Aethra Pitthei 330 filia...", "filius" is in apposition to "Aegeus" (relatedtoken1 331 -> "Aegeus", relationship1 = 'apposition'), and "Pandionis" (the 332 genitive depending on "filius") has relatedtoken1 -> "filius", 333 relationship1 = 'genitive' -- and likewise "filia" is in 334 apposition to "Aethra", with "Pitthei" as a genitive depending on 335 "filia". 336 - praenomen: a token of tokentype 'praenomen' (an abbreviated Roman 337 first name, e.g. "M." or "Sex.") has relatedtoken1 -> the id of 338 the LEXICAL token spelling out the individual's own name that it 339 abbreviates/precedes, relationship1 = 'praenomen'. Example: in 340 "Sex. Tarquinius inscio Collatino...venit", "Sex." has 341 relatedtoken1 -> "Tarquinius", relationship1 = 'praenomen'. If 342 there is no such lexical name token to relate to -- e.g. the 343 genitive filiation formula "L. f." ("Lucii filius", 'son of 344 Lucius'), where "L." precedes only the abbreviation "f." rather 345 than a lexical name -- leave it unrelated, same as any other 346 token with no relation of these kinds. 347 - prepositional phrases: the preposition has relatedtoken1 -> the id 348 of the verb (adverbial) or noun (attributive) it modifies, 349 relationship1 = 'adverbial' or 'attributive'. The noun/pronoun it 350 governs has relatedtoken1 -> the id of the preposition, 351 relationship1 = 'object of preposition' (or relatedtoken2/ 352 relationship2 if relatedtoken1 is already used for a 353 relative-pronoun link). 354 - adverbial (bare adverb): an adverb modifying a verb has 355 relatedtoken1 -> the verb's id, relationship1 = 'adverbial' -- 356 the same relationship1 value as a preposition modifying a verb, 357 just with no object-of-preposition token on the other end. 358 359 Only assign relations described above. Leave relatedtoken/ 360 relationship fields unset for tokens with no relation of these 361 kinds -- not every token will have one (e.g. a bare accusative of 362 place isn't covered). Use only the token ids given in 363 the input `tokens` list, the sentinel 'root', or a NEW id you 364 create for an implied token (see below), in your output; never 365 invent an id for anything else. 366 367 (3) implied/elided tokens. `arsgrammatica` recognizes three DIFFERENT 368 situations where something exists grammatically but has no surface 369 realization in the passage at all -- rather than skip these, add a 370 NEW entry to `tokengraph` with: a brand-new id, not used by any 371 entry in `tokens` or elsewhere in your own output (see the naming 372 rule below); the matching tokentype below; and no `token` value 373 (leave it unset/None) -- these go together, and 'implied sum', 374 'continued discourse', and 'implied subject' are the ONLY three 375 tokentype values whose id isn't one of `tokens`' own ids and whose 376 `token` is empty. `continued discourse` always stands in for a 377 missing VERBAL expression and so always needs a matching new entry 378 in `verbalunits`, exactly like any other verbal expression. 379 `implied sum` stands in for a missing verbal expression in TWO of 380 its three sub-cases (and needs a `verbalunits` entry there too), 381 but NOT in its third -- the compound-passive-with-omitted-auxiliary 382 sub-case, where a real, already-present participle anchors the 383 verbal expression instead and the implied token merely relates to 384 it as 'auxiliary' (see below for which is which). `implied subject` 385 stands in for a missing NOUN or pronoun instead, and never gets a 386 `verbalunits` entry of its own. 387 388 - tokentype 'implied sum': an elided present of 'sum' ('to be'). 389 Three sub-cases, all using this same tokentype: 390 - a bare predicate construction (subject + predicate noun/ 391 adjective, no verb at all): the implied token anchors a 392 verbal expression classified 'independent' (or 'dependent', 393 if the elided-'sum' clause is itself subordinate) and 394 'linking verb'; the subject and predicate each relate to it 395 exactly as they would to any linking verb ('subject' / 396 'predicate'). Example: "omnia praeclara rara" ('all splendid 397 things [are] rare') has an implied token anchoring an 398 'independent'/'linking verb' expression, with "omnia" (its 399 own 'praeclara' adjectival) as 'subject' and "rara" as 400 'predicate'. 401 - a compound perfect/pluperfect passive (or impersonal 402 passive) with its auxiliary omitted (e.g. "consules facti" 403 for "consules facti sunt"): UNLIKE the other two sub-cases, 404 the implied token here does NOT anchor the verbal 405 expression -- the participle itself ("facti") is already a 406 real, present token, so IT anchors the verbal expression 407 (its own `verbalunits` entry, relatedtoken1/relationship1 = 408 'root'/'unit verb' if independent, exactly as if it were an 409 ordinary one-word verb) and every relation that would 410 normally target the written-out auxiliary (subject, direct 411 object, predicate, agent, adverbs, etc.) targets the 412 participle instead. The implied token stands in only for the 413 omitted form of 'sum' itself: it gets relatedtoken1 -> the 414 participle's id, relationship1 = 'auxiliary' (exactly as a 415 written-out auxiliary would relate to the participle -- see 416 'auxiliary' above), and it does NOT get its own `verbalunits` 417 entry, since the participle already supplies the verbal 418 expression. 419 - the present participle of 'sum' does not exist in Latin at 420 all, so an ablative-absolute-style predicate construction 421 built on it (e.g. "Agrippa Menenio P. Postumio consulibus", 422 '[when] Agrippa Menenius [and] Publius Postumius [were] 423 consuls') is ALWAYS implied, never optional. Classify the 424 implied token's verbal expression 'dependent' (this 425 codebase's circumstantial-participle convention -- see 426 VerbalExpression's own docstring) and 'linking verb'; relate 427 it to its noun via 'circumstantial participle' exactly like 428 any other circumstantial participle. 429 - tokentype 'continued discourse': continuation of indirect 430 discourse -- a long run of indirect statements can share one 431 governing verb of speaking/thinking stated once, then omitted 432 across several further coordinate statements. Add ONE implied 433 token (tokentype 'continued discourse') for that omitted 434 governing verb (syntactic type 'independent' unless the whole 435 passage is itself subordinate, semantic type 'transitive active' 436 unless context says otherwise), and give EACH of the governed 437 infinitives its normal 'indirect statement' relation into it, 438 exactly as if the verb had been repeated for each one. 439 - tokentype 'implied subject': a participle's own antecedent (the 440 noun/pronoun it agrees with, related via 'circumstantial 441 participle' -- see that relation's own note above) can itself go 442 unexpressed, most often when the participle agrees with a 443 governing verb's own unexpressed subject. Rather than leaving 444 the participle with no antecedent to point at (or, worse, 445 pointing it straight at the verb, which would misrepresent it as 446 an ablative absolute), add ONE implied token (tokentype 'implied 447 subject') to stand in for the missing noun/pronoun. This implied 448 token is NOT a verbal expression itself -- it gets no 449 `verbalunits` entry -- it simply takes a normal 'subject' 450 relation into the governing verb (relatedtoken1 -> the verb's 451 id, relationship1 = 'subject'), exactly as if that subject had 452 been written out as a real word. The participle then relates to 453 THIS new token via 'circumstantial participle', exactly as it 454 would to a real antecedent. Example: in "Recordatusque 455 somniorum ait ad eos: Exploratores estis.", the participle 456 "Recordatus" agrees with the unexpressed subject of "ait" -- add 457 an implied token (tokentype 'implied subject') with relatedtoken1 458 -> "ait"'s id, relationship1 = 'subject'; "Recordatus" then has 459 relatedtoken1 -> that new implied token's id, relationship1 = 460 'circumstantial participle' (and, since "Recordatus" is itself a 461 predicate-sense participle, it ALSO anchors its own 462 `verbalunits` entry, syntactic_type 'dependent', semantic_type 463 matching its own transitivity -- unaffected by its antecedent 464 being implied rather than real). 465 466 Naming an implied token's id (all three tokentypes): append 467 '_implied' to the id of the LAST real token in `tokens` that 468 precedes where the elided word would have stood (or, if the elided 469 word would come before every real token in the sentence, the FIRST 470 real token's id instead). If more than one implied token is ever 471 needed at the same position, append '2', '3', ... after '_implied' 472 to keep them unique (e.g. 't5_implied', 't5_implied2'). Place the 473 new `tokengraph` entry at the list position where the elided word 474 would have appeared, among the tokens of its own clause -- this 475 keeps it grouped with the rest of its verbal expression (or, for 476 'implied subject', the clause of the verb it's the subject of) for 477 anything that reads `tokengraph` in order. 478 """ 479 480 passage: str = dspy.InputField(desc="The Latin passage to analyze, exactly as written.") 481 tokens: List[Token] = dspy.InputField( 482 desc="Pre-segmented tokens of the passage, in order, with fixed ids. Reference these ids in your output; do not create new ones." 483 ) 484 verbalunits: List[VerbalExpression] = dspy.OutputField( 485 desc="One entry per verbal expression (finite verb; infinitive used in indirect speech; or predicate-sense participle) in the passage." 486 ) 487 tokengraph: List[TokenAnalysis] = dspy.OutputField( 488 desc=( 489 "One entry per token in `tokens`, in the same order, with its " 490 "type and any relations -- PLUS one additional entry for each " 491 "implied/elided token you add (see this signature's docstring), " 492 "positioned where that token's clause falls in reading order." 493 ) 494 )
Analyze the syntax of a passage of Latin according to a two-part scheme:
(1) a list of verbal expressions. Three constructions count as a verbal expression: finite verbs, infinitives, and participles.
- A finite verb (including compound perfect/pluperfect passive forms
of participle + a form of 'sum') is always a verbal expression.
Classify its syntactic type as 'independent' (main/principal),
'dependent' (subordinate, introduced by a subordinating word),
'direct quote' (occurring in directly quoted speech framed by
another verb, e.g. "est" in `"Tuum est," inquit, "Servi regnum."`),
or 'aside' (a verbal expression that interrupts the surrounding
syntax, e.g. "dixerim" in "pace dixerim deum" interrupting "eos...
spero").
- An infinitive is a verbal expression only when part of an indirect
statement; its syntactic type is always 'indirect statement'. In a
compound future-infinitive form (participle + a form of 'sum',
e.g. "facturum...fuisse"), the participle/infinitive itself
anchors the verbal expression, same as a compound passive.
- A participle is a verbal expression only when it has a *predicate*
sense (e.g. an ablative-absolute-like "Anco regnante Lucumo...",
'while Ancus was reigning') rather than a purely *attributive*
sense (modifying a noun like an ordinary adjective, e.g.
"consentiens laus", 'universal praise' -- NOT a verbal expression
at all). Use 'dependent' as its syntactic type. When it's
genuinely uncertain whether a given participle is attributive or
predicate/circumstantial, PREFER the circumstantial reading --
treat it as its own verbal expression rather than folding it into
an attributive relation. Example: in "ille moriens, cum sciret
sagittas hydrae Lernaeae felle tinctas quantam uim haberent
ueneni, sanguinem suum exceptum Deianirae dedit", both "moriens"
(agreeing with "ille") and "tinctas" (agreeing with "sagittas")
are treated as circumstantial participles, each anchoring its own
verbal expression, rather than as ordinary attributive
adjectives.
Classify each verbal expression's semantic type too (transitive
active/transitive passive/intransitive/linking verb).
(2) a token-by-token dependency graph. For each token, record up to two relations to other tokens (by id), using only these relation labels:
- unit verb (independent): every INDEPENDENT verb has relatedtoken1
= the special sentinel string 'root' -- never an actual token id;
no real token may be assigned the id 'root' -- and relationship1 =
'unit verb'.
- unit verb (dependent) / subordinating conjunction / relative
pronoun: the verb of a DEPENDENT clause has relatedtoken1 -> the
id of its subordinating conjunction or relative pronoun,
relationship1 = 'unit verb'. That conjunction or pronoun in turn
has relatedtoken1 -> the id of the verb of the clause it is
subordinate to, with relationship1 = 'subordinating conjunction'
for a conjunction, or relatedtoken1 -> its antecedent's id with
relationship1 = 'relative pronoun' for a relative pronoun.
Indirect questions are treated as a kind of dependent clause: the
interrogative word introducing one (e.g. "quanta" in "Theseus
audit quanta calamitate ciuitas afficeretur") is treated the same
way as a subordinating conjunction -- it has relatedtoken1 -> the
id of the verb it introduces (here "audit"), relationship1 =
'subordinating conjunction' (no separate label for this case) --
while the dependent verb itself ("afficeretur") has relatedtoken1
-> the interrogative word's id ("quanta"), relationship1 = 'unit
verb', exactly like any other dependent clause.
- indirect statement (governing verb): an infinitive anchoring an
indirect-statement verbal expression ALSO has relatedtoken1 ->
the id of the verb that governs the indirect statement (the verb
of saying/thinking/perceiving it depends on), relationship1 =
'indirect statement' -- matching its own syntactic type, the same
convention 'direct quote' and 'aside' verbal expressions use
below. There's no separate subordinating-word token to point at
first (a Latin accusative-and-infinitive construction has no
equivalent of English 'that'), so the infinitive points directly
at its governing verb, rather than via a conjunction/pronoun
intermediary the way a dependent finite verb's 'unit verb'
relation does. In a compound future-infinitive form (participle +
a form of 'sum'), this relation belongs on the participle/
infinitive itself, since IT anchors the verbal expression (see
'auxiliary' below) -- the form of 'sum' takes no relation of its
own into the governing verb.
- complementary infinitive: an infinitive that completes the sense
of a governing verb like 'volo', 'incipio', 'audeo', 'licet', or
'decet' (rather than reporting indirect speech) has relatedtoken1
-> the id of that governing verb, relationship1 = 'complementary
infinitive'. Unlike an indirect-statement infinitive, this does
NOT make the infinitive its own verbal expression -- it gets no
`verbalunits` entry of its own; the governing verb is still the
only verbal expression here. Example: in "Amphion...cum templum
Apollinis expugnare vellet...", "expugnare" completes "vellet"
(relatedtoken1 -> "vellet", relationship1 = 'complementary
infinitive'); "templum" is still "expugnare"'s own direct object,
exactly as if "expugnare" were a finite verb.
- infinitive used as a noun: an infinitive can also function as an
ordinary noun -- most often a verb's subject or object -- rather
than anchoring an indirect statement or completing another verb.
Treat it exactly like any other noun in that role: relatedtoken1
-> the verb it's the subject/object of, relationship1 = 'subject'
or 'direct object' as appropriate (no dedicated label, and again
no `verbalunits` entry of its own). Example: in "dolere malum
est", "dolere" has relatedtoken1 -> "est", relationship1 =
'subject'. Like any verbal form, an infinitive used this way can
still take its own object or adverb, related to it the same way
they'd relate to a finite verb.
- gerunds and gerundives: a gerundive is simply an adjective --
treat it exactly like one (relatedtoken1 -> the noun it agrees
with, relationship1 = 'adjectival'; see 'adjectival' below).
Example: in "...ad sacrum faciendum", "faciendum" (the gerundive)
has relatedtoken1 -> "sacrum", relationship1 = 'adjectival'. A
gerund is a noun -- the oblique-case form a verb takes where an
infinitive would be needed in the nominative -- so relate it like
any other noun (most often 'genitive'); it can still take its own
object or adverb, related to it the same way they'd relate to a
finite verb or infinitive. Example: in "ars bene disserendi",
"disserendi" (the gerund) has relatedtoken1 -> "ars",
relationship1 = 'genitive', and "bene" (the adverb modifying it)
has relatedtoken1 -> "disserendi", relationship1 = 'adverbial'.
Neither a gerund nor a gerundive is a verbal expression in its
own right -- no dedicated label, no `verbalunits` entry.
- coordinating conjunction: when a coordinating conjunction (e.g.
'et', '-que') joins a pair of adjectives, nouns, or prepositional
phrases, it has relatedtoken1 -> the id of the first joined
token, relatedtoken2 -> the id of the second, with BOTH
relationship1 and relationship2 = 'coordinating conjunction' (not
an overflow slot here -- this is the one relation that genuinely
uses relatedtoken1 and relatedtoken2 for two ends of the same
relation at once). When it joins two verbal expressions instead,
relatedtoken1/relatedtoken2 are the ids of the two verbs (or, for
an infinitive/participle-anchored verbal expression, the id that
anchors it) rather than of nearby nouns -- go by which verbal
expression the conjunction functionally introduces, NOT by which
token it happens to be adjacent to or (for an enclitic like
'-que') physically attached to; those can differ (e.g. an
enclitic conjunction attached to the second clause's direct
object still relates the two VERBS, not the object). If the
conjunction opens an entirely new sentence with no explicit verb
to its left to pair with, set only relatedtoken1/relationship1 (->
the verb it introduces); do not invent a link to an implied
preceding clause. 'et' specifically can also function as a plain
adverb ('even', 'also') rather than a conjunction -- when it
does, treat it like any other adverb: relatedtoken1 -> the verb
or (if there is none, e.g. a verbless exclamation) the nearest
token it emphasizes, relationship1 = 'adverbial', not
'coordinating conjunction'. IMPORTANT: when the conjunction joins
two independent verbs, BOTH still get their own `verbalunits`
entry and their own relatedtoken1 = 'root'/relationship1 = 'unit
verb' -- this doesn't change just because one of them (usually
the second) also governs further subordinate structure of its
own (a dependent clause, an indirect statement, etc). A verb
that governs an indirect statement or introduces a further
clause is NOT thereby demoted to a mere "framing verb" for what
follows -- it is still, independently, one of the two
coordinated root verbs, and needs its own entry exactly like the
first one. Example: in "...dedit et id philtrum esse dixit.",
dedit and dixit are both independent verbs coordinated by et;
dixit ALSO governs the indirect statement anchored at esse
('id philtrum esse'), but that does not exempt dixit itself from
getting relatedtoken1 = 'root', relationship1 = 'unit verb', and
its own entry in `verbalunits` -- exactly as if it stood alone.
- coordinating conjunction, repeated as a series: a conjunction
like 'et' or 'aut' can also be repeated to coordinate a series
of three or more items (polysyndeton, e.g. 'et...et...et'), not
just used once between a pair. Two shapes occur, and they're
annotated differently:
- "A et B et C" (a conjunction only BETWEEN items, none before
the first): annotate each connector exactly like the simple
pairwise case above -- relatedtoken1 -> the item immediately
BEFORE it, relatedtoken2 -> the item immediately AFTER it,
both relationship1/relationship2 = 'coordinating
conjunction'. The first connector (between A and B) has
relatedtoken1 -> A, relatedtoken2 -> B; the second (between
B and C) has relatedtoken1 -> B, relatedtoken2 -> C; and so
on for a longer series. Every connector always relates
directly to the two real items on either side of it --
NEVER to another connector.
- "et A et B et C" (an introductory connector before the
FIRST item too, one connector per item): the introductory
connector (before A) has ONLY relatedtoken1 -> A,
relationship1 = 'coordinating conjunction' -- there is no
preceding item for it to pair with, so relatedtoken2 is left
unset, same as the sentence-initial one-sided case above.
Every connector AFTER the introductory one is annotated
exactly like the "A et B et C" case: relatedtoken1 -> the
PRECEDING item, relatedtoken2 -> the FOLLOWING item, both
relationship labels 'coordinating conjunction'. So the
second connector (before B) has relatedtoken1 -> A,
relatedtoken2 -> B; the third (before C) has relatedtoken1
-> B, relatedtoken2 -> C.
Either way, every connector in a series relates directly to the
two real coordinated items flanking it (or, for an introductory
connector, the one item following it) -- never to a neighboring
connector. Each connected item ALSO keeps its own ordinary
relation to the rest of the sentence (subject, ablative, direct
object, or whatever fits), completely independent of this
chain -- the coordinating-conjunction relation only links a
connector to its flanking item(s); it never substitutes for
that item's own relation to whatever governs it. Example: in
"Tarquinius et assiduitate et varietate et magnificentia omnes
antecessit" ("et A et B et C" shape -- an et before every one of
the three ablatives, including the first), the first et
(introductory) has ONLY relatedtoken1 -> assiduitate; the
second et has relatedtoken1 -> assiduitate, relatedtoken2 ->
varietate; the third et has relatedtoken1 -> varietate,
relatedtoken2 -> magnificentia. assiduitate, varietate, and
magnificentia each ALSO have their own relatedtoken1 ->
antecessit, relationship1 = 'ablative', same as any other
ablative -- unaffected by which connector introduces them.
- direct quote / aside: a verbal expression of syntactic type
'direct quote' or 'aside' has relatedtoken1 -> the id of the verb
of the clause it interrupts or is framed by, relationship1 =
'direct quote' or 'aside' respectively (matching its syntactic
type).
- circumstantial participle / ablative absolute: a participial
verbal expression's own relatedtoken1 -> the id of the noun or
pronoun it agrees with, relationship1 = 'circumstantial
participle'. That noun in turn: if it also fits a normal role in
the surrounding clause (e.g. it's already the main verb's direct
object), it takes THAT normal relation instead (nothing extra to
add). If it's an ablative with no other syntactic connection to
the sentence (a true ablative absolute), it instead has
relatedtoken1 -> the id of the main verb, relationship1 =
'ablative absolute'. Sometimes there is no noun or pronoun at
all for the participle to agree with -- most often when it
agrees with a governing verb's own unexpressed subject. Do NOT
leave the participle's relatedtoken1 pointing at the verb
directly in that case (that would misrepresent the participle as
relating straight to the verb, the way an ablative-absolute noun
does): instead add a new tokentype='implied subject' token (see
(3) below) standing in for the missing noun, give IT the normal
'subject' relation into the verb, and have the participle relate
to THIS new token via 'circumstantial participle' exactly as it
would to a real one.
- auxiliary: in a compound perfect/pluperfect passive, or compound
future-infinitive, verb form (participle + a form of 'sum'), the
participle or infinitive itself anchors the verbal expression and
is the target of every relation into it (subject, direct object,
agent, etc); the accompanying form of 'sum' instead has
relatedtoken1 -> the id of that participle/infinitive,
relationship1 = 'auxiliary'. The same pattern applies to an
impersonal passive of an intransitive verb (e.g. "ventum erat",
'there had been a coming'): the form of 'sum' still relates to
the participle as its auxiliary, even with no subject.
- agent: the preposition 'a'/'ab' introducing the agent of a passive
verb has relatedtoken1 -> the passive verb's id (the id of the
participle, for a compound form -- NOT the accompanying form of
'sum'), relationship1 = 'agent'. The noun/pronoun governed by that
'a'/'ab' has relatedtoken1 -> the id of 'a'/'ab', relationship1 =
'object of preposition'.
- subject / direct object / predicate: a noun or pronoun serving as
subject or direct object has relatedtoken1 -> the id of the verb
(the id of the participle or infinitive, NOT the accompanying
form of 'sum', for a compound passive or future-infinitive form),
relationship1 = 'subject' or 'direct object'. This applies to the
accusative subject of an infinitive in indirect statement too. A
noun or pronoun serving as the predicate complement of a LINKING
verb uses relationship1 = 'predicate' instead, same relatedtoken1
target. If the token is a relative pronoun already using
relatedtoken1/relationship1 for its antecedent link, put this
relation in relatedtoken2/relationship2 instead.
- adjectival: an adjective (or an attributive participle) modifying
a noun has relatedtoken1 -> the noun's id, relationship1 =
'adjectival'. An adjective used as a substantive (standing in for
a noun) is treated as a noun/pronoun instead, not as adjectival.
- genitive / dative / ablative / accusative: a noun in the
genitive, dative, ablative, or accusative case that depends on a
verb or another noun -- and isn't already covered by a more
specific relation above (subject, direct object, object of
preposition, ablative absolute, etc) -- has relatedtoken1 -> the
id of the verb or noun it depends on, relationship1 = the
matching case name ('genitive', 'dative', 'ablative', or
'accusative'). These are purely syntactic (case-function) labels,
not semantic ones -- don't distinguish e.g. possessive vs.
partitive genitive. 'accusative' specifically covers an
accusative relation that ISN'T a direct object: a bare
accusative of place to which (e.g. "Romam" in "Romam venit",
relatedtoken1 -> "venit", even though "venit" is intransitive)
or an accusative of extent that modifies another NOUN rather
than a verb (e.g. "milia" in "duo milia passuum iter fecerunt",
relatedtoken1 -> "iter", the noun it qualifies, not "fecerunt").
The idiomatic construction 'opus est' + an ablative is a special
case worth noting for 'ablative': the ablative token relates to
"opus" itself, not to "est" -- e.g. in "Collatinus negat verbis
opus esse", "verbis" has relatedtoken1 -> "opus", relationship1 =
'ablative'.
- vocative: a noun in the vocative case (direct address) has
relatedtoken1 -> the id of the verb of the clause it's addressed
within, relationship1 = 'vocative'. Unlike 'genitive'/'dative'/
'ablative'/'accusative' above, a vocative relates to a verb
only, never to another noun. Example: in "Non est ita, domine,
sed servi tui venerunt ut emerent cibos.", "domine" has
relatedtoken1 -> "est", relationship1 = 'vocative'.
- apposition: when one noun stands in apposition to another, the
appositive has relatedtoken1 -> the id of the first (the noun it
restates or further identifies), relationship1 = 'apposition'. A
genitive depending on either noun still gets its own ordinary
'genitive' relation, pointing at whichever noun it actually
depends on -- apposition doesn't change that. Example: in
"Neptunus et Aegeus Pandionis filius...cum Aethra Pitthei
filia...", "filius" is in apposition to "Aegeus" (relatedtoken1
-> "Aegeus", relationship1 = 'apposition'), and "Pandionis" (the
genitive depending on "filius") has relatedtoken1 -> "filius",
relationship1 = 'genitive' -- and likewise "filia" is in
apposition to "Aethra", with "Pitthei" as a genitive depending on
"filia".
- praenomen: a token of tokentype 'praenomen' (an abbreviated Roman
first name, e.g. "M." or "Sex.") has relatedtoken1 -> the id of
the LEXICAL token spelling out the individual's own name that it
abbreviates/precedes, relationship1 = 'praenomen'. Example: in
"Sex. Tarquinius inscio Collatino...venit", "Sex." has
relatedtoken1 -> "Tarquinius", relationship1 = 'praenomen'. If
there is no such lexical name token to relate to -- e.g. the
genitive filiation formula "L. f." ("Lucii filius", 'son of
Lucius'), where "L." precedes only the abbreviation "f." rather
than a lexical name -- leave it unrelated, same as any other
token with no relation of these kinds.
- prepositional phrases: the preposition has relatedtoken1 -> the id
of the verb (adverbial) or noun (attributive) it modifies,
relationship1 = 'adverbial' or 'attributive'. The noun/pronoun it
governs has relatedtoken1 -> the id of the preposition,
relationship1 = 'object of preposition' (or relatedtoken2/
relationship2 if relatedtoken1 is already used for a
relative-pronoun link).
- adverbial (bare adverb): an adverb modifying a verb has
relatedtoken1 -> the verb's id, relationship1 = 'adverbial' --
the same relationship1 value as a preposition modifying a verb,
just with no object-of-preposition token on the other end.
Only assign relations described above. Leave relatedtoken/
relationship fields unset for tokens with no relation of these
kinds -- not every token will have one (e.g. a bare accusative of
place isn't covered). Use only the token ids given in
the input `tokens` list, the sentinel 'root', or a NEW id you
create for an implied token (see below), in your output; never
invent an id for anything else.
(3) implied/elided tokens. arsgrammatica recognizes three DIFFERENT
situations where something exists grammatically but has no surface
realization in the passage at all -- rather than skip these, add a
NEW entry to tokengraph with: a brand-new id, not used by any
entry in tokens or elsewhere in your own output (see the naming
rule below); the matching tokentype below; and no token value
(leave it unset/None) -- these go together, and 'implied sum',
'continued discourse', and 'implied subject' are the ONLY three
tokentype values whose id isn't one of tokens' own ids and whose
token is empty. continued discourse always stands in for a
missing VERBAL expression and so always needs a matching new entry
in verbalunits, exactly like any other verbal expression.
implied sum stands in for a missing verbal expression in TWO of
its three sub-cases (and needs a verbalunits entry there too),
but NOT in its third -- the compound-passive-with-omitted-auxiliary
sub-case, where a real, already-present participle anchors the
verbal expression instead and the implied token merely relates to
it as 'auxiliary' (see below for which is which). implied subject
stands in for a missing NOUN or pronoun instead, and never gets a
verbalunits entry of its own.
- tokentype 'implied sum': an elided present of 'sum' ('to be').
Three sub-cases, all using this same tokentype:
- a bare predicate construction (subject + predicate noun/
adjective, no verb at all): the implied token anchors a
verbal expression classified 'independent' (or 'dependent',
if the elided-'sum' clause is itself subordinate) and
'linking verb'; the subject and predicate each relate to it
exactly as they would to any linking verb ('subject' /
'predicate'). Example: "omnia praeclara rara" ('all splendid
things [are] rare') has an implied token anchoring an
'independent'/'linking verb' expression, with "omnia" (its
own 'praeclara' adjectival) as 'subject' and "rara" as
'predicate'.
- a compound perfect/pluperfect passive (or impersonal
passive) with its auxiliary omitted (e.g. "consules facti"
for "consules facti sunt"): UNLIKE the other two sub-cases,
the implied token here does NOT anchor the verbal
expression -- the participle itself ("facti") is already a
real, present token, so IT anchors the verbal expression
(its own `verbalunits` entry, relatedtoken1/relationship1 =
'root'/'unit verb' if independent, exactly as if it were an
ordinary one-word verb) and every relation that would
normally target the written-out auxiliary (subject, direct
object, predicate, agent, adverbs, etc.) targets the
participle instead. The implied token stands in only for the
omitted form of 'sum' itself: it gets relatedtoken1 -> the
participle's id, relationship1 = 'auxiliary' (exactly as a
written-out auxiliary would relate to the participle -- see
'auxiliary' above), and it does NOT get its own `verbalunits`
entry, since the participle already supplies the verbal
expression.
- the present participle of 'sum' does not exist in Latin at
all, so an ablative-absolute-style predicate construction
built on it (e.g. "Agrippa Menenio P. Postumio consulibus",
'[when] Agrippa Menenius [and] Publius Postumius [were]
consuls') is ALWAYS implied, never optional. Classify the
implied token's verbal expression 'dependent' (this
codebase's circumstantial-participle convention -- see
VerbalExpression's own docstring) and 'linking verb'; relate
it to its noun via 'circumstantial participle' exactly like
any other circumstantial participle.
- tokentype 'continued discourse': continuation of indirect
discourse -- a long run of indirect statements can share one
governing verb of speaking/thinking stated once, then omitted
across several further coordinate statements. Add ONE implied
token (tokentype 'continued discourse') for that omitted
governing verb (syntactic type 'independent' unless the whole
passage is itself subordinate, semantic type 'transitive active'
unless context says otherwise), and give EACH of the governed
infinitives its normal 'indirect statement' relation into it,
exactly as if the verb had been repeated for each one.
- tokentype 'implied subject': a participle's own antecedent (the
noun/pronoun it agrees with, related via 'circumstantial
participle' -- see that relation's own note above) can itself go
unexpressed, most often when the participle agrees with a
governing verb's own unexpressed subject. Rather than leaving
the participle with no antecedent to point at (or, worse,
pointing it straight at the verb, which would misrepresent it as
an ablative absolute), add ONE implied token (tokentype 'implied
subject') to stand in for the missing noun/pronoun. This implied
token is NOT a verbal expression itself -- it gets no
`verbalunits` entry -- it simply takes a normal 'subject'
relation into the governing verb (relatedtoken1 -> the verb's
id, relationship1 = 'subject'), exactly as if that subject had
been written out as a real word. The participle then relates to
THIS new token via 'circumstantial participle', exactly as it
would to a real antecedent. Example: in "Recordatusque
somniorum ait ad eos: Exploratores estis.", the participle
"Recordatus" agrees with the unexpressed subject of "ait" -- add
an implied token (tokentype 'implied subject') with relatedtoken1
-> "ait"'s id, relationship1 = 'subject'; "Recordatus" then has
relatedtoken1 -> that new implied token's id, relationship1 =
'circumstantial participle' (and, since "Recordatus" is itself a
predicate-sense participle, it ALSO anchors its own
`verbalunits` entry, syntactic_type 'dependent', semantic_type
matching its own transitivity -- unaffected by its antecedent
being implied rather than real).
Naming an implied token's id (all three tokentypes): append
'_implied' to the id of the LAST real token in `tokens` that
precedes where the elided word would have stood (or, if the elided
word would come before every real token in the sentence, the FIRST
real token's id instead). If more than one implied token is ever
needed at the same position, append '2', '3', ... after '_implied'
to keep them unique (e.g. 't5_implied', 't5_implied2'). Place the
new `tokengraph` entry at the list position where the elided word
would have appeared, among the tokens of its own clause -- this
keeps it grouped with the rest of its verbal expression (or, for
'implied subject', the clause of the verb it's the subject of) for
anything that reads `tokengraph` in order.
80def analyze_string(passage: str, citation: str = "") -> Tuple[List[Sentence], list]: 81 """Convenience wrapper for the common case of a single string rather 82 than a list of citation-labeled CitedText sources -- kept here so 83 existing callers (syntaxer_main.py, the marimo notebook) have a 84 one-string entry point rather than needing to build a CitedText list 85 themselves for the ordinary case of one passage from one source. 86 87 Wraps `passage` as one CitedText (using `citation` if given, else an 88 empty string -- fine for callers that don't track citations) and runs 89 it through analyze_sources(). Returns (sentences, results) -- the exact 90 same shape analyze_sources() returns, one entry per sentence 91 segmentation finds in `passage`, in order. 92 93 `passage` may contain any number of sentences: each is segmented and 94 analyzed successively, same as if you'd called analyze_sources() with 95 one CitedText yourself. (An earlier version of this function raised 96 ValueError on multi-sentence input and returned a single (tokens, 97 result) pair for exactly one sentence; callers written against that 98 contract need to change to unpack (sentences, results) and iterate.) 99 """ 100 return analyze_sources([CitedText(citation=citation, text=passage)])
Convenience wrapper for the common case of a single string rather than a list of citation-labeled CitedText sources -- kept here so existing callers (syntaxer_main.py, the marimo notebook) have a one-string entry point rather than needing to build a CitedText list themselves for the ordinary case of one passage from one source.
Wraps passage as one CitedText (using citation if given, else an
empty string -- fine for callers that don't track citations) and runs
it through analyze_sources(). Returns (sentences, results) -- the exact
same shape analyze_sources() returns, one entry per sentence
segmentation finds in passage, in order.
passage may contain any number of sentences: each is segmented and
analyzed successively, same as if you'd called analyze_sources() with
one CitedText yourself. (An earlier version of this function raised
ValueError on multi-sentence input and returned a single (tokens,
result) pair for exactly one sentence; callers written against that
contract need to change to unpack (sentences, results) and iterate.)
504def validate(tokens: List[Token], result) -> List[str]: 505 """Check that every id the LM produced actually exists among `tokens` 506 -- OR is a legitimately new implied token (tokentype in 507 IMPLIED_TOKENTYPES -- 'implied sum', 'continued discourse', or 508 'implied subject'; see SentenceAnalysis's docstring) -- and that implied 509 tokens themselves are well-formed. Returns a list of human-readable 510 problem descriptions (empty if clean). 511 512 'root' is a special sentinel value for an independent verb's own 513 relatedtoken1 (see SentenceAnalysis's docstring) -- it is never treated as 514 an unknown id, but syntax_model.md also requires that no actual token 515 ever be assigned the id 'root', so that's checked here too. 516 517 Implied tokens get their own, narrower checks: a tokengraph entry 518 claiming an IMPLIED_TOKENTYPES value must use a genuinely NEW id (not 519 one already in `tokens`) and must leave `token` unset (None) -- getting 520 either wrong is exactly the kind of malformed output this function 521 exists to catch, not a legitimate implied token. A non-implied entry, 522 conversely, must use one of `tokens`' own ids and must NOT have 523 `token=None` -- only an IMPLIED_TOKENTYPES tokentype may omit real 524 surface text. This check is purely structural either way (new id, 525 empty text) -- it does NOT also require an 'implied sum'/'continued 526 discourse' token to have a matching `verbalunits` entry, or an 527 'implied subject' token to lack one; that distinction is documented 528 behavior (see SentenceAnalysis's docstring), not something this function 529 enforces.""" 530 valid_ids = {t.id for t in tokens} 531 problems = [] 532 533 if "root" in valid_ids: 534 problems.append( 535 "token id 'root' is reserved as the sentinel relatedtoken1 " 536 "value for independent verbs and must not be assigned to an " 537 "actual token" 538 ) 539 540 implied_ids = {tok.id for tok in result.tokengraph if tok.tokentype in IMPLIED_TOKENTYPES} 541 known_ids = valid_ids | implied_ids 542 543 for tok in result.tokengraph: 544 if tok.tokentype in IMPLIED_TOKENTYPES: 545 if tok.id in valid_ids: 546 problems.append( 547 f"tokengraph entry {tok.id!r} is tokentype={tok.tokentype!r} but " 548 "reuses an id already in the input `tokens` list -- an " 549 "implied token must use a new id" 550 ) 551 if tok.token is not None: 552 problems.append( 553 f"tokengraph entry {tok.id!r} is tokentype={tok.tokentype!r} but " 554 f"has a non-None token value {tok.token!r} -- an implied " 555 "token's text must be left unset" 556 ) 557 else: 558 if tok.id not in valid_ids: 559 problems.append(f"tokengraph entry has unknown id {tok.id!r}") 560 if tok.token is None: 561 allowed = "/".join(repr(t) for t in sorted(IMPLIED_TOKENTYPES)) 562 problems.append( 563 f"tokengraph entry {tok.id!r} has token=None but " 564 f"tokentype={tok.tokentype!r} -- only {allowed} may " 565 "omit surface text" 566 ) 567 for field in ("relatedtoken1", "relatedtoken2"): 568 val = getattr(tok, field) 569 if val is not None and val != "root" and val not in known_ids: 570 problems.append(f"token {tok.id!r} {field}={val!r} is not a known token id") 571 572 for vu in result.verbalunits: 573 if vu.id not in known_ids: 574 problems.append(f"verbal expression id {vu.id!r} is not a known token id") 575 576 return problems
Check that every id the LM produced actually exists among tokens
-- OR is a legitimately new implied token (tokentype in
IMPLIED_TOKENTYPES -- 'implied sum', 'continued discourse', or
'implied subject'; see SentenceAnalysis's docstring) -- and that implied
tokens themselves are well-formed. Returns a list of human-readable
problem descriptions (empty if clean).
'root' is a special sentinel value for an independent verb's own relatedtoken1 (see SentenceAnalysis's docstring) -- it is never treated as an unknown id, but syntax_model.md also requires that no actual token ever be assigned the id 'root', so that's checked here too.
Implied tokens get their own, narrower checks: a tokengraph entry
claiming an IMPLIED_TOKENTYPES value must use a genuinely NEW id (not
one already in tokens) and must leave token unset (None) -- getting
either wrong is exactly the kind of malformed output this function
exists to catch, not a legitimate implied token. A non-implied entry,
conversely, must use one of tokens' own ids and must NOT have
token=None -- only an IMPLIED_TOKENTYPES tokentype may omit real
surface text. This check is purely structural either way (new id,
empty text) -- it does NOT also require an 'implied sum'/'continued
discourse' token to have a matching verbalunits entry, or an
'implied subject' token to lack one; that distinction is documented
behavior (see SentenceAnalysis's docstring), not something this function
enforces.
579def print_analysis(tokens: List[Token], result): 580 print("Tokens:") 581 for t in tokens: 582 print(f" {t.id:>4} {t.text}") 583 584 print("\nVerbal expressions:") 585 for vu in result.verbalunits: 586 print(f" id={vu.id} syntactic_type={vu.syntactic_type} semantic_type={vu.semantic_type}") 587 588 print("\nToken graph:") 589 for tok in result.tokengraph: 590 rels = [] 591 if tok.relationship1: 592 rels.append(f"{tok.relationship1} -> {tok.relatedtoken1}") 593 if tok.relationship2: 594 rels.append(f"{tok.relationship2} -> {tok.relatedtoken2}") 595 rel_str = "; ".join(rels) if rels else "-" 596 vu_str = f" [verbal unit {tok.verbalunitid}]" if tok.verbalunitid else "" 597 token_str = tok.token if tok.token is not None else f"({tok.tokentype})" 598 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.
36def analyze_sources(sources: List[CitedText]) -> Tuple[List[Sentence], list]: 37 """Segment `sources` into citation-aware sentences, run each sentence's 38 tokens through SentenceAnalysis, and validate each result. 39 40 Returns (sentences, results): results[i] is the SentenceAnalysis result 41 for sentences[i], same order, one entry per sentence. 42 43 Each sentence's SentenceAnalysis call goes through 44 `token_budget.analyze_with_retry()` rather than calling `analyze()` 45 directly, so a sentence whose analysis needs more output than a fixed 46 `max_tokens` would allow (a long or deeply subordinated sentence) gets 47 an estimated, appropriately-sized budget up front, and a retry with a 48 larger one if it still comes back truncated -- see token_budget.py's 49 module docstring for the full design. 50 """ 51 sentences = segment_sources(sources) 52 53 results = [] 54 for sentence in sentences: 55 result = analyze_with_retry(passage=_render_sentence_text(sentence), tokens=sentence.tokens) 56 57 problems = validate(sentence.tokens, result) 58 if problems: 59 first_id = sentence.tokens[0].id if sentence.tokens else "?" 60 print(f"Validation warnings (sentence starting at {first_id}):") 61 for p in problems: 62 print(f" - {p}") 63 64 results.append(result) 65 66 return sentences, results
Segment sources into citation-aware sentences, run each sentence's
tokens through SentenceAnalysis, and validate each result.
Returns (sentences, results): results[i] is the SentenceAnalysis result for sentences[i], same order, one entry per sentence.
Each sentence's SentenceAnalysis call goes through
token_budget.analyze_with_retry() rather than calling analyze()
directly, so a sentence whose analysis needs more output than a fixed
max_tokens would allow (a long or deeply subordinated sentence) gets
an estimated, appropriately-sized budget up front, and a retry with a
larger one if it still comes back truncated -- see token_budget.py's
module docstring for the full design.
103def analyze_selected_passages( 104 passage_ids: List[str], cited_texts: List[CitedText] 105) -> Tuple[List[Sentence], list]: 106 """Select the entries of `cited_texts` whose `citation` is in 107 `passage_ids`, then run exactly those through analyze_sources() -- 108 e.g. after read_ctsdata() has loaded a whole source file but only some 109 of its passages are wanted for this run. 110 111 Selected passages are analyzed in `cited_texts`' OWN order, not 112 `passage_ids`' order -- the same convention 113 marimo/latin_syntaxer_ctsdata.py's own `selected_rows` cell already 114 uses, and for the same reason: segment_sources() (inside 115 analyze_sources()) treats consecutive sources as potentially sharing a 116 sentence, so an out-of-file-order source list could segment 117 incorrectly, or produce citations in a confusing order. 118 `passage_ids` therefore acts purely as a filter -- which passages to 119 include -- never as a sort key. 120 121 Raises ValueError, naming every missing id at once, if any entry of 122 `passage_ids` doesn't match any `cited_texts` citation -- a typo'd or 123 stale passage id fails loudly here rather than silently analyzing 124 fewer passages than asked for. 125 126 Returns (sentences, results) -- the exact same shape analyze_sources() 127 returns, spanning only the selected passages. 128 """ 129 wanted = set(passage_ids) 130 selected = [ct for ct in cited_texts if ct.citation in wanted] 131 132 found = {ct.citation for ct in selected} 133 missing = sorted(pid for pid in wanted if pid not in found) 134 if missing: 135 raise ValueError( 136 f"passage id(s) not found in cited_texts: {missing!r}" 137 ) 138 139 return analyze_sources(selected)
Select the entries of cited_texts whose citation is in
passage_ids, then run exactly those through analyze_sources() --
e.g. after read_ctsdata() has loaded a whole source file but only some
of its passages are wanted for this run.
Selected passages are analyzed in cited_texts' OWN order, not
passage_ids' order -- the same convention
marimo/latin_syntaxer_ctsdata.py's own selected_rows cell already
uses, and for the same reason: segment_sources() (inside
analyze_sources()) treats consecutive sources as potentially sharing a
sentence, so an out-of-file-order source list could segment
incorrectly, or produce citations in a confusing order.
passage_ids therefore acts purely as a filter -- which passages to
include -- never as a sort key.
Raises ValueError, naming every missing id at once, if any entry of
passage_ids doesn't match any cited_texts citation -- a typo'd or
stale passage id fails loudly here rather than silently analyzing
fewer passages than asked for.
Returns (sentences, results) -- the exact same shape analyze_sources() returns, spanning only the selected passages.
142def analyze_ctsdata(path: str, delimiter: str = "|") -> Tuple[List[Sentence], list]: 143 """Convenience wrapper for the common case of a whole `#!ctsdata` (CEX) 144 source file on disk, rather than an in-memory `List[CitedText]` -- 145 reads `path` with `read_ctsdata()` (ctsdata.py) and runs the result 146 straight through `analyze_sources()`, the same "read a CEX corpus, 147 then analyze it" pair every entry point in this codebase that starts 148 from a CEX file already does by hand (`utilities/tokenize_ctsdata.py`, 149 `utilities/analyze_ctsdata_to_files.py`, `utilities/ 150 group_ctsdata_by_sentence.py`, each of the marimo ctsdata notebooks). 151 152 `delimiter` is passed straight through to `read_ctsdata()` -- it's the 153 SOURCE file's own column delimiter ('|' by default, matching every 154 other serialized format in this codebase), not related to anything 155 `analyze_sources()` itself does. 156 157 Returns `(sentences, results)` -- the exact same shape 158 `analyze_sources()` returns, spanning every passage in `path`, in the 159 file's own order. Every passage in the file is analyzed; use 160 `analyze_selected_passages()` instead if only some of them are wanted. 161 162 Propagates `read_ctsdata()`'s own `ValueError`/`OSError` as-is for a 163 missing file or a malformed `#!ctsdata` block (see that function's own 164 docstring for exactly what's checked) -- raised before any LM call is 165 made, same as every CLI entry point that reads a CEX file up front for 166 the same reason. 167 """ 168 cited_texts = read_ctsdata(path, delimiter=delimiter) 169 return analyze_sources(cited_texts)
Convenience wrapper for the common case of a whole #!ctsdata (CEX)
source file on disk, rather than an in-memory List[CitedText] --
reads path with read_ctsdata() (ctsdata.py) and runs the result
straight through analyze_sources(), the same "read a CEX corpus,
then analyze it" pair every entry point in this codebase that starts
from a CEX file already does by hand (utilities/tokenize_ctsdata.py,
utilities/analyze_ctsdata_to_files.py, utilities/
group_ctsdata_by_sentence.py, each of the marimo ctsdata notebooks).
delimiter is passed straight through to read_ctsdata() -- it's the
SOURCE file's own column delimiter ('|' by default, matching every
other serialized format in this codebase), not related to anything
analyze_sources() itself does.
Returns (sentences, results) -- the exact same shape
analyze_sources() returns, spanning every passage in path, in the
file's own order. Every passage in the file is analyzed; use
analyze_selected_passages() instead if only some of them are wanted.
Propagates read_ctsdata()'s own ValueError/OSError as-is for a
missing file or a malformed #!ctsdata block (see that function's own
docstring for exactly what's checked) -- raised before any LM call is
made, same as every CLI entry point that reads a CEX file up front for
the same reason.
69def combined_tokengraph(results) -> list: 70 """Concatenate every sentence result's tokengraph, in order, into one 71 flat list spanning the whole input -- since token ids are global, 72 tokengraph_to_mermaid() (mermaid.py) needs no changes at all to render 73 this as one diagram for a multi-sentence, multi-citation passage.""" 74 combined = [] 75 for result in results: 76 combined.extend(result.tokengraph) 77 return combined
Concatenate every sentence result's tokengraph, in order, into one flat list spanning the whole input -- since token ids are global, tokengraph_to_mermaid() (mermaid.py) needs no changes at all to render this as one diagram for a multi-sentence, multi-citation passage.
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 ([], []).
214class LMInfo(NamedTuple): 215 """One sentence's '#!LM' entry (see the module docstring) -- which 216 model produced that sentence's analysis, a sentence-style identifier 217 for what it was given to analyze ('CONTEXT1.ID1-CONTEXT2.ID2', its 218 first and last token's own citation and id -- see 219 `_sentence_context_identifier()`), and its own reasoning. Any of the 220 three may be None, the same as an empty column elsewhere in this 221 format (e.g. `model` is None whenever serialize_analyses()/ 222 write_analyses() were called without a `model` argument).""" 223 224 model: Optional[str] 225 context: Optional[str] 226 reasoning: Optional[str]
One sentence's '#!LM' entry (see the module docstring) -- which
model produced that sentence's analysis, a sentence-style identifier
for what it was given to analyze ('CONTEXT1.ID1-CONTEXT2.ID2', its
first and last token's own citation and id -- see
_sentence_context_identifier()), and its own reasoning. Any of the
three may be None, the same as an empty column elsewhere in this
format (e.g. model is None whenever serialize_analyses()/
write_analyses() were called without a model argument).
304def serialize_analyses( 305 sentences: List[Sentence], 306 verbalunits: List[VerbalExpression], 307 tokengraph: List[TokenAnalysis], 308 *, 309 model: Optional[str] = None, 310 reasoning: Optional[List[Optional[str]]] = None, 311) -> Tuple[str, List[str]]: 312 """Build the exact text write_analyses() would write to a file, and 313 return it directly as `(content, warnings)` instead of writing it 314 anywhere -- see the module docstring for why this exists alongside 315 write_analyses(). All three positional lists are flat and span however 316 many sentences/citation sources were analyzed -- the same shape 317 analyze_sources() (for `sentences`) and combined_tokengraph() (for 318 `tokengraph`; `verbalunits` needs the analogous concatenation, which 319 this function does not do for you) already produce. 320 321 `model`/`reasoning` are optional and control the '#!LM' block (see the 322 module docstring's "The #!LM block"): omit both (the default) to skip 323 '#!LM' entirely, exactly reproducing this function's pre-'#!LM' 324 output. Passing `reasoning` -- one entry per sentence, in the same 325 order as `sentences`, each either that sentence's own reasoning text 326 or None -- turns it on; `model` is then written as every entry's own 327 MODEL= value (typically `os.environ["MODEL"]`, but this module has no 328 opinion on where it comes from). `reasoning` must have exactly one 329 entry per sentence; a mismatched length raises ValueError immediately, 330 before anything is written. 331 332 `content` is the complete file body, including its trailing newline, 333 exactly as write_analyses() would have written it. `warnings` is a 334 list of warning strings (empty if nothing looks wrong), matching this 335 codebase's "degrade visibly, don't raise" convention for warnings 336 distinct from hard errors: 337 338 - a tokengraph or verbalunits entry whose id isn't found among any 339 given sentence's tokens (so no citation is known for it -- an empty 340 context is written, same as a token that legitimately has no 341 citation at all, but this case specifically means the id wasn't 342 found anywhere in `sentences` -- EXCEPT for an implied token 343 (tokentype in IMPLIED_TOKENTYPES), which never appears in any sentence's own 344 `tokens` by design, so this warning is suppressed for those 345 specifically rather than flagged as an anomaly); 346 - a sentence whose own tokens don't form a contiguous, matching-order 347 run in `tokengraph`'s given order -- see the module docstring for 348 why this matters for read_analyses() to recover sentence boundaries 349 correctly. 350 351 Raises ValueError for a sentence with no tokens at all (nothing to 352 derive first_token/last_token from, or -- when `reasoning` is given -- 353 nothing to derive '#!LM's CONTEXT= from either), if any field value 354 contains '|' or a newline (see `_field`), or if `reasoning` is given 355 with a different number of entries than `sentences`. 356 """ 357 warnings: List[str] = [] 358 359 if reasoning is not None and len(reasoning) != len(sentences): 360 raise ValueError( 361 f"`reasoning` has {len(reasoning)} entr" 362 f"{'y' if len(reasoning) == 1 else 'ies'}, but there " 363 f"{'is' if len(sentences) == 1 else 'are'} {len(sentences)} " 364 "sentence(s) -- '#!LM' needs exactly one reasoning entry per " 365 "sentence" 366 ) 367 368 id_to_citation: Dict[str, Optional[str]] = {} 369 for sentence in sentences: 370 for tok in sentence.tokens: 371 id_to_citation[tok.id] = tok.citation 372 373 # Implied tokens (tokentype in IMPLIED_TOKENTYPES) never appear in any sentence's 374 # own `tokens` list by design (see the module docstring's note above) 375 # -- so having no recorded citation is expected and correct for them, 376 # not the kind of anomaly the "not found among the given sentences' 377 # tokens" warning below exists to flag. 378 implied_ids = {tok.id for tok in tokengraph if tok.tokentype in IMPLIED_TOKENTYPES} 379 380 tg_index = {tok.id: i for i, tok in enumerate(tokengraph)} 381 382 lines: List[str] = [] 383 384 if reasoning is not None: 385 lines.append(LM_LABEL) 386 for s_idx, sentence in enumerate(sentences): 387 if not sentence.tokens: 388 raise ValueError( 389 f"sentence at index {s_idx} has no tokens -- cannot " 390 "derive the '#!LM' block's CONTEXT= for an empty " 391 "sentence" 392 ) 393 where = f"'#!LM' entry for sentence {s_idx}" 394 context_value = _sentence_context_identifier(sentence) 395 reasoning_value = reasoning[s_idx] 396 collapsed_reasoning = ( 397 _collapse_to_single_line(reasoning_value) if reasoning_value is not None else None 398 ) 399 lines.append(_LM_MODEL_PREFIX + _lm_field(model, where=where)) 400 lines.append(_LM_CONTEXT_PREFIX + _lm_field(context_value, where=where)) 401 lines.append(_LM_REASONING_PREFIX + _lm_field(collapsed_reasoning, where=where)) 402 lines.append("") 403 404 lines.append(SENTENCES_LABEL) 405 lines.append(SENTENCES_HEADER) 406 for s_idx, sentence in enumerate(sentences): 407 if not sentence.tokens: 408 raise ValueError( 409 f"sentence at index {s_idx} has no tokens -- cannot derive " 410 "first_token/last_token for an empty sentence" 411 ) 412 first_tok = sentence.tokens[0] 413 last_tok = sentence.tokens[-1] 414 415 first_pos = tg_index.get(first_tok.id) 416 last_pos = tg_index.get(last_tok.id) 417 if first_pos is None or last_pos is None: 418 warnings.append( 419 f"sentence at index {s_idx} (tokens {first_tok.id!r}.." 420 f"{last_tok.id!r}) has a boundary token not present in the " 421 "given tokengraph -- reading this file back may not " 422 "reconstruct this sentence's tokens correctly" 423 ) 424 else: 425 expected_ids = [t.id for t in sentence.tokens] 426 # Implied tokens (tokentype in IMPLIED_TOKENTYPES) were never part of the 427 # original per-sentence `tokens` list -- they're synthesized by 428 # analysis itself -- so exclude them here before comparing, or 429 # every sentence containing one would spuriously warn. 430 actual_ids = [ 431 tok.id 432 for tok in tokengraph[first_pos : last_pos + 1] 433 if tok.tokentype not in IMPLIED_TOKENTYPES 434 ] 435 if actual_ids != expected_ids: 436 warnings.append( 437 f"sentence at index {s_idx} (tokens {first_tok.id!r}.." 438 f"{last_tok.id!r}) is not a contiguous, matching-order " 439 "run in the given tokengraph -- reading this file back " 440 "may not reconstruct this sentence's tokens correctly" 441 ) 442 443 where = f"#!sentences row for sentence {s_idx}" 444 lines.append( 445 "|".join( 446 [ 447 _field(first_tok.citation, where=where), 448 _field(first_tok.id, where=where), 449 _field(last_tok.citation, where=where), 450 _field(last_tok.id, where=where), 451 ] 452 ) 453 ) 454 455 lines.append("") 456 lines.append(VERBAL_UNITS_LABEL) 457 lines.append(VERBAL_UNITS_HEADER) 458 for vu in verbalunits: 459 if vu.id not in id_to_citation and vu.id not in implied_ids: 460 warnings.append( 461 f"verbal expression {vu.id!r} not found among the given " 462 "sentences' tokens -- writing an empty context for it" 463 ) 464 where = f"#!verbal_units row for {vu.id}" 465 lines.append( 466 "|".join( 467 [ 468 _field(id_to_citation.get(vu.id), where=where), 469 _field(vu.id, where=where), 470 _field(vu.syntactic_type, where=where), 471 _field(vu.semantic_type, where=where), 472 ] 473 ) 474 ) 475 476 lines.append("") 477 lines.append(TOKENS_LABEL) 478 lines.append(TOKENS_HEADER) 479 for tok in tokengraph: 480 if tok.id not in id_to_citation and tok.id not in implied_ids: 481 warnings.append( 482 f"token {tok.id!r} not found among the given sentences' " 483 "tokens -- writing an empty context for it" 484 ) 485 where = f"#!tokens row for {tok.id}" 486 lines.append( 487 "|".join( 488 [ 489 _field(id_to_citation.get(tok.id), where=where), 490 _field(tok.id, where=where), 491 _field(tok.tokentype, where=where), 492 _field(tok.token, where=where), 493 _field(tok.lemma, where=where), 494 _field(tok.verbalunitid, where=where), 495 _field(tok.relatedtoken1, where=where), 496 _field(tok.relationship1, where=where), 497 _field(tok.relatedtoken2, where=where), 498 _field(tok.relationship2, where=where), 499 ] 500 ) 501 ) 502 503 return "\n".join(lines) + "\n", warnings
Build the exact text write_analyses() would write to a file, and
return it directly as (content, warnings) instead of writing it
anywhere -- see the module docstring for why this exists alongside
write_analyses(). All three positional lists are flat and span however
many sentences/citation sources were analyzed -- the same shape
analyze_sources() (for sentences) and combined_tokengraph() (for
tokengraph; verbalunits needs the analogous concatenation, which
this function does not do for you) already produce.
model/reasoning are optional and control the '#!LM' block (see the
module docstring's "The #!LM block"): omit both (the default) to skip
'#!LM' entirely, exactly reproducing this function's pre-'#!LM'
output. Passing reasoning -- one entry per sentence, in the same
order as sentences, each either that sentence's own reasoning text
or None -- turns it on; model is then written as every entry's own
MODEL= value (typically os.environ["MODEL"], but this module has no
opinion on where it comes from). reasoning must have exactly one
entry per sentence; a mismatched length raises ValueError immediately,
before anything is written.
content is the complete file body, including its trailing newline,
exactly as write_analyses() would have written it. warnings is a
list of warning strings (empty if nothing looks wrong), matching this
codebase's "degrade visibly, don't raise" convention for warnings
distinct from hard errors:
- a tokengraph or verbalunits entry whose id isn't found among any
given sentence's tokens (so no citation is known for it -- an empty
context is written, same as a token that legitimately has no
citation at all, but this case specifically means the id wasn't
found anywhere in
sentences-- EXCEPT for an implied token (tokentype in IMPLIED_TOKENTYPES), which never appears in any sentence's 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.
506def write_analyses( 507 sentences: List[Sentence], 508 verbalunits: List[VerbalExpression], 509 tokengraph: List[TokenAnalysis], 510 path: str, 511 *, 512 model: Optional[str] = None, 513 reasoning: Optional[List[Optional[str]]] = None, 514) -> List[str]: 515 """Write `sentences`/`verbalunits`/`tokengraph` to `path` in the format 516 this module's docstring describes -- see serialize_analyses() (which 517 this is a thin wrapper around) for what's actually written and for the 518 full list of warnings this can return. `model`/`reasoning` are passed 519 straight through to serialize_analyses() and control the optional 520 '#!LM' block exactly as described there -- omit both to skip it. 521 522 Returns a list of warning strings (empty if nothing looks wrong); see 523 serialize_analyses()'s docstring for what each one means. Raises 524 ValueError for a sentence with no tokens at all (nothing to derive 525 first_token/last_token from), if any field value contains '|' or a 526 newline (see `_field`), or if `reasoning` is given with a different 527 number of entries than `sentences` -- all raised by serialize_analyses() 528 before this function ever opens `path`. 529 """ 530 content, warnings = serialize_analyses( 531 sentences, verbalunits, tokengraph, model=model, reasoning=reasoning 532 ) 533 with open(path, "w", encoding="utf-8") as f: 534 f.write(content) 535 return warnings
Write sentences/verbalunits/tokengraph to path in the format
this module's docstring describes -- see serialize_analyses() (which
this is a thin wrapper around) for what's actually written and for the
full list of warnings this can return. model/reasoning are passed
straight through to serialize_analyses() and control the optional
'#!LM' block exactly as described there -- omit both to skip it.
Returns a list of warning strings (empty if nothing looks wrong); see
serialize_analyses()'s docstring for what each one means. Raises
ValueError for a sentence with no tokens at all (nothing to derive
first_token/last_token from), if any field value contains '|' or a
newline (see _field), or if reasoning is given with a different
number of entries than sentences -- all raised by serialize_analyses()
before this function ever opens path.
538def read_analyses( 539 path: str, 540) -> Tuple[List[TokenAnalysis], List[VerbalExpression], List[Sentence], List[LMInfo]]: 541 """Read `path` (as written by write_analyses()/serialize_analyses()) and 542 reconstruct `(tokengraph, verbalunits, sentences, lm_infos)` -- in that 543 order, matching the order these types are usually discussed in this 544 codebase (the token-level graph, then the verbal-expression table, 545 then the sentence/citation structure that supplies context for both, 546 then the optional per-sentence '#!LM' record of what produced them). 547 548 Each of the three required block labels may appear more than once in 549 `path` (see the module docstring) -- every instance contributes its 550 own rows, in file order, to that label's combined row list, as if the 551 file were the concatenation of however many separate write_analyses()/ 552 serialize_analyses() outputs it actually is. '#!LM' may repeat the 553 same way. 554 555 `lm_infos` is `[]` if `path` has no '#!LM' block at all (every file 556 written before this block existed, or any file written without 557 passing `reasoning` to write_analyses()/serialize_analyses(), reads 558 back exactly as before) -- otherwise it's one `LMInfo` per sentence, 559 aligned with `sentences` by position, same as `tokengraph`/ 560 `verbalunits` are conceptually aligned with `sentences` via token ids. 561 562 Raises ValueError, naming the offending line and problem, for anything 563 that isn't a faithful, internally-consistent file written by 564 write_analyses() -- see this module's own docstring for exactly what's 565 checked, including '#!LM's own narrower checks (a line count that 566 isn't a multiple of 3, a line missing its expected prefix, or a number 567 of entries that doesn't match the number of sentences). This function 568 does not accept a file with warnings-worthy inconsistencies silently 569 patched over; if write_analyses() returned warnings when the file was 570 written, fix the input and re-write it rather than expecting 571 read_analyses() to compensate. 572 """ 573 with open(path, "r", encoding="utf-8") as f: 574 raw_lines = f.read().splitlines() 575 576 # blocks[label] accumulates (line_no, line) data rows across every 577 # instance of that label found in the file, in file order. A label 578 # line always starts a new instance and must be immediately followed 579 # by that label's header line (`awaiting_header` tracks this) before 580 # any more data rows can be appended to it -- this holds per instance, 581 # not just for the label's first appearance, so every repeated block 582 # must repeat its own header line too. '#!LM' is the one exception: it 583 # has no header line at all (see the module docstring), so a '#!LM' 584 # label line goes straight to accepting data rows -- `awaiting_header` 585 # is never set for it. 586 blocks: Dict[str, List[Tuple[int, str]]] = {label: [] for label in _EXPECTED_HEADERS} 587 blocks[LM_LABEL] = [] 588 seen_labels = set() 589 current_label: Optional[str] = None 590 awaiting_header = False 591 592 for line_no, line in enumerate(raw_lines, start=1): 593 if line.strip() == "": 594 continue 595 596 if line == LM_LABEL or line in _EXPECTED_HEADERS: 597 if awaiting_header: 598 raise ValueError( 599 f"line {line_no}: block {current_label!r} has a label " 600 "line but no header line before the next block starts" 601 ) 602 current_label = line 603 seen_labels.add(line) 604 awaiting_header = line != LM_LABEL 605 continue 606 607 if current_label is None: 608 raise ValueError( 609 f"line {line_no}: data line {line!r} appears before any " 610 "'#!' block label" 611 ) 612 613 if awaiting_header: 614 expected = _EXPECTED_HEADERS[current_label] 615 if line != expected: 616 raise ValueError( 617 f"line {line_no}: expected header {expected!r} for " 618 f"block {current_label!r}, got {line!r}" 619 ) 620 awaiting_header = False 621 continue 622 623 blocks[current_label].append((line_no, line)) 624 625 missing = sorted(set(_EXPECTED_HEADERS) - seen_labels) 626 if missing: 627 raise ValueError(f"file is missing required block(s): {missing}") 628 if awaiting_header: 629 raise ValueError( 630 f"block {current_label!r} has a label line but no header line " 631 "(and no data) -- the file ends too early" 632 ) 633 634 # --- #!tokens: build the TokenAnalysis list, the id->citation map, 635 # and the row-order index sentence reconstruction relies on. --- 636 tokengraph: List[TokenAnalysis] = [] 637 id_to_citation: Dict[str, Optional[str]] = {} 638 row_order: List[str] = [] 639 640 for line_no, line in blocks[TOKENS_LABEL]: 641 parts = line.split("|") 642 if len(parts) != 10: 643 raise ValueError( 644 f"line {line_no}: #!tokens row has {len(parts)} columns, " 645 f"expected 10: {line!r}" 646 ) 647 ( 648 context, 649 tok_id, 650 tokentype, 651 text, 652 lemma, 653 verbalunit, 654 related1, 655 relationship1, 656 related2, 657 relationship2, 658 ) = parts 659 if tok_id == "": 660 raise ValueError(f"line {line_no}: #!tokens row has an empty id") 661 if tok_id in id_to_citation: 662 raise ValueError(f"line {line_no}: duplicate token id {tok_id!r} in #!tokens") 663 664 tokengraph.append( 665 TokenAnalysis( 666 id=tok_id, 667 token=_parse_optional(text), 668 tokentype=tokentype, 669 lemma=_parse_optional(lemma), 670 verbalunitid=_parse_optional(verbalunit), 671 relatedtoken1=_parse_optional(related1), 672 relationship1=_parse_optional(relationship1), 673 relatedtoken2=_parse_optional(related2), 674 relationship2=_parse_optional(relationship2), 675 ) 676 ) 677 id_to_citation[tok_id] = _parse_optional(context) 678 row_order.append(tok_id) 679 680 id_position = {tid: i for i, tid in enumerate(row_order)} 681 682 # --- #!verbal_units --- 683 verbalunits: List[VerbalExpression] = [] 684 for line_no, line in blocks[VERBAL_UNITS_LABEL]: 685 parts = line.split("|") 686 if len(parts) != 4: 687 raise ValueError( 688 f"line {line_no}: #!verbal_units row has {len(parts)} " 689 f"columns, expected 4: {line!r}" 690 ) 691 context, vu_id, syntactic_type, semantic_type = parts 692 if vu_id == "": 693 raise ValueError(f"line {line_no}: #!verbal_units row has an empty token id") 694 if vu_id not in id_to_citation: 695 raise ValueError( 696 f"line {line_no}: #!verbal_units references token id " 697 f"{vu_id!r}, which does not appear in the #!tokens block" 698 ) 699 recorded_context = _parse_optional(context) 700 expected_context = id_to_citation[vu_id] 701 if recorded_context != expected_context: 702 raise ValueError( 703 f"line {line_no}: #!verbal_units row's context " 704 f"{recorded_context!r} for token {vu_id!r} does not match " 705 f"the #!tokens block's recorded context {expected_context!r} " 706 "for the same id" 707 ) 708 709 verbalunits.append( 710 VerbalExpression( 711 id=vu_id, 712 syntactic_type=syntactic_type, 713 semantic_type=semantic_type, 714 ) 715 ) 716 717 # --- #!sentences --- 718 sentences: List[Sentence] = [] 719 for line_no, line in blocks[SENTENCES_LABEL]: 720 parts = line.split("|") 721 if len(parts) != 4: 722 raise ValueError( 723 f"line {line_no}: #!sentences row has {len(parts)} " 724 f"columns, expected 4: {line!r}" 725 ) 726 context_begin, first_id, context_end, last_id = parts 727 if first_id == "" or last_id == "": 728 raise ValueError( 729 f"line {line_no}: #!sentences row is missing first_token " 730 f"or last_token: {line!r}" 731 ) 732 if first_id not in id_position or last_id not in id_position: 733 raise ValueError( 734 f"line {line_no}: #!sentences references a first_token/" 735 "last_token id not found in the #!tokens block" 736 ) 737 738 start = id_position[first_id] 739 end = id_position[last_id] 740 if start > end: 741 raise ValueError( 742 f"line {line_no}: #!sentences row's first_token " 743 f"{first_id!r} comes after last_token {last_id!r} in the " 744 "#!tokens block's row order" 745 ) 746 747 parsed_begin = _parse_optional(context_begin) 748 parsed_end = _parse_optional(context_end) 749 if parsed_begin != id_to_citation[first_id]: 750 raise ValueError( 751 f"line {line_no}: #!sentences row's context_begin " 752 f"{parsed_begin!r} does not match the #!tokens block's " 753 f"recorded context {id_to_citation[first_id]!r} for token " 754 f"{first_id!r}" 755 ) 756 if parsed_end != id_to_citation[last_id]: 757 raise ValueError( 758 f"line {line_no}: #!sentences row's context_end " 759 f"{parsed_end!r} does not match the #!tokens block's " 760 f"recorded context {id_to_citation[last_id]!r} for token " 761 f"{last_id!r}" 762 ) 763 764 sentence_ids = [ 765 tid 766 for tid in row_order[start : end + 1] 767 if tokengraph[id_position[tid]].tokentype not in IMPLIED_TOKENTYPES 768 ] 769 sentences.append( 770 Sentence( 771 tokens=[ 772 Token( 773 id=tid, 774 text=tokengraph[id_position[tid]].token, 775 citation=id_to_citation[tid], 776 ) 777 for tid in sentence_ids 778 ] 779 ) 780 ) 781 782 # --- #!LM (optional) --- 783 lm_raw = blocks[LM_LABEL] 784 lm_infos: List[LMInfo] = [] 785 if lm_raw: 786 if len(lm_raw) % 3 != 0: 787 first_line_no = lm_raw[0][0] 788 raise ValueError( 789 f"line {first_line_no}: '#!LM' block has {len(lm_raw)} " 790 "line(s), which is not a multiple of 3 -- each entry needs " 791 "exactly a MODEL=, CONTEXT=, and REASONING= line, in that " 792 "order" 793 ) 794 for i in range(0, len(lm_raw), 3): 795 model_line_no, model_line = lm_raw[i] 796 context_line_no, context_line = lm_raw[i + 1] 797 reasoning_line_no, reasoning_line = lm_raw[i + 2] 798 if not model_line.startswith(_LM_MODEL_PREFIX): 799 raise ValueError( 800 f"line {model_line_no}: expected a line starting with " 801 f"{_LM_MODEL_PREFIX!r} in the '#!LM' block, got " 802 f"{model_line!r}" 803 ) 804 if not context_line.startswith(_LM_CONTEXT_PREFIX): 805 raise ValueError( 806 f"line {context_line_no}: expected a line starting " 807 f"with {_LM_CONTEXT_PREFIX!r} in the '#!LM' block, got " 808 f"{context_line!r}" 809 ) 810 if not reasoning_line.startswith(_LM_REASONING_PREFIX): 811 raise ValueError( 812 f"line {reasoning_line_no}: expected a line starting " 813 f"with {_LM_REASONING_PREFIX!r} in the '#!LM' block, " 814 f"got {reasoning_line!r}" 815 ) 816 lm_infos.append( 817 LMInfo( 818 model=_parse_optional(model_line[len(_LM_MODEL_PREFIX):]), 819 context=_parse_optional(context_line[len(_LM_CONTEXT_PREFIX):]), 820 reasoning=_parse_optional( 821 reasoning_line[len(_LM_REASONING_PREFIX):] 822 ), 823 ) 824 ) 825 826 if len(lm_infos) != len(sentences): 827 raise ValueError( 828 f"'#!LM' block has {len(lm_infos)} " 829 f"entr{'y' if len(lm_infos) == 1 else 'ies'}, but " 830 f"#!sentences reconstructed {len(sentences)} sentence(s) -- " 831 "'#!LM' entries are recorded one per sentence, so these " 832 "must match" 833 ) 834 835 return tokengraph, verbalunits, sentences, lm_infos
Read path (as written by write_analyses()/serialize_analyses()) and
reconstruct (tokengraph, verbalunits, sentences, lm_infos) -- in that
order, matching the order these types are usually discussed in this
codebase (the token-level graph, then the verbal-expression table,
then the sentence/citation structure that supplies context for both,
then the optional per-sentence '#!LM' record of what produced them).
Each of the three required block labels may appear more than once in
path (see the module docstring) -- every instance contributes its
own rows, in file order, to that label's combined row list, as if the
file were the concatenation of however many separate write_analyses()/
serialize_analyses() outputs it actually is. '#!LM' may repeat the
same way.
lm_infos is [] if path has no '#!LM' block at all (every file
written before this block existed, or any file written without
passing reasoning to write_analyses()/serialize_analyses(), reads
back exactly as before) -- otherwise it's one LMInfo per sentence,
aligned with sentences by position, same as tokengraph/
verbalunits are conceptually aligned with sentences via token ids.
Raises ValueError, naming the offending line and problem, for anything that isn't a faithful, internally-consistent file written by write_analyses() -- see this module's own docstring for exactly what's checked, including '#!LM's own narrower checks (a line count that isn't a multiple of 3, a line missing its expected prefix, or a number of entries that doesn't match the number of sentences). This function does not accept a file with warnings-worthy inconsistencies silently patched over; if write_analyses() returned warnings when the file was written, fix the input and re-write it rather than expecting read_analyses() to compensate.
838def split_analysis_by_sentence( 839 tokengraph: List[TokenAnalysis], 840 verbalunits: List[VerbalExpression], 841 sentences: List[Sentence], 842) -> List[Tuple[List[TokenAnalysis], List[VerbalExpression]]]: 843 """The inverse of what write_analyses()/serialize_analyses() flatten 844 together: given the same `(tokengraph, verbalunits, sentences)` triple 845 read_analyses() returns (or that analyze_sources()/combined_tokengraph() 846 produce before ever being written to a file), split `tokengraph` and 847 `verbalunits` back into one slice per sentence. 848 849 Returns a list the same length and order as `sentences` -- entry i is 850 `(sentence_tokengraph, sentence_verbalunits)` for `sentences[i]`. Useful 851 for anything that wants to review or render one sentence's analysis at 852 a time (e.g. a sentence-picker UI, like marimo/latin_syntaxer_review.py) 853 without re-running analysis or re-deriving the same id-position 854 bookkeeping read_analyses()/write_analyses() already do internally. 855 856 Relies on the same invariant read_analyses() and write_analyses() 857 already depend on: a sentence's own tokens form a contiguous, 858 matching-order run in `tokengraph` (see this module's own docstring). 859 `sentence_tokengraph` is the slice of `tokengraph` between that 860 sentence's first and last token's positions, inclusive -- which also 861 picks up any implied/elided tokens (tokentype in IMPLIED_TOKENTYPES) 862 interspersed within that range, since those were never part of 863 `sentence.tokens` to begin with but do belong to that sentence's own 864 analysis. `sentence_verbalunits` is every VerbalExpression whose id 865 falls within that same slice. 866 867 One consequence of using [first, last] *real* token positions as the 868 slice boundary, shared with read_analyses()'s own sentence 869 reconstruction: an implied token placed AFTER a sentence's last real 870 token (rather than nested between two real tokens) falls just outside 871 that slice, since there's no further real token of the same sentence 872 to bound it from above -- e.g. a one-real-token sentence like "Rara 873 [sunt]." (see tests/test_serialization.py's 874 test_split_excludes_a_trailing_implied_token_past_the_sentences_last_real_token). 875 An implied token nested between two real tokens of the same sentence 876 is included as expected; only this specific trailing case isn't. 877 878 Raises ValueError for a sentence with no tokens at all, or whose first 879 or last token id isn't present in `tokengraph` -- both should be 880 impossible for a triple that actually came from read_analyses(), which 881 already guarantees this by construction, but this function checks 882 explicitly anyway rather than trusting the caller, since nothing stops 883 it being called with a hand-built triple too. 884 """ 885 id_position: Dict[str, int] = {tok.id: i for i, tok in enumerate(tokengraph)} 886 887 result: List[Tuple[List[TokenAnalysis], List[VerbalExpression]]] = [] 888 for s_idx, sentence in enumerate(sentences): 889 if not sentence.tokens: 890 raise ValueError(f"sentence at index {s_idx} has no tokens") 891 892 first_id = sentence.tokens[0].id 893 last_id = sentence.tokens[-1].id 894 if first_id not in id_position or last_id not in id_position: 895 raise ValueError( 896 f"sentence at index {s_idx} (tokens {first_id!r}.." 897 f"{last_id!r}) has a boundary token not present in the " 898 "given tokengraph" 899 ) 900 901 start = id_position[first_id] 902 end = id_position[last_id] 903 sentence_tokengraph = tokengraph[start : end + 1] 904 sentence_ids = {tok.id for tok in sentence_tokengraph} 905 sentence_verbalunits = [vu for vu in verbalunits if vu.id in sentence_ids] 906 result.append((sentence_tokengraph, sentence_verbalunits)) 907 908 return result
The inverse of what write_analyses()/serialize_analyses() flatten
together: given the same (tokengraph, verbalunits, sentences) triple
read_analyses() returns (or that analyze_sources()/combined_tokengraph()
produce before ever being written to a file), split tokengraph and
verbalunits back into one slice per sentence.
Returns a list the same length and order as sentences -- entry i is
(sentence_tokengraph, sentence_verbalunits) for sentences[i]. Useful
for anything that wants to review or render one sentence's analysis at
a time (e.g. a sentence-picker UI, like marimo/latin_syntaxer_review.py)
without re-running analysis or re-deriving the same id-position
bookkeeping read_analyses()/write_analyses() already do internally.
Relies on the same invariant read_analyses() and write_analyses()
already depend on: a sentence's own tokens form a contiguous,
matching-order run in tokengraph (see this module's own docstring).
sentence_tokengraph is the slice of tokengraph between that
sentence's first and last token's positions, inclusive -- which also
picks up any implied/elided tokens (tokentype in IMPLIED_TOKENTYPES)
interspersed within that range, since those were never part of
sentence.tokens to begin with but do belong to that sentence's own
analysis. sentence_verbalunits is every VerbalExpression whose id
falls within that same slice.
One consequence of using [first, last] real token positions as the slice boundary, shared with read_analyses()'s own sentence reconstruction: an implied token placed AFTER a sentence's last real token (rather than nested between two real tokens) falls just outside that slice, since there's no further real token of the same sentence to bound it from above -- e.g. a one-real-token sentence like "Rara [sunt]." (see tests/test_serialization.py's test_split_excludes_a_trailing_implied_token_past_the_sentences_last_real_token). An implied token nested between two real tokens of the same sentence is included as expected; only this specific trailing case isn't.
Raises ValueError for a sentence with no tokens at all, or whose first
or last token id isn't present in tokengraph -- both should be
impossible for a triple that actually came from read_analyses(), which
already guarantees this by construction, but this function checks
explicitly anyway rather than trusting the caller, since nothing stops
it being called with a hand-built triple too.
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.
134def estimate_max_tokens( 135 num_tokens: int, 136 *, 137 safety_margin: float = DEFAULT_SAFETY_MARGIN, 138 floor: int = DEFAULT_FLOOR, 139 ceiling: int = DEFAULT_CEILING, 140) -> int: 141 """Estimate a `max_tokens` budget for a SentenceAnalysis call over a 142 sentence with `num_tokens` input tokens. 143 144 `raw = intercept + slope * num_tokens` comes from the calibrated (or 145 fallback) linear fit (see _load_calibration()); `safety_margin` 146 multiplies that to leave room for the reasoning field's length being 147 only roughly, not exactly, a function of passage length. The result is 148 clamped to `[floor, ceiling]` -- `floor` guards against a degenerate 149 tiny estimate for a 1-2 token sentence, `ceiling` is a hard cap you 150 should set to your actual model's real max-output-tokens limit (see 151 DEFAULT_CEILING's docstring note). 152 153 Raises ValueError if `num_tokens` is negative. 154 """ 155 if num_tokens < 0: 156 raise ValueError(f"num_tokens must be >= 0, got {num_tokens}") 157 158 calibration = _load_calibration() 159 raw = calibration["intercept"] + calibration["slope"] * num_tokens 160 budget = math.ceil(raw * safety_margin) 161 return max(floor, min(ceiling, budget))
Estimate a max_tokens budget for a SentenceAnalysis call over a
sentence with num_tokens input tokens.
raw = intercept + slope * num_tokens comes from the calibrated (or
fallback) linear fit (see _load_calibration()); safety_margin
multiplies that to leave room for the reasoning field's length being
only roughly, not exactly, a function of passage length. The result is
clamped to [floor, ceiling] -- floor guards against a degenerate
tiny estimate for a 1-2 token sentence, ceiling is a hard cap you
should set to your actual model's real max-output-tokens limit (see
DEFAULT_CEILING's docstring note).
Raises ValueError if num_tokens is negative.
210def analyze_with_retry( 211 passage: str, 212 tokens: List[Token], 213 *, 214 max_retries: int = 1, 215 growth_factor: float = 2.0, 216 safety_margin: float = DEFAULT_SAFETY_MARGIN, 217 floor: int = DEFAULT_FLOOR, 218 ceiling: int = DEFAULT_CEILING, 219 initial_max_tokens: Optional[int] = None, 220): 221 """Call `analyze()`, detecting truncation and retrying with a larger 222 `max_tokens` budget instead of either crashing or silently returning an 223 incomplete result. 224 225 The starting budget is `initial_max_tokens` if given, else 226 `estimate_max_tokens(len(tokens), safety_margin=safety_margin, 227 floor=floor, ceiling=ceiling)`. 228 229 After each attempt, truncation is checked two ways: `_missing_token_ids` 230 against the result (the primary, LM-independent signal -- works 231 whenever a result exists at all, parsed or not, including under 232 DummyLM in tests) and, if the call raised `AdapterParseError` instead 233 of returning a result (the JSON was cut off badly enough to not parse 234 at all), `_finish_reason_was_length()` as a corroborating check. 235 236 If truncation is detected and there's still a retry available (fewer 237 than `max_retries` attempts so far, and the budget hasn't already hit 238 `ceiling`), the budget is multiplied by `growth_factor` (capped at 239 `ceiling`) and the call is retried. `max_tokens` is part of DSPy's own 240 LM cache key, so a retry with a different budget always reaches the LM 241 again rather than replaying a cached truncated response. 242 243 An `AdapterParseError` whose `finish_reason` ISN'T "length" means the 244 response was well-terminated but still malformed somewhere -- e.g. one 245 `tokengraph` entry coming back as a bare `["id"]` list instead of a 246 full TokenAnalysis object. A bigger budget wouldn't have fixed that, 247 but the malformation itself is very often a one-off sampling glitch 248 rather than a systematic prompt/schema problem, so it's retried once 249 too (still counted against `max_retries`, at the SAME budget) with 250 dspy's own response cache explicitly bypassed for that one attempt 251 (`config={"cache": False, ...}`) -- without that, an identical request 252 would just replay the identical broken response, retrying nothing. If 253 that retry also fails to parse, or `max_retries` is already exhausted, 254 the exception propagates. 255 256 Once retries are exhausted: if the last attempt raised, that exception 257 propagates (there's no result to fall back to). If the last attempt 258 returned a still-incomplete result, it's returned anyway -- with a 259 `UserWarning` naming the missing token ids -- rather than raising, 260 matching this codebase's existing convention of surfacing analysis 261 problems as warnings (see pipeline.py's own validate() warning-printing 262 and this module's docstring) instead of treating an imperfect LM 263 result as fatal. 264 """ 265 budget = initial_max_tokens if initial_max_tokens is not None else estimate_max_tokens( 266 len(tokens), safety_margin=safety_margin, floor=floor, ceiling=ceiling 267 ) 268 269 attempt = 0 270 bypass_cache = False 271 while True: 272 old_budget = budget 273 call_config = {"max_tokens": budget} 274 if bypass_cache: 275 call_config["cache"] = False 276 bypass_cache = False # only meant for the one attempt it was set for 277 try: 278 result = analyze(passage=passage, tokens=tokens, config=call_config) 279 except AdapterParseError as exc: 280 if attempt >= max_retries: 281 raise 282 if budget < ceiling and _finish_reason_was_length(): 283 attempt += 1 284 budget = min(ceiling, math.ceil(budget * growth_factor)) 285 warnings.warn( 286 f"SentenceAnalysis call truncated at max_tokens={old_budget} before it " 287 f"could be parsed at all; retrying with max_tokens={budget} " 288 f"(attempt {attempt}/{max_retries}).", 289 stacklevel=2, 290 ) 291 continue 292 # Not a (detectable) truncation -- the response finished 293 # normally but was malformed somewhere (see this function's own 294 # docstring). Retry once more at the SAME budget, but with 295 # dspy's cache explicitly bypassed for that one attempt, so a 296 # retry is a genuinely fresh LM call rather than a replay of 297 # the same broken response -- otherwise, if this exact request 298 # was already served from cache (e.g. a repeat of an earlier, 299 # already-broken run), simply calling analyze() again would 300 # just return the identical malformed result again and again, 301 # even with dspy's cache enabled as normal for every other call. 302 attempt += 1 303 bypass_cache = True 304 warnings.warn( 305 f"SentenceAnalysis call at max_tokens={old_budget} returned output that " 306 f"failed to parse, but doesn't look like a truncation (finish_reason " 307 f"wasn't 'length'): {exc} Retrying once at the same budget with the LM " 308 f"cache bypassed, in case this was a one-off malformed-output glitch " 309 f"(attempt {attempt}/{max_retries}).", 310 stacklevel=2, 311 ) 312 continue 313 314 missing = _missing_token_ids(tokens, result) 315 truncated = bool(missing) or _finish_reason_was_length() 316 if truncated and attempt < max_retries and budget < ceiling: 317 attempt += 1 318 budget = min(ceiling, math.ceil(budget * growth_factor)) 319 warnings.warn( 320 f"SentenceAnalysis call at max_tokens={old_budget} returned a tokengraph " 321 f"missing {len(missing)} input token id(s) ({sorted(missing)}); retrying " 322 f"with a larger max_tokens={budget} (attempt {attempt}/{max_retries}).", 323 stacklevel=2, 324 ) 325 continue 326 327 if truncated: 328 missing_desc = sorted(missing) if missing else "(finish_reason indicated truncation, but no ids are directly missing)" 329 warnings.warn( 330 f"SentenceAnalysis call still looks truncated after {attempt} retry(ies) " 331 f"(max_tokens={old_budget}) -- returning it anyway. Missing input token " 332 f"id(s): {missing_desc}.", 333 stacklevel=2, 334 ) 335 336 return result
Call analyze(), detecting truncation and retrying with a larger
max_tokens budget instead of either crashing or silently returning an
incomplete result.
The starting budget is initial_max_tokens if given, else
estimate_max_tokens(len(tokens), safety_margin=safety_margin,
floor=floor, ceiling=ceiling).
After each attempt, truncation is checked two ways: _missing_token_ids
against the result (the primary, LM-independent signal -- works
whenever a result exists at all, parsed or not, including under
DummyLM in tests) and, if the call raised AdapterParseError instead
of returning a result (the JSON was cut off badly enough to not parse
at all), _finish_reason_was_length() as a corroborating check.
If truncation is detected and there's still a retry available (fewer
than max_retries attempts so far, and the budget hasn't already hit
ceiling), the budget is multiplied by growth_factor (capped at
ceiling) and the call is retried. max_tokens is part of DSPy's own
LM cache key, so a retry with a different budget always reaches the LM
again rather than replaying a cached truncated response.
An AdapterParseError whose finish_reason ISN'T "length" means the
response was well-terminated but still malformed somewhere -- e.g. one
tokengraph entry coming back as a bare ["id"] list instead of a
full TokenAnalysis object. A bigger budget wouldn't have fixed that,
but the malformation itself is very often a one-off sampling glitch
rather than a systematic prompt/schema problem, so it's retried once
too (still counted against max_retries, at the SAME budget) with
dspy's own response cache explicitly bypassed for that one attempt
(config={"cache": False, ...}) -- without that, an identical request
would just replay the identical broken response, retrying nothing. If
that retry also fails to parse, or max_retries is already exhausted,
the exception propagates.
Once retries are exhausted: if the last attempt raised, that exception
propagates (there's no result to fall back to). If the last attempt
returned a still-incomplete result, it's returned anyway -- with a
UserWarning naming the missing token ids -- rather than raising,
matching this codebase's existing convention of surfacing analysis
problems as warnings (see pipeline.py's own validate() warning-printing
and this module's docstring) instead of treating an imperfect LM
result as fatal.
126def get_calibration() -> dict: 127 """Public introspection: what (intercept, slope) is estimate_max_tokens() 128 currently using, and did it come from calibrate_max_tokens.py's fit or 129 from this module's untuned fallback? See _load_calibration()'s 130 docstring for the shape returned.""" 131 return _load_calibration()
Public introspection: what (intercept, slope) is estimate_max_tokens() currently using, and did it come from calibrate_max_tokens.py's fit or from this module's untuned fallback? See _load_calibration()'s docstring for the shape returned.
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
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 attgraph(sentences: List[Sentence], results: list) -> Tuple[AATGraph, List[str]]: 180 """Build an `aat.core.AATGraph` from an already-completed 181 arsgrammatica analysis -- `sentences`/`results`, in the exact shape 182 `pipeline.analyze_sources()` (or `analyze_string()`) returns them: 183 `results[i]` is the SentenceAnalysis result (with its own `.tokengraph` 184 and `.verbalunits`) for `sentences[i]`, same order, one entry per 185 sentence. 186 187 Returns `(graph, warnings)` -- `warnings` follows this codebase's 188 usual "degrade visibly, don't raise" convention (see 189 `verbal_units.compute_subordination_depths()`): a sentence whose 190 tokens span more than one citation, or a verbal expression missing 191 its own `verbalunits` entry (shouldn't happen in well-formed output, 192 but this function doesn't assume it), is reported here rather than 193 raising. An empty list means nothing unusual was found -- not that 194 the underlying Latin analysis itself is correct; run the resulting 195 graph through `aat.core.validate.validate()` (with a matching 196 `CitableToken` list) for that. 197 198 Every action, agent, and target node this function can derive is 199 documented in this module's own docstring, above. 200 """ 201 warnings: List[str] = [] 202 nodes: List[AATNode] = [] 203 204 for index, (sentence, result) in enumerate(zip(sentences, results)): 205 tokengraph = result.tokengraph 206 verbalunits = result.verbalunits 207 208 context = _sentence_context(sentence, warnings, index) 209 by_id = {tok.id: tok for tok in tokengraph} 210 order_index = {tok.id: i for i, tok in enumerate(tokengraph)} 211 anchor_ids = sorted( 212 (tok.id for tok in tokengraph if tok.verbalunitid == tok.id), 213 key=lambda tid: order_index.get(tid, -1), 214 ) 215 vu_by_id = {vu.id: vu for vu in verbalunits} 216 governing = find_governing_verbal_expression(tokengraph) 217 218 for anchor_id in anchor_ids: 219 vu = vu_by_id.get(anchor_id) 220 if vu is None: 221 warnings.append( 222 f"sentence {index + 1}: anchor {anchor_id!r} has no " 223 "matching entry in verbalunits -- skipping its action " 224 "(and any agent/target) node" 225 ) 226 continue 227 228 component_ids = sorted( 229 set(_component_ids(anchor_id, tokengraph)), 230 key=lambda tid: order_index.get(tid, -1), 231 ) 232 value = " ".join(token_label(by_id[tid]) for tid in component_ids) 233 234 nodes.append( 235 AATNode( 236 context=context, 237 id=anchor_id, 238 value=value, 239 role="action", 240 related_node=governing.get(anchor_id), 241 ) 242 ) 243 244 semantic_type = vu.semantic_type 245 seen_role_token_ids: set = set() 246 for tok in tokengraph: 247 for related_field, label_field in ( 248 ("relatedtoken1", "relationship1"), 249 ("relatedtoken2", "relationship2"), 250 ): 251 label = getattr(tok, label_field) 252 target_id = getattr(tok, related_field) 253 if target_id != anchor_id: 254 continue 255 role = _role_for_relation(label, semantic_type) 256 if role is None: 257 continue 258 259 if label == "agent": 260 role_tok = _object_of_preposition(tok.id, tokengraph) 261 if role_tok is None: 262 warnings.append( 263 f"sentence {index + 1}: {tok.id!r} relates to " 264 f"{anchor_id!r} as 'agent' but has no 'object " 265 "of preposition' dependent -- skipping its " 266 "agent node" 267 ) 268 continue 269 else: 270 role_tok = tok 271 272 if role_tok.tokentype in IMPLIED_TOKENTYPES: 273 continue 274 if role_tok.id in seen_role_token_ids: 275 continue 276 seen_role_token_ids.add(role_tok.id) 277 278 nodes.append( 279 AATNode( 280 context=context, 281 id=role_tok.id, 282 value=token_label(role_tok), 283 role=role, 284 related_node=anchor_id, 285 ) 286 ) 287 288 return AATGraph(nodes=nodes), warnings
Build an aat.core.AATGraph from an already-completed
arsgrammatica analysis -- sentences/results, in the exact shape
pipeline.analyze_sources() (or analyze_string()) returns them:
results[i] is the SentenceAnalysis result (with its own .tokengraph
and .verbalunits) for sentences[i], same order, one entry per
sentence.
Returns (graph, warnings) -- warnings follows this codebase's
usual "degrade visibly, don't raise" convention (see
verbal_units.compute_subordination_depths()): a sentence whose
tokens span more than one citation, or a verbal expression missing
its own verbalunits entry (shouldn't happen in well-formed output,
but this function doesn't assume it), is reported here rather than
raising. An empty list means nothing unusual was found -- not that
the underlying Latin analysis itself is correct; run the resulting
graph through aat.core.validate.validate() (with a matching
CitableToken list) for that.
Every action, agent, and target node this function can derive is documented in this module's own docstring, above.