grammatike
grammatike: a DSPy program analyzing the syntax of Ancient Greek passages according to the scheme documented in syntax_model.md.
Greek analogue of arsgrammatica (https://github.com/neelsmith/arsgrammatica),
the same author's parallel package for analyzing Latin syntax.
1"""grammatike: a DSPy program analyzing the syntax of Ancient Greek passages 2according to the scheme documented in syntax_model.md. 3 4Greek analogue of `arsgrammatica` (https://github.com/neelsmith/arsgrammatica), 5the same author's parallel package for analyzing Latin syntax. 6""" 7 8from .models import ( 9 Token, 10 CitedText, 11 Sentence, 12 VerbalExpression, 13 TokenAnalysis, 14 RelationLabel, 15 IMPLIED_TOKENTYPES, 16) 17from .mermaid import tokengraph_to_mermaid, save_mermaid 18from .verbal_units import ( 19 assign_verbal_units, 20 assign_verbal_unit_colors, 21 compute_subordination_depths, 22 max_subordination_depth, 23 find_unanchored_coordinated_verbs, 24) 25from .dot import tokengraph_to_dot, save_dot, compute_graph_depths, max_graph_depth 26from .rendering import tokengraph_to_text, tokengraph_to_html, tokengraph_to_depth_html 27from .greek_syntax_dspy import ( 28 SyntaxAnalysis, 29 analyze, 30 validate, 31 print_analysis, 32) 33from .segmentation import segment_sources 34from .pipeline import analyze_sources, combined_tokengraph, analyze_passage 35from .serialization import ( 36 serialize_analyses, 37 write_analyses, 38 read_analyses, 39 read_llm_notes, 40 split_analysis_by_sentence, 41) 42from .ctsdata import CtsDataRow, read_ctsdata 43from .token_budget import ( 44 estimate_max_tokens, 45 analyze_with_retry, 46 get_calibration, 47 DEFAULT_CEILING, 48) 49from .gepa_metric import syntax_metric 50 51__all__ = [ 52 "Token", 53 "CitedText", 54 "Sentence", 55 "VerbalExpression", 56 "TokenAnalysis", 57 "RelationLabel", 58 "IMPLIED_TOKENTYPES", 59 "tokengraph_to_mermaid", 60 "save_mermaid", 61 "assign_verbal_units", 62 "assign_verbal_unit_colors", 63 "compute_subordination_depths", 64 "max_subordination_depth", 65 "find_unanchored_coordinated_verbs", 66 "tokengraph_to_dot", 67 "save_dot", 68 "compute_graph_depths", 69 "max_graph_depth", 70 "tokengraph_to_text", 71 "tokengraph_to_html", 72 "tokengraph_to_depth_html", 73 "SyntaxAnalysis", 74 "analyze", 75 "validate", 76 "print_analysis", 77 "segment_sources", 78 "analyze_sources", 79 "combined_tokengraph", 80 "analyze_passage", 81 "serialize_analyses", 82 "write_analyses", 83 "read_analyses", 84 "read_llm_notes", 85 "split_analysis_by_sentence", 86 "CtsDataRow", 87 "read_ctsdata", 88 "estimate_max_tokens", 89 "analyze_with_retry", 90 "get_calibration", 91 "DEFAULT_CEILING", 92 "syntax_metric", 93]
36class Token(BaseModel): 37 """A single pre-segmented token with a stable id. 38 39 `citation` is optional so this model still works for citation-free 40 callers -- e.g. a test fixture built directly from a canned tokengraph, 41 with no CitedText source at all -- as well as for the citation-aware 42 segmentation stage (segmentation.py), which is the only thing that 43 actually populates it, knowing which CitedText source unit each token 44 came from.""" 45 46 id: str = Field(description="Stable token id, globally unique and sequential across the whole input, e.g. 't0', 't1', ...") 47 text: str = Field(description="The token's surface text, exactly as it appears in the source.") 48 citation: Optional[str] = Field( 49 default=None, 50 description="Citation label of the source unit this token came from (e.g. 'Lysias 1.1'), if known.", 51 )
A single pre-segmented token with a stable id.
citation is optional so this model still works for citation-free
callers -- e.g. a test fixture built directly from a canned tokengraph,
with no CitedText source at all -- as well as for the citation-aware
segmentation stage (segmentation.py), which is the only thing that
actually populates it, knowing which CitedText source unit each token
came from.
24class CitedText(BaseModel): 25 """One citable unit of source text -- e.g. one line of poetry, one 26 section of prose -- paired with its citation label. A sequence of 27 these is segmentation.py's input: sentence boundaries do NOT need 28 to respect CitedText boundaries (one sentence may span several units), 29 but every resulting token still records which unit it came from via 30 Token.citation.""" 31 32 citation: str = Field(description="Citation label for this unit, e.g. 'Lysias 1.1'.") 33 text: str = Field(description="This unit's raw text, exactly as written.")
One citable unit of source text -- e.g. one line of poetry, one section of prose -- paired with its citation label. A sequence of these is segmentation.py's input: sentence boundaries do NOT need to respect CitedText boundaries (one sentence may span several units), but every resulting token still records which unit it came from via Token.citation.
54class Sentence(BaseModel): 55 """One sentence's worth of tokens, in reading order, as produced by the 56 deterministic segmentation stage (segmentation.py). Token ids are 57 global across the whole passage -- numbering continues across sentence 58 boundaries rather than restarting at t0 for each sentence -- so a 59 Sentence is a contiguous slice of the passage's id sequence, not an 60 independently-numbered unit.""" 61 62 tokens: List[Token] = Field( 63 description="This sentence's tokens, in reading order, using the passage's global token ids." 64 )
One sentence's worth of tokens, in reading order, as produced by the deterministic segmentation stage (segmentation.py). Token ids are global across the whole passage -- numbering continues across sentence boundaries rather than restarting at t0 for each sentence -- so a Sentence is a contiguous slice of the passage's id sequence, not an independently-numbered unit.
67class VerbalExpression(BaseModel): 68 """One entry in the table of verbal expressions (syntax_model.md, 'Basic 69 model' / verbal-expression sections). Three constructions count as a 70 verbal expression: finite verbs, infinitives (only when part of indirect 71 speech), and participles -- but only some participles: an *attributive* 72 participle (e.g. ὁ ἀνὴρ ὁ ὑβρίζων εἰς σέ, "the man who is insulting 73 you") and a *circumstantial* participle (e.g. χρόνου μεταξὺ 74 διαγενομένου, "when some time had passed") each constitute a verbal 75 expression of their own, as does a participle expressing indirect 76 speech after a verb of perception or thinking (εἶδε τὴν βασίλειαν 77 φεύγουσαν, "he saw the queen fleeing"). A *supplementary* participle 78 (e.g. ὁ ἀνὴρ ἐχθρὸς ὢν ἡμῖν τυγχάνει, where ὤν supplements τυγχάνει) is 79 NOT a verbal expression at all -- it does not get its own entry here. 80 81 Each construction has its own set of allowed `syntactic_type` values, 82 given explicitly by syntax_model.md rather than left to convention: 83 a finite verb is 'independent', 'dependent', 'direct quote' (occurring 84 in directly quoted speech, e.g. πειρᾷς in '"ἵνα σύ γε" ἔφη "πειρᾷς 85 ἐνταῦθα τὴν παιδίσκην"'), or 'aside' (a verbal expression that 86 interrupts the surrounding syntax, e.g. δεῖ in 'δεῖ γὰρ καὶ ταῦθ᾽ 87 ὑμῖν διηγήσασθαι'); an infinitive or participle anchoring an indirect 88 statement is always 'indirect statement'; an attributive participle is 89 'attributive'; a circumstantial participle is 'circumstantial'.""" 90 91 id: str = Field( 92 description=( 93 "The token id (from the input `tokens` list) of the finite verb, " 94 "infinitive, or participle that anchors this verbal expression. " 95 "For a multi-word compound form with a conjugated form of εἰμί " 96 "(e.g. the perfect passive/middle system, ὁ νόμος γεγραμμένος " 97 "ἐστίν), use the id of the conjugated form of εἰμί, not the " 98 "participle. For an implied/elided verbal expression (see " 99 "TokenAnalysis's 'implied eimi'/'implied repetition' tokentypes, " 100 "IMPLIED_TOKENTYPES), use the new implied token's id instead -- " 101 "an implied token always anchors its own verbal expression." 102 ) 103 ) 104 syntactic_type: Literal[ 105 "independent", 106 "dependent", 107 "direct quote", 108 "aside", 109 "indirect statement", 110 "attributive", 111 "circumstantial", 112 ] = Field( 113 description=( 114 "For a finite verb: 'independent' (main/principal), 'dependent' " 115 "(subordinate/secondary, introduced by a subordinating " 116 "conjunction or relative/interrogative pronoun), 'direct quote' " 117 "(occurring in directly quoted speech), or 'aside' (interrupts " 118 "the surrounding syntax). For an infinitive, or a participle " 119 "after a verb of perception/thinking, anchoring an indirect " 120 "statement: 'indirect statement'. For an attributive " 121 "participle: 'attributive'. For a circumstantial participle " 122 "(including a genitive absolute): 'circumstantial'." 123 ) 124 ) 125 semantic_type: Literal[ 126 "transitive active", "transitive passive", "intransitive", "linking verb" 127 ] = Field(description="The verb's semantic/voice type.")
One entry in the table of verbal expressions (syntax_model.md, 'Basic model' / verbal-expression sections). Three constructions count as a verbal expression: finite verbs, infinitives (only when part of indirect speech), and participles -- but only some participles: an attributive participle (e.g. ὁ ἀνὴρ ὁ ὑβρίζων εἰς σέ, "the man who is insulting you") and a circumstantial participle (e.g. χρόνου μεταξὺ διαγενομένου, "when some time had passed") each constitute a verbal expression of their own, as does a participle expressing indirect speech after a verb of perception or thinking (εἶδε τὴν βασίλειαν φεύγουσαν, "he saw the queen fleeing"). A supplementary participle (e.g. ὁ ἀνὴρ ἐχθρὸς ὢν ἡμῖν τυγχάνει, where ὤν supplements τυγχάνει) is NOT a verbal expression at all -- it does not get its own entry here.
Each construction has its own set of allowed syntactic_type values,
given explicitly by syntax_model.md rather than left to convention:
a finite verb is 'independent', 'dependent', 'direct quote' (occurring
in directly quoted speech, e.g. πειρᾷς in '"ἵνα σύ γε" ἔφη "πειρᾷς
ἐνταῦθα τὴν παιδίσκην"'), or 'aside' (a verbal expression that
interrupts the surrounding syntax, e.g. δεῖ in 'δεῖ γὰρ καὶ ταῦθ᾽
ὑμῖν διηγήσασθαι'); an infinitive or participle anchoring an indirect
statement is always 'indirect statement'; an attributive participle is
'attributive'; a circumstantial participle is 'circumstantial'.
The token id (from the input tokens list) of the finite verb, infinitive, or participle that anchors this verbal expression. For a multi-word compound form with a conjugated form of εἰμί (e.g. the perfect passive/middle system, ὁ νόμος γεγραμμένος ἐστίν), use the id of the conjugated form of εἰμί, not the participle. For an implied/elided verbal expression (see TokenAnalysis's 'implied eimi'/'implied repetition' tokentypes, IMPLIED_TOKENTYPES), use the new implied token's id instead -- an implied token always anchors its own verbal expression.
For a finite verb: 'independent' (main/principal), 'dependent' (subordinate/secondary, introduced by a subordinating conjunction or relative/interrogative pronoun), 'direct quote' (occurring in directly quoted speech), or 'aside' (interrupts the surrounding syntax). For an infinitive, or a participle after a verb of perception/thinking, anchoring an indirect statement: 'indirect statement'. For an attributive participle: 'attributive'. For a circumstantial participle (including a genitive absolute): 'circumstantial'.
262class TokenAnalysis(BaseModel): 263 """One entry per token in the dependency graph (syntax_model.md, 264 'Token-level table of dependencies'). Not every token will have a 265 relation -- leave the relatedtoken*/relationship* fields unset when none 266 of the documented relations apply (e.g. an independent verb has no 267 relation2, and a punctuation token typically has none at all). 268 269 Most entries correspond 1:1 to an entry in the input `tokens` list. The 270 exceptions are the two IMPLIED_TOKENTYPES values below: syntax_model.md's 271 'understood or implied verbal expressions' section documents two 272 DIFFERENT situations where a verbal expression exists grammatically but 273 has no surface realization at all in the passage, and this codebase 274 distinguishes them with two distinct tokentype values rather than one 275 generic 'implied': 276 277 - 'implied eimi': an elided form of εἰμί ("to be") in a predicate 278 expression. Example: ταύτην τὴν ὕβριν ἅπαντες ἄνθρωποι δεινοτάτην 279 ἡγοῦνται has an implied infinitive of εἰμί governing the predicate 280 ταύτην τὴν ὕβριν ... δεινοτάτην in indirect statement after 281 ἡγοῦνται. 282 - 'implied repetition': a verb elided from a later verbal expression in 283 a coordinated series because it repeats the verb of an earlier one. 284 Example: ἐγὼ μὲν ἄνω διῃτώμην, αἱ δὲ γυναῖκες κάτω elides a second 285 διῃτώμην after γυναῖκες. 286 287 For either, add a NEW entry here -- with a NEW id, not present in 288 `tokens` -- rather than skipping the construction entirely; see 289 greek_syntax_dspy.SyntaxAnalysis's docstring for the full rules and the 290 id-naming convention.""" 291 292 id: str = Field( 293 description=( 294 "For an ordinary entry, must match the id of the corresponding " 295 "entry in the input `tokens` list. For an implied token " 296 "(tokentype in IMPLIED_TOKENTYPES -- 'implied eimi' or " 297 "'implied repetition'), a NEW id not used by any entry in " 298 "`tokens` or elsewhere in this tokengraph -- see " 299 "SyntaxAnalysis's docstring for the naming convention." 300 ) 301 ) 302 token: Optional[str] = Field( 303 default=None, 304 description=( 305 "The token's surface text; should match the `text` of the input " 306 "token with this id. Leave as None ONLY for an implied token " 307 "(tokentype 'implied eimi' or 'implied repetition') -- one with " 308 "no surface realization in the passage at all; every other " 309 "tokentype must have real text." 310 ), 311 ) 312 tokentype: Literal[ 313 "lexical", "enclitic", "punctuation", "numeral", 314 "implied eimi", "implied repetition", 315 ] = Field( 316 description=( 317 "'numeral' is a number written NUMERICALLY (e.g. in Milesian " 318 "notation) rather than spelled out as a word; a number spelled " 319 "out as an ordinary word (e.g. δύω for 'two') is 'lexical' " 320 "instead, even though it's semantically a number -- e.g. in " 321 "'Ἀτρεΐδα δὲ μάλιστα δύω', δύω is 'lexical', not 'numeral'. " 322 "'enclitic' tokenization must consider context -- syntax_model.md's " 323 "tokenization section documents this. " 324 "'implied eimi' and 'implied repetition' each mark a token with " 325 "NO surface realization at all (see this model's own docstring " 326 "for the distinction) -- the only two tokentypes whose `token` " 327 "field is None and whose `id` is not one of the input `tokens`' " 328 "own ids." 329 ) 330 ) 331 332 lemma: Optional[str] = Field(default=None, description="Dictionary headword, for lexical tokens. Omit for punctuation.") 333 verbalunitid: Optional[str] = Field( 334 default=None, 335 description="If this token anchors a verbal expression in `verbalunits`, repeat its own id here; otherwise omit.", 336 ) 337 338 relatedtoken1: Optional[str] = Field( 339 default=None, 340 description=( 341 "Token id this token relates to (primary relation). For an " 342 "INDEPENDENT verb's own 'unit verb' relation, use the special " 343 "sentinel string 'root' instead of a token id -- 'root' is " 344 "reserved and must never be assigned as an actual token's id." 345 ), 346 ) 347 relationship1: Optional[RelationLabel] = Field(default=None, description="The primary relation type, if any.") 348 349 relatedtoken2: Optional[str] = Field(default=None, description="Token id this token relates to (secondary relation, used when relation1 is already occupied -- e.g. a relative pronoun's function inside its own clause).") 350 relationship2: Optional[RelationLabel] = Field(default=None, description="The secondary relation type, if any.")
One entry per token in the dependency graph (syntax_model.md, 'Token-level table of dependencies'). Not every token will have a relation -- leave the relatedtoken*/relationship* fields unset when none of the documented relations apply (e.g. an independent verb has no relation2, and a punctuation token typically has none at all).
Most entries correspond 1:1 to an entry in the input tokens list. The
exceptions are the two IMPLIED_TOKENTYPES values below: syntax_model.md's
'understood or implied verbal expressions' section documents two
DIFFERENT situations where a verbal expression exists grammatically but
has no surface realization at all in the passage, and this codebase
distinguishes them with two distinct tokentype values rather than one
generic 'implied':
- 'implied eimi': an elided form of εἰμί ("to be") in a predicate expression. Example: ταύτην τὴν ὕβριν ἅπαντες ἄνθρωποι δεινοτάτην ἡγοῦνται has an implied infinitive of εἰμί governing the predicate ταύτην τὴν ὕβριν ... δεινοτάτην in indirect statement after ἡγοῦνται.
- 'implied repetition': a verb elided from a later verbal expression in a coordinated series because it repeats the verb of an earlier one. Example: ἐγὼ μὲν ἄνω διῃτώμην, αἱ δὲ γυναῖκες κάτω elides a second διῃτώμην after γυναῖκες.
For either, add a NEW entry here -- with a NEW id, not present in
tokens -- rather than skipping the construction entirely; see
greek_syntax_dspy.SyntaxAnalysis's docstring for the full rules and the
id-naming convention.
For an ordinary entry, must match the id of the corresponding entry in the input tokens list. For an implied token (tokentype in IMPLIED_TOKENTYPES -- 'implied eimi' or 'implied repetition'), a NEW id not used by any entry in tokens or elsewhere in this tokengraph -- see SyntaxAnalysis's docstring for the naming convention.
The token's surface text; should match the text of the input token with this id. Leave as None ONLY for an implied token (tokentype 'implied eimi' or 'implied repetition') -- one with no surface realization in the passage at all; every other tokentype must have real text.
'numeral' is a number written NUMERICALLY (e.g. in Milesian notation) rather than spelled out as a word; a number spelled out as an ordinary word (e.g. δύω for 'two') is 'lexical' instead, even though it's semantically a number -- e.g. in 'Ἀτρεΐδα δὲ μάλιστα δύω', δύω is 'lexical', not 'numeral'. 'enclitic' tokenization must consider context -- syntax_model.md's tokenization section documents this. 'implied eimi' and 'implied repetition' each mark a token with NO surface realization at all (see this model's own docstring for the distinction) -- the only two tokentypes whose token field is None and whose id is not one of the input tokens' own ids.
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.
114def tokengraph_to_mermaid( 115 tokengraph: List[TokenAnalysis], 116 orientation: str = "BT", 117 color_by_verbal_unit: bool = True, 118) -> Tuple[str, List[str]]: 119 """Build a Mermaid `graph` diagram from a tokengraph. 120 121 `orientation` is Mermaid's own flowchart orientation code -- `BT` 122 (bottom-to-top, the default here), `TB`, `LR`, or `RL` -- used verbatim 123 in the diagram's opening line (`graph BT`, `graph LR`, etc.). See 124 https://mermaid.js.org/syntax/flowchart.html for what each value looks 125 like; this function doesn't validate it, so a typo just becomes invalid 126 Mermaid syntax in the output rather than an error here. 127 128 `color_by_verbal_unit` (default True) colors every node by the verbal 129 unit it belongs to, per verbal_units.assign_verbal_units() -- so each 130 clause is visually distinguishable. Verbal units are assigned colors 131 from `_VERBAL_UNIT_PALETTE` in the order their tokens first appear in 132 `tokengraph`; a token assigned to no verbal unit is left with Mermaid's 133 default node styling. The one exception is an implied/elided token 134 (models.py's IMPLIED_TOKENTYPES) -- it always gets its own dedicated 135 `implied` class, colored with `verbal_units._IMPLIED_TOKEN_COLOR`, 136 instead of whatever `_VERBAL_UNIT_PALETTE` color its own verbal unit 137 would otherwise get (see this module's own docstring for why). Pass 138 False to skip coloring and get a plain diagram, as before this 139 parameter existed. 140 141 Returns (diagram_text, warnings). `warnings` lists any edges that were 142 skipped because they referenced a punctuation token or an id not present 143 in `tokengraph` -- worth checking, since it usually means the id came 144 from a validation problem upstream (see greek_syntax_dspy.validate) -- 145 plus, if `color_by_verbal_unit` is True and the passage has more than 8 146 verbal units, one warning that colors are repeating rather than staying 147 distinct (the palette has 8 slots; see _VERBAL_UNIT_PALETTE). 148 """ 149 node_ids = {tok.id for tok in tokengraph if tok.tokentype != "punctuation"} 150 151 lines = [f"graph {orientation}"] 152 for tok in tokengraph: 153 if tok.id not in node_ids: 154 continue 155 # An implied/elided token (see models.py's IMPLIED_TOKENTYPES) has 156 # no surface text at all -- tok.token is None -- so it needs a 157 # placeholder label rather than crashing _escape_label() on None. 158 # _IMPLIED_TOKEN_LABELS supplies that ("elided eimi" for "implied 159 # eimi", "implied repetition" verbatim); the node's color (below) 160 # is what actually marks it as an implied token, not the label 161 # text. token_label() is the shared helper dot.py's 162 # tokengraph_to_dot() also uses, so both renderers agree on this. 163 label = token_label(tok) 164 lines.append(f' {tok.id}["{_escape_label(label)}"]') 165 166 warnings = [] 167 for tok in tokengraph: 168 if tok.id not in node_ids: 169 continue 170 for related_field, label_field in ( 171 ("relatedtoken1", "relationship1"), 172 ("relatedtoken2", "relationship2"), 173 ): 174 related_id = getattr(tok, related_field) 175 label = getattr(tok, label_field) 176 if related_id is None or label is None: 177 continue 178 if related_id == "root": 179 # An independent verb's own unit-verb relation, per 180 # syntax_model.md -- intentionally not a real node, so not 181 # a warning-worthy gap. Just draw no edge for it. 182 continue 183 if related_id not in node_ids: 184 warnings.append( 185 f"skipped edge {tok.id} -[{label}]-> {related_id}: " 186 f"target is punctuation or not in tokengraph" 187 ) 188 continue 189 lines.append(f' {tok.id} -->|{_escape_label(label)}| {related_id}') 190 191 if color_by_verbal_unit: 192 assignment = assign_verbal_units(tokengraph) 193 colors, color_warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment) 194 warnings.extend(color_warnings) 195 196 # Implied tokens (models.py's IMPLIED_TOKENTYPES) always get a 197 # dedicated "caution" amber (_IMPLIED_TOKEN_COLOR) instead of 198 # whatever color their own verbal unit would otherwise get -- 199 # regardless of which unit they anchor -- so they're excluded from 200 # every per-unit `member_ids` group below and given their own 201 # classDef/class pair instead. See rendering.py's 202 # tokengraph_to_html() docstring for the matching HTML behavior. 203 implied_ids = [ 204 tok.id 205 for tok in tokengraph 206 if tok.id in node_ids and tok.tokentype in IMPLIED_TOKENTYPES 207 ] 208 209 # A token whose relationship1 is specifically "sentence connector" 210 # (e.g. γάρ tying this sentence back to the previous one -- see 211 # rendering.py's identical carve-out, and models.py's RelationLabel 212 # docstring for the distinction from the more general "connecting 213 # word") always gets its own dedicated `sentenceconnector` class -- 214 # a neon-yellow fill with a strong black border -- instead of 215 # whatever color its own verbal unit would otherwise get, 216 # regardless of which unit it's assigned to. Excluded from 217 # implied_ids (in the never-really-expected case a token is somehow 218 # both) so the two classes never compete for the same node. 219 connector_ids = [ 220 tok.id 221 for tok in tokengraph 222 if tok.id in node_ids 223 and tok.id not in implied_ids 224 and tok.relationship1 == "sentence connector" 225 ] 226 227 if colors or implied_ids or connector_ids: 228 lines.append("") 229 class_names = {} 230 for i, (unit_id, (fill, stroke, text)) in enumerate(colors.items()): 231 class_name = f"vu{i}" 232 class_names[unit_id] = class_name 233 lines.append( 234 f" classDef {class_name} fill:{fill},stroke:{stroke},color:{text};" 235 ) 236 for unit_id in colors: 237 member_ids = [ 238 tok.id 239 for tok in tokengraph 240 if tok.id in node_ids 241 and assignment.get(tok.id) == unit_id 242 and tok.id not in implied_ids 243 and tok.id not in connector_ids 244 ] 245 if member_ids: 246 lines.append(f" class {','.join(member_ids)} {class_names[unit_id]};") 247 if implied_ids: 248 fill, stroke, text = _IMPLIED_TOKEN_COLOR 249 lines.append( 250 f" classDef implied fill:{fill},stroke:{stroke},color:{text};" 251 ) 252 lines.append(f" class {','.join(implied_ids)} implied;") 253 if connector_ids: 254 lines.append( 255 " classDef sentenceconnector fill:#ffff00,stroke:#000000,stroke-width:4px,color:#000000;" 256 ) 257 lines.append(f" class {','.join(connector_ids)} sentenceconnector;") 258 259 return "\n".join(lines), warnings
Build a Mermaid graph diagram from a tokengraph.
orientation is Mermaid's own flowchart orientation code -- BT
(bottom-to-top, the default here), TB, LR, or RL -- used verbatim
in the diagram's opening line (graph BT, graph LR, etc.). See
https://mermaid.js.org/syntax/flowchart.html for what each value looks
like; this function doesn't validate it, so a typo just becomes invalid
Mermaid syntax in the output rather than an error here.
color_by_verbal_unit (default True) colors every node by the verbal
unit it belongs to, per verbal_units.assign_verbal_units() -- so each
clause is visually distinguishable. Verbal units are assigned colors
from _VERBAL_UNIT_PALETTE in the order their tokens first appear in
tokengraph; a token assigned to no verbal unit is left with Mermaid's
default node styling. The one exception is an implied/elided token
(models.py's IMPLIED_TOKENTYPES) -- it always gets its own dedicated
implied class, colored with verbal_units._IMPLIED_TOKEN_COLOR,
instead of whatever _VERBAL_UNIT_PALETTE color its own verbal unit
would otherwise get (see this module's own docstring for why). Pass
False to skip coloring and get a plain diagram, as before this
parameter existed.
Returns (diagram_text, warnings). warnings lists any edges that were
skipped because they referenced a punctuation token or an id not present
in tokengraph -- worth checking, since it usually means the id came
from a validation problem upstream (see greek_syntax_dspy.validate) --
plus, if color_by_verbal_unit is True and the passage has more than 8
verbal units, one warning that colors are repeating rather than staying
distinct (the palette has 8 slots; see _VERBAL_UNIT_PALETTE).
262def save_mermaid( 263 tokengraph: List[TokenAnalysis], 264 path: str, 265 orientation: str = "BT", 266 color_by_verbal_unit: bool = True, 267) -> List[str]: 268 """Write the diagram to `path` (e.g. 'analysis.mmd') and return any 269 warnings from tokengraph_to_mermaid.""" 270 diagram, warnings = tokengraph_to_mermaid( 271 tokengraph, orientation=orientation, color_by_verbal_unit=color_by_verbal_unit 272 ) 273 with open(path, "w", encoding="utf-8") as f: 274 f.write(diagram + "\n") 275 return warnings
Write the diagram to path (e.g. 'analysis.mmd') and return any
warnings from tokengraph_to_mermaid.
161def assign_verbal_units(tokengraph: List[TokenAnalysis]) -> Dict[str, Optional[str]]: 162 """Return {token id: verbal unit id or None}, one entry per token in 163 `tokengraph` (including punctuation and unrelated tokens, so every id 164 is accounted for -- callers that only care about assigned tokens can 165 filter out the None values themselves). 166 167 A verbal unit's own anchor token is assigned to itself (its 168 `verbalunitid`). Every other token is assigned to the verbal unit its 169 relations resolve to, per this module's docstring; a token with no 170 resolvable relation (e.g. a bare accusative of time, an enclitic left 171 unrelated) gets None. 172 173 A true genitive-absolute noun (its own outgoing relation is "genitive 174 absolute", not some normal clause role) is redirected to the verbal 175 unit of the circumstantial participle it agrees with, rather than to 176 the verb its own relatedtoken1 points at -- see this module's 177 docstring for the full "προϊόντος δὲ τοῦ χρόνου ... ἧκον" example. 178 Anything that in turn chains through that noun (an adjective, an 179 appositive) follows it into the participle's unit too, since this 180 redirect happens once, at the noun itself, and every other resolution 181 is unchanged. 182 183 Attributive participles need NO analogous redirect: the noun/pronoun 184 they agree with always keeps its ordinary syntactic role (there is no 185 "attributive absolute" construction), so it resolves correctly via the 186 plain fallback chase below without any special-casing -- see this 187 module's docstring for why. 188 """ 189 by_id = {tok.id: tok for tok in tokengraph} 190 191 # Reverse index: for every token that some OTHER token points at via a 192 # "unit verb" relation, record who points at it. Per syntax_model.md, 193 # a "unit verb" target is always either the literal sentinel 'root' 194 # (from an independent verb -- never a real token) or a subordinating 195 # conjunction/relative (or interrogative) pronoun's id (from a 196 # dependent verb) -- so a hit here always means "this token introduces 197 # the pointing verb's clause." 198 introduces_clause_for: Dict[str, str] = {} 199 # Reverse index: for every token that some OTHER token points at via a 200 # "circumstantial participle" relation, record who points at it (the 201 # participle -- real or implied -- that agrees with it). Used below to 202 # redirect a TRUE genitive-absolute noun to that participle's own 203 # verbal unit instead of the verb it otherwise points at; a noun a 204 # circumstantial participle agrees with that fits normally into the 205 # clause (its own outgoing relation isn't "genitive absolute") is left 206 # alone and keeps resolving normally, so this index is consulted but 207 # not always used. 208 # 209 # There is deliberately NO analogous reverse index for "attributive 210 # participle": the noun/pronoun an attributive participle agrees with 211 # never needs redirecting (see this module's docstring), so building 212 # one here would just be dead code. 213 circumstantial_participle_for: Dict[str, str] = {} 214 for tok in tokengraph: 215 for related_field, label_field in ( 216 ("relatedtoken1", "relationship1"), 217 ("relatedtoken2", "relationship2"), 218 ): 219 related = getattr(tok, related_field) 220 label = getattr(tok, label_field) 221 if related is None or related == "root": 222 continue 223 if label == _UNIT_VERB: 224 introduces_clause_for[related] = tok.id 225 elif label == _CIRCUMSTANTIAL_PARTICIPLE: 226 circumstantial_participle_for[related] = tok.id 227 228 resolved: Dict[str, Optional[str]] = {} 229 in_progress: set = set() 230 231 def resolve(tid: str) -> Optional[str]: 232 if tid in resolved: 233 return resolved[tid] 234 tok = by_id.get(tid) 235 if tok is None: 236 return None 237 238 if tok.verbalunitid is not None: 239 resolved[tid] = tok.verbalunitid 240 return tok.verbalunitid 241 242 if tid in in_progress: 243 # A cycle in the relation graph (malformed LM output) -- bail 244 # out on this token rather than recursing forever. 245 return None 246 in_progress.add(tid) 247 248 result = None 249 250 clause_verb_id = introduces_clause_for.get(tid) 251 if clause_verb_id is not None: 252 result = resolve(clause_verb_id) 253 254 if result is None: 255 participle_id = circumstantial_participle_for.get(tid) 256 is_genitive_absolute = ( 257 tok.relationship1 == _GENITIVE_ABSOLUTE 258 or tok.relationship2 == _GENITIVE_ABSOLUTE 259 ) 260 if participle_id is not None and is_genitive_absolute: 261 result = resolve(participle_id) 262 263 if result is None: 264 for related_field in ("relatedtoken1", "relatedtoken2"): 265 related = getattr(tok, related_field) 266 if related is None or related == "root": 267 continue 268 result = resolve(related) 269 if result is not None: 270 break 271 272 in_progress.discard(tid) 273 resolved[tid] = result 274 return result 275 276 for tid in by_id: 277 resolve(tid) 278 279 return resolved
Return {token id: verbal unit id or None}, one entry per token in
tokengraph (including punctuation and unrelated tokens, so every id
is accounted for -- callers that only care about assigned tokens can
filter out the None values themselves).
A verbal unit's own anchor token is assigned to itself (its
verbalunitid). Every other token is assigned to the verbal unit its
relations resolve to, per this module's docstring; a token with no
resolvable relation (e.g. a bare accusative of time, an enclitic left
unrelated) gets None.
A true genitive-absolute noun (its own outgoing relation is "genitive absolute", not some normal clause role) is redirected to the verbal unit of the circumstantial participle it agrees with, rather than to the verb its own relatedtoken1 points at -- see this module's docstring for the full "προϊόντος δὲ τοῦ χρόνου ... ἧκον" example. Anything that in turn chains through that noun (an adjective, an appositive) follows it into the participle's unit too, since this redirect happens once, at the noun itself, and every other resolution is unchanged.
Attributive participles need NO analogous redirect: the noun/pronoun they agree with always keeps its ordinary syntactic role (there is no "attributive absolute" construction), so it resolves correctly via the plain fallback chase below without any special-casing -- see this module's docstring for why.
282def assign_verbal_unit_colors( 283 tokengraph: List[TokenAnalysis], 284 assignment: Optional[Dict[str, Optional[str]]] = None, 285) -> Tuple[Dict[str, Tuple[str, str, str]], List[str]]: 286 """Assign each verbal unit found in `tokengraph` a stable (fill, stroke, 287 text) triple from `_VERBAL_UNIT_PALETTE`, using the exact ordering rule 288 `tokengraph_to_mermaid()` uses for its node coloring -- so any other 289 caller wanting "the same colors as the mermaid graph" (currently 290 rendering.py's `tokengraph_to_html()`) gets an identical mapping without 291 re-deriving the rule itself. 292 293 Order is by first appearance of each verbal unit among tokengraph's 294 *non-punctuation* tokens, since those are the only tokens that become 295 mermaid nodes at all -- a verbal unit whose earliest token happens to be 296 punctuation (it can't be: punctuation tokens aren't assigned to a 297 verbal unit's anchor, but could in principle inherit one from a 298 relation) still gets ordered by its first non-punctuation member. 299 300 Pass `assignment` (the result of `assign_verbal_units(tokengraph)`) if 301 the caller already computed it, to avoid re-deriving it here; otherwise 302 it's computed internally. 303 304 Returns `({verbal unit id: (fill, stroke, text)}, warnings)` -- 305 `warnings` holds one entry, with the same wording 306 `tokengraph_to_mermaid()` uses, if there are more distinct verbal units 307 than palette slots (colors repeat past the 8th unit). A verbal unit id 308 absent from the returned dict was never assigned to any non-punctuation 309 token -- callers should treat that the same as "no verbal unit" (no 310 coloring), same as `tokengraph_to_mermaid()` does. 311 """ 312 if assignment is None: 313 assignment = assign_verbal_units(tokengraph) 314 315 non_punctuation_ids = {tok.id for tok in tokengraph if tok.tokentype != "punctuation"} 316 317 unit_order: List[str] = [] 318 seen_units = set() 319 for tok in tokengraph: 320 if tok.id not in non_punctuation_ids: 321 continue 322 unit_id = assignment.get(tok.id) 323 if unit_id is not None and unit_id not in seen_units: 324 seen_units.add(unit_id) 325 unit_order.append(unit_id) 326 327 warnings: List[str] = [] 328 if len(unit_order) > len(_VERBAL_UNIT_PALETTE): 329 warnings.append( 330 f"{len(unit_order)} verbal units but only {len(_VERBAL_UNIT_PALETTE)} " 331 "distinct colors -- colors repeat and may be ambiguous between units" 332 ) 333 334 colors = { 335 unit_id: _VERBAL_UNIT_PALETTE[i % len(_VERBAL_UNIT_PALETTE)] 336 for i, unit_id in enumerate(unit_order) 337 } 338 return colors, warnings
Assign each verbal unit found in tokengraph a stable (fill, stroke,
text) triple from _VERBAL_UNIT_PALETTE, using the exact ordering rule
tokengraph_to_mermaid() uses for its node coloring -- so any other
caller wanting "the same colors as the mermaid graph" (currently
rendering.py's tokengraph_to_html()) gets an identical mapping without
re-deriving the rule itself.
Order is by first appearance of each verbal unit among tokengraph's non-punctuation tokens, since those are the only tokens that become mermaid nodes at all -- a verbal unit whose earliest token happens to be punctuation (it can't be: punctuation tokens aren't assigned to a verbal unit's anchor, but could in principle inherit one from a relation) still gets ordered by its first non-punctuation member.
Pass assignment (the result of assign_verbal_units(tokengraph)) if
the caller already computed it, to avoid re-deriving it here; otherwise
it's computed internally.
Returns ({verbal unit id: (fill, stroke, text)}, warnings) --
warnings holds one entry, with the same wording
tokengraph_to_mermaid() uses, if there are more distinct verbal units
than palette slots (colors repeat past the 8th unit). A verbal unit id
absent from the returned dict was never assigned to any non-punctuation
token -- callers should treat that the same as "no verbal unit" (no
coloring), same as tokengraph_to_mermaid() does.
341def compute_subordination_depths( 342 tokengraph: List[TokenAnalysis], 343) -> Tuple[Dict[str, Optional[int]], List[str]]: 344 """Compute each verbal expression's *depth of subordination*: the 345 number of verbal expressions it is removed from an independent ("root") 346 clause. An independent verb is depth 0; a verb it introduces (a 347 dependent clause, a direct quote, an aside) is depth 1; a verbal 348 expression THAT verb in turn introduces (e.g. an indirect statement 349 inside a dependent clause) is depth 2; and so on. 350 351 A "verbal expression" here is any token that anchors one -- i.e. any 352 token with `verbalunitid` set to its own id (the same convention 353 `assign_verbal_units()` relies on). For each anchor, this function 354 finds its *parent* anchor -- the verbal expression it's subordinate to 355 -- by following the anchor's own relatedtoken1 (falling back to 356 relatedtoken2), through as many intermediate non-anchor tokens as 357 necessary, until it lands on another anchor. This one chase handles 358 every documented case uniformly, without needing to special-case by 359 relationship label, because they all eventually resolve to another 360 anchor via forward pointers already in the graph: 361 362 - unit verb (independent): relatedtoken1 == 'root' -> no parent, depth 0. 363 - unit verb (dependent): relatedtoken1 -> a subordinating conjunction or 364 relative/interrogative pronoun (not itself an anchor) -> ITS 365 relatedtoken1 -> the superior verb (a conjunction) or an antecedent 366 noun (a relative pronoun), the latter requiring one more hop through 367 the noun's own relation to reach the verb it depends on. Example: 368 "ἐπειδὴ δὲ ἦν πρὸς ἡμέραν, ἧκεν ἐκείνη" -- ἦν's relatedtoken1 is 369 ἐπειδὴ, whose own relatedtoken1 is ἧκεν. 370 - direct quote / aside / indirect statement: relatedtoken1 -> the verb 371 of the clause it interrupts, is framed by, or (for an indirect- 372 statement infinitive or participle) governs it, directly (no 373 intermediate token). Examples: πειρᾷς -> ἔφη (direct quote); δεῖ -> 374 ἔστι (aside); ἀποσβεσθῆναι -> ἔφασκε (indirect statement, an 375 infinitive); φεύγουσαν -> εἶδε (indirect statement, a participle 376 after a verb of perception). 377 - circumstantial participle: relatedtoken1 -> the noun/pronoun it 378 agrees with (not itself an anchor) -> that noun's own relation, 379 either its normal role in the surrounding clause (one more hop to a 380 verb, e.g. παραλείπων/λέγων -> ἐγώ -> ἐπιδείξω via "subject") or, for 381 a true genitive absolute, 'genitive absolute' pointing directly at 382 the main verb (e.g. προϊόντος -> χρόνου -> ἧκον). 383 - attributive participle: structurally identical to the circumstantial 384 case above, just with a different relationship label and no 385 "absolute" variant -- relatedtoken1 -> the noun/pronoun it agrees 386 with (not itself an anchor) -> that noun's own ordinary relation, 387 one more hop to the governing verb. Example: "ὁ γὰρ ἀνὴρ ὁ ὑβρίζων 388 εἰς σὲ ... τυγχάνει" -- ὑβρίζων's relatedtoken1 is ἀνήρ, whose own 389 relatedtoken1 (relationship1 "subject") is τυγχάνει. This is the one 390 construction with no Latin precedent at all (Latin's scheme never 391 treats an attributive participle as a verbal expression), but it 392 needs no new code here: the same generic hop-through-the-noun chase 393 that already handles circumstantial participles handles it too. 394 395 Returns `({anchor id: depth or None}, warnings)`. A depth of `None` 396 means the chase from that anchor never reached another anchor (a 397 malformed or genuinely disconnected verbal expression) or a cycle was 398 detected; `warnings` names which anchor(s) and why, mirroring 399 `tokengraph_to_mermaid()`'s warnings-list convention rather than 400 raising. 401 """ 402 by_id = {tok.id: tok for tok in tokengraph} 403 anchor_ids = {tok.id for tok in tokengraph if tok.verbalunitid == tok.id} 404 405 warnings: List[str] = [] 406 407 def chase(token_id: str, visited: set) -> Optional[str]: 408 """Follow relatedtoken1 (then relatedtoken2) forward from 409 `token_id`, returning the first anchor id reached, or None if the 410 chain dead-ends or cycles before reaching one. `token_id` itself 411 counts as a hit if it's already an anchor (the direct-link cases: 412 direct quote, aside, indirect statement).""" 413 if token_id in visited: 414 return None 415 visited.add(token_id) 416 if token_id in anchor_ids: 417 return token_id 418 tok = by_id.get(token_id) 419 if tok is None: 420 return None 421 for field in ("relatedtoken1", "relatedtoken2"): 422 target = getattr(tok, field) 423 if target is None or target == "root": 424 continue 425 result = chase(target, visited) 426 if result is not None: 427 return result 428 return None 429 430 def parent_of(anchor_id: str) -> Optional[str]: 431 tok = by_id[anchor_id] 432 for field in ("relatedtoken1", "relatedtoken2"): 433 target = getattr(tok, field) 434 if target is None or target == "root": 435 continue 436 result = chase(target, visited=set()) 437 if result is not None and result != anchor_id: 438 return result 439 return None 440 441 depths: Dict[str, Optional[int]] = {} 442 in_progress: set = set() 443 444 def depth_of(anchor_id: str) -> Optional[int]: 445 if anchor_id in depths: 446 return depths[anchor_id] 447 tok = by_id[anchor_id] 448 if tok.relatedtoken1 == "root": 449 depths[anchor_id] = 0 450 return 0 451 452 if anchor_id in in_progress: 453 warnings.append( 454 f"cycle detected resolving the governing verbal expression " 455 f"for {anchor_id!r} -- leaving its depth (and its parent's) " 456 f"unresolved" 457 ) 458 return None 459 in_progress.add(anchor_id) 460 461 parent = parent_of(anchor_id) 462 if parent is None: 463 warnings.append( 464 f"could not find a governing verbal expression for " 465 f"{anchor_id!r} -- leaving its depth unresolved" 466 ) 467 result = None 468 else: 469 parent_depth = depth_of(parent) 470 result = None if parent_depth is None else parent_depth + 1 471 472 in_progress.discard(anchor_id) 473 depths[anchor_id] = result 474 return result 475 476 for anchor_id in anchor_ids: 477 depth_of(anchor_id) 478 479 return depths, warnings
Compute each verbal expression's depth of subordination: the number of verbal expressions it is removed from an independent ("root") clause. An independent verb is depth 0; a verb it introduces (a dependent clause, a direct quote, an aside) is depth 1; a verbal expression THAT verb in turn introduces (e.g. an indirect statement inside a dependent clause) is depth 2; and so on.
A "verbal expression" here is any token that anchors one -- i.e. any
token with verbalunitid set to its own id (the same convention
assign_verbal_units() relies on). For each anchor, this function
finds its parent anchor -- the verbal expression it's subordinate to
-- by following the anchor's own relatedtoken1 (falling back to
relatedtoken2), through as many intermediate non-anchor tokens as
necessary, until it lands on another anchor. This one chase handles
every documented case uniformly, without needing to special-case by
relationship label, because they all eventually resolve to another
anchor via forward pointers already in the graph:
- unit verb (independent): relatedtoken1 == 'root' -> no parent, depth 0.
- unit verb (dependent): relatedtoken1 -> a subordinating conjunction or relative/interrogative pronoun (not itself an anchor) -> ITS relatedtoken1 -> the superior verb (a conjunction) or an antecedent noun (a relative pronoun), the latter requiring one more hop through the noun's own relation to reach the verb it depends on. Example: "ἐπειδὴ δὲ ἦν πρὸς ἡμέραν, ἧκεν ἐκείνη" -- ἦν's relatedtoken1 is ἐπειδὴ, whose own relatedtoken1 is ἧκεν.
- direct quote / aside / indirect statement: relatedtoken1 -> the verb of the clause it interrupts, is framed by, or (for an indirect- statement infinitive or participle) governs it, directly (no intermediate token). Examples: πειρᾷς -> ἔφη (direct quote); δεῖ -> ἔστι (aside); ἀποσβεσθῆναι -> ἔφασκε (indirect statement, an infinitive); φεύγουσαν -> εἶδε (indirect statement, a participle after a verb of perception).
- circumstantial participle: relatedtoken1 -> the noun/pronoun it agrees with (not itself an anchor) -> that noun's own relation, either its normal role in the surrounding clause (one more hop to a verb, e.g. παραλείπων/λέγων -> ἐγώ -> ἐπιδείξω via "subject") or, for a true genitive absolute, 'genitive absolute' pointing directly at the main verb (e.g. προϊόντος -> χρόνου -> ἧκον).
- attributive participle: structurally identical to the circumstantial case above, just with a different relationship label and no "absolute" variant -- relatedtoken1 -> the noun/pronoun it agrees with (not itself an anchor) -> that noun's own ordinary relation, one more hop to the governing verb. Example: "ὁ γὰρ ἀνὴρ ὁ ὑβρίζων εἰς σὲ ... τυγχάνει" -- ὑβρίζων's relatedtoken1 is ἀνήρ, whose own relatedtoken1 (relationship1 "subject") is τυγχάνει. This is the one construction with no Latin precedent at all (Latin's scheme never treats an attributive participle as a verbal expression), but it needs no new code here: the same generic hop-through-the-noun chase that already handles circumstantial participles handles it too.
Returns ({anchor id: depth or None}, warnings). A depth of None
means the chase from that anchor never reached another anchor (a
malformed or genuinely disconnected verbal expression) or a cycle was
detected; warnings names which anchor(s) and why, mirroring
tokengraph_to_mermaid()'s warnings-list convention rather than
raising.
482def max_subordination_depth( 483 tokengraph: List[TokenAnalysis], 484 depths: Optional[Dict[str, Optional[int]]] = None, 485) -> Optional[int]: 486 """Return the deepest level of subordination reached anywhere in 487 `tokengraph` -- the highest value `compute_subordination_depths()` 488 assigns to any verbal expression. Root/independent clauses are depth 489 0, so this is also the upper end of the valid `depth` range for 490 `rendering.tokengraph_to_depth_html()`'s own `depth` parameter (whose 491 valid range is 0, root clauses only, through this function's return 492 value, everything). 493 494 Pass `depths` (the first element of `compute_subordination_depths()`'s 495 return value) if the caller already computed it, to avoid re-deriving 496 it here; otherwise it's computed internally (any resolution warnings 497 are silently dropped in that case -- call 498 `compute_subordination_depths()` directly first if the caller also 499 needs those). 500 501 Returns `None` if `tokengraph` has no verbal expressions at all (an 502 empty passage, or one with none of the five constructions 503 syntax_model.md counts as one), or if every anchor's own depth came 504 back unresolved (see `compute_subordination_depths()`'s own 505 warnings for why an anchor might be unresolved -- a relation cycle, or 506 a governing verbal expression that couldn't be found). Otherwise 507 returns the maximum of every RESOLVED anchor's depth, ignoring 508 unresolved ones rather than letting a single bad anchor blank out the 509 whole result. 510 """ 511 if depths is None: 512 depths, _warnings = compute_subordination_depths(tokengraph) 513 514 resolved = [d for d in depths.values() if d is not None] 515 if not resolved: 516 return None 517 return max(resolved)
Return the deepest level of subordination reached anywhere in
tokengraph -- the highest value compute_subordination_depths()
assigns to any verbal expression. Root/independent clauses are depth
0, so this is also the upper end of the valid depth range for
rendering.tokengraph_to_depth_html()'s own depth parameter (whose
valid range is 0, root clauses only, through this function's return
value, everything).
Pass depths (the first element of compute_subordination_depths()'s
return value) if the caller already computed it, to avoid re-deriving
it here; otherwise it's computed internally (any resolution warnings
are silently dropped in that case -- call
compute_subordination_depths() directly first if the caller also
needs those).
Returns None if tokengraph has no verbal expressions at all (an
empty passage, or one with none of the five constructions
syntax_model.md counts as one), or if every anchor's own depth came
back unresolved (see compute_subordination_depths()'s own
warnings for why an anchor might be unresolved -- a relation cycle, or
a governing verbal expression that couldn't be found). Otherwise
returns the maximum of every RESOLVED anchor's depth, ignoring
unresolved ones rather than letting a single bad anchor blank out the
whole result.
520def find_unanchored_coordinated_verbs(tokengraph: List[TokenAnalysis]) -> List[str]: 521 """Heuristic sanity check, adapted from arsgrammatica's original, for a 522 class of live-LM mistake: a "connecting word" correctly marks a series 523 of coordinate CLAUSES, but one of the later clauses' own verb never 524 gets flagged as its own verbal-unit anchor (no `verbalunitid` set) -- 525 e.g. an explicit verb the LM forgot to add to `verbalunits`, or a 526 missing 'implied repetition' token for an elided one. 527 528 This check USED to need a coarse, imprecise proxy (does some OTHER 529 verbal expression merely exist at the same subordination depth as the 530 series' first member?), because an earlier version of syntax_model.md's 531 "connecting word" relation recorded only ONE id anywhere in the graph 532 -- "the id of the first item" in the series -- never the id of the 533 item the connecting word itself was attached to, so there was nothing 534 to name the second (or later) member directly and compare against. 535 (Latin's own "coordinating conjunction", by contrast, always named 536 both conjuncts, one on relatedtoken1 and one on relatedtoken2 of the 537 same token -- see gold_examples.py's 538 coordinating_conjunction_dedit_et_dixit_esse fixture for the original, 539 Latin-side observation this function is adapted from.) 540 541 syntax_model.md's current "connecting word" rules (its "other uses of 542 connecting words" section) removed that limitation: every connecting 543 word now names ITS OWN adjacent item on relation1 (never generically 544 "the first item"), and relation2 completes the pair or series, in one 545 of three shapes -- see SyntaxAnalysis's own docstring for the full 546 account with worked examples: 547 - a single connecting word joining a pair: relation1 = the first 548 item's id, relation2 = the second item's id, both directly on the 549 SAME token. 550 - a paired correlative (τε...καί, a repeated καὶ...καί, or a 551 within-sentence μέν...δέ pair): each connector's own relation1 = 552 its own adjacent item, relation2 = the id of the OTHER connector. 553 - a 3+-member series (e.g. οὔτε...οὔτε...οὔτε): each connector's own 554 relation1 = its own adjacent item again; relation2 chains the 555 connectors together instead of naming an item -- the first 556 connector's relation2 points forward to the second connector, and 557 every later connector's relation2 points backward to the one 558 immediately before it. 559 560 This function exploits that added precision. It first groups every 561 "connecting word" token into its own coordinate chain: a lone token by 562 itself for the single-pair shape, or several tokens linked together 563 (by relation2 pointing from one connecting word to another) for a 564 correlative pair or series. For each chain, it collects every member's 565 own coordinated ITEM -- relation1 always, plus relation2 whenever that 566 ISN'T itself another connecting word in the same chain (i.e. the 567 direct second-item pointer the single-pair shape uses). If ANY item in 568 a chain turns out to be a recognized verbal-unit anchor, the whole 569 chain is coordinating CLAUSES (not nouns/adjectives/adverbs), so every 570 OTHER item in that chain is expected to be an anchor too -- one that 571 isn't gets named directly in a warning, the same precision Latin's 572 original dual-linked check had. A lone connecting word whose relation1 573 already points at an anchor but that has no relation2 at all (and no 574 correlative partner pointing back at it either) is flagged separately: 575 it looks like one half of a pair whose other half was never recorded. 576 577 "sentence connector" is deliberately excluded from this check, exactly 578 as before: syntax_model.md defines it as pointing at "the verb of THIS 579 sentence" -- the sentence it introduces, not a cross-clause pairing 580 with whatever precedes it (a sentence connector never records a 581 cross-sentence link) -- so it never asserts "there are two coordinate 582 members" the way "connecting word" does, and there is nothing here for 583 it to be checked against. 584 585 Returns a list of warning strings (empty if nothing looks suspicious), 586 the same "degrade visibly, don't raise" convention every other 587 warnings-returning function in this codebase uses. This is a 588 heuristic, not a guarantee: a clean result here isn't a substitute for 589 validate() or a human read of the analysis, and a flagged result 590 deserves a look rather than an automatic "fix." 591 """ 592 by_id = {tok.id: tok for tok in tokengraph} 593 anchor_ids = {tok.id for tok in tokengraph if tok.verbalunitid == tok.id} 594 connecting_word_ids = { 595 tok.id for tok in tokengraph if tok.relationship1 == _CONNECTING_WORD 596 } 597 598 # Union-find over connecting-word tokens: two are in the same 599 # coordinate chain if either's relatedtoken2 names the other -- the 600 # correlative-pair/series-chaining shape described above. A lone 601 # connecting word (the single-pair shape) ends up its own singleton 602 # chain. 603 parent: Dict[str, str] = {cid: cid for cid in connecting_word_ids} 604 605 def find(x: str) -> str: 606 while parent[x] != x: 607 parent[x] = parent[parent[x]] 608 x = parent[x] 609 return x 610 611 def union(a: str, b: str) -> None: 612 ra, rb = find(a), find(b) 613 if ra != rb: 614 parent[ra] = rb 615 616 for cid in connecting_word_ids: 617 partner = by_id[cid].relatedtoken2 618 if partner in connecting_word_ids: 619 union(cid, partner) 620 621 chains: Dict[str, List[str]] = {} 622 for cid in connecting_word_ids: 623 chains.setdefault(find(cid), []).append(cid) 624 625 warnings: List[str] = [] 626 for members in sorted(chains.values(), key=lambda m: sorted(m)): 627 items: List[Tuple[str, str]] = [] # (connecting word id, item id) 628 for cid in sorted(members): 629 tok = by_id[cid] 630 for target in (tok.relatedtoken1, tok.relatedtoken2): 631 if target is None or target == "root" or target in connecting_word_ids: 632 continue 633 items.append((cid, target)) 634 635 if not any(target in anchor_ids for _cid, target in items): 636 continue # not a clause-coordinating chain -- nouns/adjectives/etc. 637 638 for cid, target in items: 639 if target in anchor_ids: 640 continue 641 item_tok = by_id.get(target) 642 text = item_tok.token if item_tok is not None else target 643 connector_tok = by_id[cid] 644 warnings.append( 645 f"{cid} ({connector_tok.token!r}) is a 'connecting word' " 646 "coordinating clauses (another member of its chain points " 647 "at a recognized verbal-unit anchor), but its own item " 648 f"{target!r} ({text!r}) is not itself anchored -- it may " 649 "be missing its own verbalunitid (an explicit verb that " 650 "wasn't flagged, or a missing 'implied repetition' token " 651 "if the verb was elided)." 652 ) 653 654 if len(members) == 1: 655 only_cid = members[0] 656 tok = by_id[only_cid] 657 if tok.relatedtoken1 in anchor_ids and tok.relatedtoken2 is None: 658 warnings.append( 659 f"{only_cid} ({tok.token!r}) is a 'connecting word' " 660 "whose relation1 points at a recognized verbal-unit " 661 "anchor, but it has no relation2 and no correlative " 662 "partner -- it looks like one half of a coordinated " 663 "pair whose other member was never recorded." 664 ) 665 666 return warnings
Heuristic sanity check, adapted from arsgrammatica's original, for a
class of live-LM mistake: a "connecting word" correctly marks a series
of coordinate CLAUSES, but one of the later clauses' own verb never
gets flagged as its own verbal-unit anchor (no verbalunitid set) --
e.g. an explicit verb the LM forgot to add to verbalunits, or a
missing 'implied repetition' token for an elided one.
This check USED to need a coarse, imprecise proxy (does some OTHER verbal expression merely exist at the same subordination depth as the series' first member?), because an earlier version of syntax_model.md's "connecting word" relation recorded only ONE id anywhere in the graph -- "the id of the first item" in the series -- never the id of the item the connecting word itself was attached to, so there was nothing to name the second (or later) member directly and compare against. (Latin's own "coordinating conjunction", by contrast, always named both conjuncts, one on relatedtoken1 and one on relatedtoken2 of the same token -- see gold_examples.py's coordinating_conjunction_dedit_et_dixit_esse fixture for the original, Latin-side observation this function is adapted from.)
syntax_model.md's current "connecting word" rules (its "other uses of connecting words" section) removed that limitation: every connecting word now names ITS OWN adjacent item on relation1 (never generically "the first item"), and relation2 completes the pair or series, in one of three shapes -- see SyntaxAnalysis's own docstring for the full account with worked examples:
- a single connecting word joining a pair: relation1 = the first item's id, relation2 = the second item's id, both directly on the SAME token.
- a paired correlative (τε...καί, a repeated καὶ...καί, or a within-sentence μέν...δέ pair): each connector's own relation1 = its own adjacent item, relation2 = the id of the OTHER connector.
- a 3+-member series (e.g. οὔτε...οὔτε...οὔτε): each connector's own relation1 = its own adjacent item again; relation2 chains the connectors together instead of naming an item -- the first connector's relation2 points forward to the second connector, and every later connector's relation2 points backward to the one immediately before it.
This function exploits that added precision. It first groups every "connecting word" token into its own coordinate chain: a lone token by itself for the single-pair shape, or several tokens linked together (by relation2 pointing from one connecting word to another) for a correlative pair or series. For each chain, it collects every member's own coordinated ITEM -- relation1 always, plus relation2 whenever that ISN'T itself another connecting word in the same chain (i.e. the direct second-item pointer the single-pair shape uses). If ANY item in a chain turns out to be a recognized verbal-unit anchor, the whole chain is coordinating CLAUSES (not nouns/adjectives/adverbs), so every OTHER item in that chain is expected to be an anchor too -- one that isn't gets named directly in a warning, the same precision Latin's original dual-linked check had. A lone connecting word whose relation1 already points at an anchor but that has no relation2 at all (and no correlative partner pointing back at it either) is flagged separately: it looks like one half of a pair whose other half was never recorded.
"sentence connector" is deliberately excluded from this check, exactly as before: syntax_model.md defines it as pointing at "the verb of THIS sentence" -- the sentence it introduces, not a cross-clause pairing with whatever precedes it (a sentence connector never records a cross-sentence link) -- so it never asserts "there are two coordinate members" the way "connecting word" does, and there is nothing here for it to be checked against.
Returns a list of warning strings (empty if nothing looks suspicious), the same "degrade visibly, don't raise" convention every other warnings-returning function in this codebase uses. This is a heuristic, not a guarantee: a clean result here isn't a substitute for validate() or a human read of the analysis, and a flagged result deserves a look rather than an automatic "fix."
276def tokengraph_to_dot( 277 tokengraph: List[TokenAnalysis], 278 orientation: str = "BT", 279 color_by_verbal_unit: bool = True, 280 rank_by_depth: bool = True, 281 depth: Optional[int] = None, 282) -> Tuple[str, List[str]]: 283 """Build a Graphviz DOT `digraph` from a tokengraph -- the same diagram 284 tokengraph_to_mermaid() draws (same nodes, same edges, same coloring), 285 as DOT source instead of Mermaid source. See this module's own 286 docstring for why this exists alongside tokengraph_to_mermaid(), the 287 two adaptations from arsgrammatica's own dot.py, and what actually 288 rendering the result requires. 289 290 `orientation` maps directly onto DOT's `rankdir` graph attribute -- `BT` 291 (bottom-to-top, the default here, matching tokengraph_to_mermaid()'s 292 own default), `TB`, `LR`, or `RL`. Not validated here, same as 293 tokengraph_to_mermaid()'s `orientation` -- a typo just becomes an 294 attribute value Graphviz itself will reject. 295 296 `color_by_verbal_unit` (default True) colors every node by the verbal 297 unit it belongs to, per verbal_units.assign_verbal_units() -- the exact 298 same colors (and the same >8-verbal-units warning) as 299 tokengraph_to_mermaid(), just written as `fillcolor`/`color`/ 300 `fontcolor` attributes directly on each node line instead of Mermaid's 301 separate `classDef`/`class` statements (DOT has no equivalent of a 302 named, reusable class -- inline per-node attributes are the idiomatic 303 way to do this). An implied/elided token (IMPLIED_TOKENTYPES) always 304 gets its own dedicated amber (verbal_units._IMPLIED_TOKEN_COLOR) 305 instead of whatever color its own verbal unit would otherwise get, and 306 a token whose relationship1 is "sentence connector" always gets its own 307 dedicated neon-yellow/strong-border color (_SENTENCE_CONNECTOR_COLOR), 308 both exactly mirroring tokengraph_to_mermaid()'s own precedence (see 309 that function's docstring). Pass False for a plain, uncolored diagram. 310 311 `rank_by_depth` (default True) is the reason this module exists 312 alongside tokengraph_to_mermaid() -- see the module docstring. Every 313 verbal-unit anchor node (any token with `verbalunitid` set to its own 314 id, implied tokens included) at the same depth in 315 verbal_units.compute_subordination_depths() -- grammatike's own 316 substitute for arsgrammatica's compute_aat_depths(), see "Two 317 adaptations" in this module's own docstring -- gets listed together in 318 one `{rank=same; id1; id2; ...}` subgraph statement, which *forces* 319 Graphviz's layout engine to place them on the same rank -- not a nudge, 320 a hard constraint. A depth with only one anchor gets no `rank=same` 321 statement (nothing to align it WITH); an anchor whose subordination 322 depth came back unresolved (None -- a relation cycle, or no governing 323 verbal expression found; see compute_subordination_depths()'s own 324 docstring) is excluded from every grouping, and its own warning about 325 why is folded into this function's returned warnings. Pass False to 326 skip this and let Graphviz's own layout heuristics place every node. 327 328 `depth`, if given, caps the diagram to nodes at or within that many 329 edges of a root/independent verbal-unit anchor -- compute_graph_depths() 330 above, a plain GRAPH distance along the same relatedtoken1/ 331 relatedtoken2 edges drawn as `->` lines below, NOT 332 verbal_units.compute_subordination_depths() (`rank_by_depth` above, and 333 the CLAUSE-level notion behind tokengraph_to_depth_html()'s own 334 indented-HTML `depth` slider -- a whole clause's subject, object, and 335 other ordinary dependents share ONE subordination depth with their 336 verb, but each is its own hop of GRAPH depth). `depth=0` shows ONLY 337 root anchors -- an independent verb with no dependents at all; 338 `depth=1` adds every token one edge away from a root anchor (its 339 subject, object, adverbials, ...); and so on. A token farther than 340 `depth` is dropped entirely: omitted as a node, exactly as if it had 341 never been in `tokengraph`. Omit `depth` (or pass `None`, the default) 342 to show every node, same as before this parameter existed. A `depth` at 343 or beyond max_graph_depth()'s own return value for this `tokengraph` 344 shows everything too; a negative `depth` raises ValueError. 345 346 Dropping a node can leave a KEPT node's edge pointing at a now-excluded 347 one. Such an edge is skipped, with the same combined warning already 348 used for an edge targeting punctuation or a genuinely absent id (see 349 Returns below) -- `depth` filtering degrades visibly rather than 350 emitting a dangling `->` line Graphviz would reject. 351 352 Returns `(dot_source, warnings)` -- same shape and same warnings as 353 tokengraph_to_mermaid(): an edge skipped because it targets a 354 punctuation token, a token excluded by the `depth` cutoff, or an id not 355 present in `tokengraph` (except the 'root' sentinel, skipped silently, 356 same as there); if `color_by_verbal_unit` is True and the passage has 357 more than 8 verbal units, one warning that colors are repeating. 358 `depth` filtering itself never adds a warning (compute_graph_depths() 359 has no unresolved state -- an unrelated or cyclic token just defaults 360 to depth 0), but `rank_by_depth` CAN add one -- see above, and the "Two 361 adaptations" section of this module's own docstring for why this 362 differs from arsgrammatica's own version. 363 """ 364 if depth is not None and depth < 0: 365 raise ValueError(f"depth must be >= 0 (root nodes only), got {depth!r}") 366 367 node_ids = {tok.id for tok in tokengraph if tok.tokentype != "punctuation"} 368 369 warnings: List[str] = [] 370 if depth is not None: 371 graph_depths = compute_graph_depths(tokengraph) 372 depth_excluded_ids = {tok_id for tok_id, d in graph_depths.items() if d > depth} 373 node_ids -= depth_excluded_ids 374 375 colors_by_unit: Dict[str, Tuple[str, str, str]] = {} 376 assignment: Dict[str, Optional[str]] = {} 377 implied_ids: set = set() 378 connector_ids: set = set() 379 if color_by_verbal_unit: 380 assignment = assign_verbal_units(tokengraph) 381 colors_by_unit, color_warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment) 382 warnings.extend(color_warnings) 383 implied_ids = { 384 tok.id 385 for tok in tokengraph 386 if tok.id in node_ids and tok.tokentype in IMPLIED_TOKENTYPES 387 } 388 # See this module's own docstring, "Two adaptations" (2) -- 389 # grammatike-specific, no arsgrammatica counterpart. Excluded from 390 # implied_ids (in the never-really-expected case a token is somehow 391 # both) so the two colors never compete for the same node, matching 392 # mermaid.py's own connector_ids precedent exactly. 393 connector_ids = { 394 tok.id 395 for tok in tokengraph 396 if tok.id in node_ids 397 and tok.id not in implied_ids 398 and tok.relationship1 == "sentence connector" 399 } 400 401 lines = ["digraph tokengraph {", f" rankdir={orientation};", " node [shape=box];", ""] 402 for tok in tokengraph: 403 if tok.id not in node_ids: 404 continue 405 color = None 406 strong_border = False 407 if color_by_verbal_unit: 408 if tok.id in implied_ids: 409 color = _IMPLIED_TOKEN_COLOR 410 elif tok.id in connector_ids: 411 color = _SENTENCE_CONNECTOR_COLOR 412 strong_border = True 413 else: 414 unit_id = assignment.get(tok.id) 415 color = colors_by_unit.get(unit_id) if unit_id is not None else None 416 lines.append(f" {tok.id} [{_node_attrs(tok, color, strong_border)}];") 417 418 lines.append("") 419 for tok in tokengraph: 420 if tok.id not in node_ids: 421 continue 422 for related_field, label_field in ( 423 ("relatedtoken1", "relationship1"), 424 ("relatedtoken2", "relationship2"), 425 ): 426 related_id = getattr(tok, related_field) 427 label = getattr(tok, label_field) 428 if related_id is None or label is None: 429 continue 430 if related_id == "root": 431 # An independent verb's own unit-verb relation, per 432 # syntax_model.md -- intentionally not a real node, so not 433 # a warning-worthy gap. Just draw no edge for it. 434 continue 435 if related_id not in node_ids: 436 warnings.append( 437 f"skipped edge {tok.id} -[{label}]-> {related_id}: " 438 f"target is punctuation, excluded by the depth cutoff, " 439 f"or not in tokengraph" 440 ) 441 continue 442 lines.append(f' {tok.id} -> {related_id} [label="{_escape_label(label)}"];') 443 444 if rank_by_depth: 445 sub_depths, depth_warnings = compute_subordination_depths(tokengraph) 446 warnings.extend(depth_warnings) 447 448 # Same grouping tokengraph_to_mermaid() would build for a `~~~` 449 # chain if it had one -- see this module's own docstring, "Two 450 # adaptations" (1), for why sub_depths.get() being None here means 451 # "unresolved" (a cycle, or no governing verbal expression found), 452 # unlike arsgrammatica's own compute_aat_depths(), which has no such 453 # state. Named sub_depths (not `depths`, and this loop's own 454 # variable not `depth`) to avoid shadowing the `depth` PARAMETER 455 # above -- a different depth notion entirely, see this function's 456 # own docstring. 457 depth_groups: dict = {} 458 for tok in tokengraph: 459 if tok.id not in node_ids: 460 continue 461 anchor_depth = sub_depths.get(tok.id) 462 if anchor_depth is None: 463 continue 464 depth_groups.setdefault(anchor_depth, []).append(tok.id) 465 466 rank_lines = [ 467 " {rank=same; " + "; ".join(ids) + ";}" 468 for anchor_depth in sorted(depth_groups) 469 for ids in (depth_groups[anchor_depth],) 470 if len(ids) > 1 471 ] 472 if rank_lines: 473 lines.append("") 474 lines.extend(rank_lines) 475 476 lines.append("}") 477 return "\n".join(lines), warnings
Build a Graphviz DOT digraph from a tokengraph -- the same diagram
tokengraph_to_mermaid() draws (same nodes, same edges, same coloring),
as DOT source instead of Mermaid source. See this module's own
docstring for why this exists alongside tokengraph_to_mermaid(), the
two adaptations from arsgrammatica's own dot.py, and what actually
rendering the result requires.
orientation maps directly onto DOT's rankdir graph attribute -- BT
(bottom-to-top, the default here, matching tokengraph_to_mermaid()'s
own default), TB, LR, or RL. Not validated here, same as
tokengraph_to_mermaid()'s orientation -- a typo just becomes an
attribute value Graphviz itself will reject.
color_by_verbal_unit (default True) colors every node by the verbal
unit it belongs to, per verbal_units.assign_verbal_units() -- the exact
same colors (and the same >8-verbal-units warning) as
tokengraph_to_mermaid(), just written as fillcolor/color/
fontcolor attributes directly on each node line instead of Mermaid's
separate classDef/class statements (DOT has no equivalent of a
named, reusable class -- inline per-node attributes are the idiomatic
way to do this). An implied/elided token (IMPLIED_TOKENTYPES) always
gets its own dedicated amber (verbal_units._IMPLIED_TOKEN_COLOR)
instead of whatever color its own verbal unit would otherwise get, and
a token whose relationship1 is "sentence connector" always gets its own
dedicated neon-yellow/strong-border color (_SENTENCE_CONNECTOR_COLOR),
both exactly mirroring tokengraph_to_mermaid()'s own precedence (see
that function's docstring). Pass False for a plain, uncolored diagram.
rank_by_depth (default True) is the reason this module exists
alongside tokengraph_to_mermaid() -- see the module docstring. Every
verbal-unit anchor node (any token with verbalunitid set to its own
id, implied tokens included) at the same depth in
verbal_units.compute_subordination_depths() -- grammatike's own
substitute for arsgrammatica's compute_aat_depths(), see "Two
adaptations" in this module's own docstring -- gets listed together in
one {rank=same; id1; id2; ...} subgraph statement, which forces
Graphviz's layout engine to place them on the same rank -- not a nudge,
a hard constraint. A depth with only one anchor gets no rank=same
statement (nothing to align it WITH); an anchor whose subordination
depth came back unresolved (None -- a relation cycle, or no governing
verbal expression found; see compute_subordination_depths()'s own
docstring) is excluded from every grouping, and its own warning about
why is folded into this function's returned warnings. Pass False to
skip this and let Graphviz's own layout heuristics place every node.
depth, if given, caps the diagram to nodes at or within that many
edges of a root/independent verbal-unit anchor -- compute_graph_depths()
above, a plain GRAPH distance along the same relatedtoken1/
relatedtoken2 edges drawn as -> lines below, NOT
verbal_units.compute_subordination_depths() (rank_by_depth above, and
the CLAUSE-level notion behind tokengraph_to_depth_html()'s own
indented-HTML depth slider -- a whole clause's subject, object, and
other ordinary dependents share ONE subordination depth with their
verb, but each is its own hop of GRAPH depth). depth=0 shows ONLY
root anchors -- an independent verb with no dependents at all;
depth=1 adds every token one edge away from a root anchor (its
subject, object, adverbials, ...); and so on. A token farther than
depth is dropped entirely: omitted as a node, exactly as if it had
never been in tokengraph. Omit depth (or pass None, the default)
to show every node, same as before this parameter existed. A depth at
or beyond max_graph_depth()'s own return value for this tokengraph
shows everything too; a negative depth raises ValueError.
Dropping a node can leave a KEPT node's edge pointing at a now-excluded
one. Such an edge is skipped, with the same combined warning already
used for an edge targeting punctuation or a genuinely absent id (see
Returns below) -- depth filtering degrades visibly rather than
emitting a dangling -> line Graphviz would reject.
Returns (dot_source, warnings) -- same shape and same warnings as
tokengraph_to_mermaid(): an edge skipped because it targets a
punctuation token, a token excluded by the depth cutoff, or an id not
present in tokengraph (except the 'root' sentinel, skipped silently,
same as there); if color_by_verbal_unit is True and the passage has
more than 8 verbal units, one warning that colors are repeating.
depth filtering itself never adds a warning (compute_graph_depths()
has no unresolved state -- an unrelated or cyclic token just defaults
to depth 0), but rank_by_depth CAN add one -- see above, and the "Two
adaptations" section of this module's own docstring for why this
differs from arsgrammatica's own version.
480def save_dot( 481 tokengraph: List[TokenAnalysis], 482 path: str, 483 orientation: str = "BT", 484 color_by_verbal_unit: bool = True, 485 rank_by_depth: bool = True, 486 depth: Optional[int] = None, 487) -> List[str]: 488 """Write the diagram to `path` (e.g. 'analysis.dot') and return any 489 warnings from tokengraph_to_dot().""" 490 diagram, warnings = tokengraph_to_dot( 491 tokengraph, 492 orientation=orientation, 493 color_by_verbal_unit=color_by_verbal_unit, 494 rank_by_depth=rank_by_depth, 495 depth=depth, 496 ) 497 with open(path, "w", encoding="utf-8") as f: 498 f.write(diagram + "\n") 499 return warnings
Write the diagram to path (e.g. 'analysis.dot') and return any
warnings from tokengraph_to_dot().
178def compute_graph_depths(tokengraph: List[TokenAnalysis]) -> Dict[str, int]: 179 """Each non-punctuation token's *graph depth*: the number of edges 180 separating it from the nearest root/independent verbal-unit anchor, 181 following the exact same relatedtoken1/relatedtoken2 edges 182 tokengraph_to_dot() itself draws as `->` lines (dependent -> governor) 183 -- the depth notion behind tokengraph_to_dot()'s own `depth` parameter. 184 See this module's own docstring for how this differs from 185 verbal_units.compute_subordination_depths() (rendering. 186 tokengraph_to_depth_html()'s clause-level notion, also what 187 `rank_by_depth` uses here -- see "Two adaptations" above). 188 189 A root anchor (relatedtoken1 == 'root') is depth 0. Every other token's 190 depth is one more than its PARENT's -- relatedtoken1, falling back to 191 relatedtoken2 only when relatedtoken1 itself doesn't resolve to a 192 usable parent (None, or an id not in `tokengraph`) -- the SAME 193 "relatedtoken1, fall back to relatedtoken2" preference 194 verbal_units.compute_subordination_depths() already uses to chase a 195 verbal expression's own governor. This matters for a token that plays 196 two roles at once, most notably a relative pronoun: e.g. syntax_model.md's 197 ὅν pointing at its antecedent ἀνήρ via relatedtoken1 ('relative 198 pronoun') AND at the dependent verb it's ALSO the direct object of via 199 relatedtoken2 ('direct object') -- that second edge points forward, 200 toward a token that in turn points back at the pronoun itself (its own 201 'unit verb' relation), a genuine two-way link the data model allows. 202 Taking the shallower of BOTH edges (rather than preferring 203 relatedtoken1) would let that forward edge "cheat" the pronoun's own 204 depth down to whatever the dependent verb's -- itself only computable 205 FROM the pronoun -- happens to resolve to first, collapsing what should 206 be a deeper chain. Only ever falling back to relatedtoken2, never 207 averaging or taking a minimum over both, avoids that: relatedtoken1 208 alone already resolves to the antecedent here, so relatedtoken2 is 209 simply never consulted for depth (it's still drawn as its own edge 210 below, same as always -- this only affects which relation DEPTH 211 follows). 212 213 A token whose relatedtoken1 AND any fallback relatedtoken2 both fail to 214 resolve (neither set, or pointing at ids not in `tokengraph`), or which 215 is caught in a relation cycle even after preferring relatedtoken1, 216 defaults to depth 0 -- the same "can't determine, default to root 217 level" fallback verbal_units.compute_subordination_depths() and 218 rendering.tokengraph_to_depth_html() both use for their own unresolved 219 cases, rather than raising. 220 221 Returns `{token id: depth}`, one entry per non-punctuation token in 222 `tokengraph` (punctuation is never part of the diagram, so never 223 included here either). 224 """ 225 by_id = {tok.id: tok for tok in tokengraph} 226 depths: Dict[str, int] = {} 227 in_progress: set = set() 228 229 def depth_of(tok_id: str) -> int: 230 if tok_id in depths: 231 return depths[tok_id] 232 tok = by_id[tok_id] 233 if tok.relatedtoken1 == "root": 234 depths[tok_id] = 0 235 return 0 236 237 if tok_id in in_progress: 238 # A relation cycle -- fall back to 0 rather than recursing 239 # forever; NOT cached, so a non-cyclic call further up the 240 # stack still computes (and caches) this token's real depth if 241 # some other path reaches it. 242 return 0 243 in_progress.add(tok_id) 244 245 parent_id = None 246 if tok.relatedtoken1 is not None and tok.relatedtoken1 != "root" and tok.relatedtoken1 in by_id: 247 parent_id = tok.relatedtoken1 248 elif tok.relatedtoken2 is not None and tok.relatedtoken2 in by_id: 249 parent_id = tok.relatedtoken2 250 251 result = 1 + depth_of(parent_id) if parent_id is not None else 0 252 253 in_progress.discard(tok_id) 254 depths[tok_id] = result 255 return result 256 257 for tok in tokengraph: 258 if tok.tokentype == "punctuation": 259 continue 260 depth_of(tok.id) 261 262 return depths
Each non-punctuation token's graph depth: the number of edges
separating it from the nearest root/independent verbal-unit anchor,
following the exact same relatedtoken1/relatedtoken2 edges
tokengraph_to_dot() itself draws as -> lines (dependent -> governor)
-- the depth notion behind tokengraph_to_dot()'s own depth parameter.
See this module's own docstring for how this differs from
verbal_units.compute_subordination_depths() (rendering.
tokengraph_to_depth_html()'s clause-level notion, also what
rank_by_depth uses here -- see "Two adaptations" above).
A root anchor (relatedtoken1 == 'root') is depth 0. Every other token's
depth is one more than its PARENT's -- relatedtoken1, falling back to
relatedtoken2 only when relatedtoken1 itself doesn't resolve to a
usable parent (None, or an id not in tokengraph) -- the SAME
"relatedtoken1, fall back to relatedtoken2" preference
verbal_units.compute_subordination_depths() already uses to chase a
verbal expression's own governor. This matters for a token that plays
two roles at once, most notably a relative pronoun: e.g. syntax_model.md's
ὅν pointing at its antecedent ἀνήρ via relatedtoken1 ('relative
pronoun') AND at the dependent verb it's ALSO the direct object of via
relatedtoken2 ('direct object') -- that second edge points forward,
toward a token that in turn points back at the pronoun itself (its own
'unit verb' relation), a genuine two-way link the data model allows.
Taking the shallower of BOTH edges (rather than preferring
relatedtoken1) would let that forward edge "cheat" the pronoun's own
depth down to whatever the dependent verb's -- itself only computable
FROM the pronoun -- happens to resolve to first, collapsing what should
be a deeper chain. Only ever falling back to relatedtoken2, never
averaging or taking a minimum over both, avoids that: relatedtoken1
alone already resolves to the antecedent here, so relatedtoken2 is
simply never consulted for depth (it's still drawn as its own edge
below, same as always -- this only affects which relation DEPTH
follows).
A token whose relatedtoken1 AND any fallback relatedtoken2 both fail to
resolve (neither set, or pointing at ids not in tokengraph), or which
is caught in a relation cycle even after preferring relatedtoken1,
defaults to depth 0 -- the same "can't determine, default to root
level" fallback verbal_units.compute_subordination_depths() and
rendering.tokengraph_to_depth_html() both use for their own unresolved
cases, rather than raising.
Returns {token id: depth}, one entry per non-punctuation token in
tokengraph (punctuation is never part of the diagram, so never
included here either).
265def max_graph_depth(tokengraph: List[TokenAnalysis]) -> Optional[int]: 266 """The highest value compute_graph_depths() assigns to any token in 267 `tokengraph` -- the upper end of the meaningful range for 268 tokengraph_to_dot()'s own `depth` parameter, the same role 269 verbal_units.max_subordination_depth() plays for 270 tokengraph_to_depth_html()'s unrelated depth notion. Returns None for 271 an empty tokengraph, or one with only punctuation.""" 272 depths = compute_graph_depths(tokengraph) 273 return max(depths.values()) if depths else None
The highest value compute_graph_depths() assigns to any token in
tokengraph -- the upper end of the meaningful range for
tokengraph_to_dot()'s own depth parameter, the same role
verbal_units.max_subordination_depth() plays for
tokengraph_to_depth_html()'s unrelated depth notion. Returns None for
an empty tokengraph, or one with only punctuation.
179def tokengraph_to_text(tokengraph: List[TokenAnalysis]) -> str: 180 """Join `tokengraph`'s tokens into one continuous plain-text string, 181 per this module's docstring. Tokens are read in list order (the same 182 order tokengraph_to_mermaid() and validate() assume).""" 183 quote_counts: Dict[str, int] = {} 184 pieces: List[str] = [] 185 previous_class = None 186 187 for tok in tokengraph: 188 if tok.tokentype in IMPLIED_TOKENTYPES: 189 # An implied/elided token (models.py's IMPLIED_TOKENTYPES) has 190 # no surface realization at all -- skip it entirely, exactly as 191 # if it weren't in the list, rather than trying to render 192 # `None`. previous_class is deliberately left untouched, so the 193 # next real token's spacing is decided as if this one weren't 194 # here. 195 continue 196 cls = _classify(tok, quote_counts) 197 text = tok.token 198 199 if not pieces: 200 # Nothing precedes the first token -- never prepend a space, 201 # regardless of this token's own classification. 202 pieces.append(text) 203 elif cls in (_LEFT, _ENCLITIC): 204 pieces.append(text) 205 elif cls == _RIGHT: 206 pieces.append(" " + text) 207 else: # _NORMAL 208 if previous_class == _RIGHT: 209 pieces.append(text) 210 else: 211 pieces.append(" " + text) 212 213 previous_class = cls 214 215 return "".join(pieces)
Join tokengraph's tokens into one continuous plain-text string,
per this module's docstring. Tokens are read in list order (the same
order tokengraph_to_mermaid() and validate() assume).
218def tokengraph_to_html(tokengraph: List[TokenAnalysis]) -> str: 219 """Render `tokengraph` as an HTML string: the same continuous text 220 `tokengraph_to_text()` produces -- identical spacing rules, and the same 221 punctuation/enclitic/quote-pair handling -- except every **lexical** 222 token, every **numeral** token, and every **connecting word** (any 223 token with relationship1 or relationship2 in `_CONNECTING_RELATIONS` 224 -- "connecting word" or "sentence connector" -- lexical or not), has 225 its text wrapped in a `<span style="...">` colored by the verbal unit 226 it belongs to. Colors come from `verbal_units.assign_verbal_units()` / 227 `assign_verbal_unit_colors()` -- the same assignment and the same 228 first-appearance palette ordering `tokengraph_to_mermaid()` uses for its 229 node coloring -- so a passage rendered here and the same passage's 230 Mermaid diagram color each verbal unit identically. 231 232 The connecting-word carve-out exists because a connecting particle like 233 "μέν", "δέ", or "γάρ" might be tokenized as tokentype "enclitic" rather 234 than "lexical" -- see `_CONNECTING_RELATIONS`'s own comment for the 235 open question there -- but `assign_verbal_units()` still resolves it to 236 one of the verbal units it coordinates or connects (see that module's 237 docstring): e.g. in "ἐγὼ μὲν ἄνω διῃτώμην, αἱ δὲ γυναῖκες κάτω", μέν and 238 δέ each resolve to the verbal unit of the clause they introduce, same 239 as ἐγὼ and γυναῖκες do. Leaving such a token unwrapped would visually 240 hide that assignment even though it's a real one, unlike the other 241 non-lexical, non-numeral tokentypes below. A subordinating conjunction 242 (e.g. "ἐπειδή", "ὡς") or a relative pronoun (e.g. "ὅν") doesn't need 243 this carve-out: both are always tokentype "lexical" (full words, never 244 enclitic), so they're already wrapped. 245 246 The numeral carve-out is for a related reason: syntax_model.md's 247 tokenization section restricts `tokentype`="numeral" to a number 248 written NUMERICALLY (e.g. in Milesian notation) -- a number spelled out 249 as an ordinary word (e.g. "δύω") is "lexical" instead -- but a numeral 250 is otherwise an ordinary participant in the clause, able to carry 251 whatever ordinary noun/case relation the RelationLabel set documents 252 (e.g. "attributive", "accusative"), the same relation a spelled-out 253 number would use in the same position. `assign_verbal_units()` resolves 254 that relation exactly like any other, so a numeral belonging to a 255 verbal unit is wrapped the same way a lexical token would be -- unlike 256 punctuation or a non-connecting enclitic, neither of which carries that 257 kind of ordinary syntactic relation under the current scheme. 258 259 Every other non-lexical, non-numeral token -- punctuation and a 260 non-connecting enclitic (e.g. the intensifying "γε" in "ἵνα σύ γε" ἔφη 261 "πειρᾷς...") -- is still emitted as plain (escaped) text even though 262 `assign_verbal_units()` assigns every token, including punctuation, to 263 whichever unit its relations resolve to; this function just doesn't 264 turn that assignment into a span for anything else. A lexical, numeral, 265 or connecting-word token belonging to no verbal unit (assignment is 266 `None`, e.g. a bare vocative left otherwise unrelated) is left 267 unwrapped too, as is one whose unit happens to have no non-punctuation 268 member at all and so never got a color slot from 269 `assign_verbal_unit_colors()` (should not occur in practice for a 270 lexical or numeral token, since it's always a non-punctuation member of 271 its own unit, but handled defensively rather than assumed). 272 273 A token whose relationship1 is specifically "sentence connector" (e.g. 274 γάρ tying this sentence back to the previous one) is a special case of 275 the connecting-word carve-out above: instead of the ordinary verbal-unit 276 color, it always gets `_SENTENCE_CONNECTOR_STYLE`'s neon-yellow 277 background and strong black border, regardless of which unit it's 278 assigned to -- the same override tokengraph_to_mermaid() applies to its 279 own `sentenceconnector` node class. 280 281 An **implied/elided token** (models.py's IMPLIED_TOKENTYPES: "implied 282 eimi", "implied repetition") is omitted entirely -- same as 283 tokengraph_to_text() -- rather than rendered with any span: it has no 284 surface text (`tok.token` is always `None`), and unlike 285 `tokengraph_to_mermaid()`'s diagram (which DOES show these, as their 286 own specially-colored, specially-labeled node -- see that module's own 287 docstring), inserting placeholder text into the middle of reconstructed 288 prose here would misrepresent what the passage actually says. The 289 Mermaid diagram is the one place an implied token's presence is worth 290 seeing at all. 291 292 Every token's text is HTML-escaped (`&`, `<`, `>`, and quote characters) 293 before being emitted, spans or not -- real Greek text can contain a 294 literal `"` or `'` (see the quote-pair handling below, and this 295 module's TODO on elision apostrophes), which would otherwise be 296 indistinguishable from markup to anything that re-parses this output. 297 298 The span's inline style sets both `background-color` (the verbal unit's 299 palette `fill`, the same value used as a Mermaid node's `fill`) and 300 `color` (the palette's `text` value, currently black for every slot) -- 301 the latter so the token reads correctly regardless of whatever text 302 color the surrounding page has set, matching the explicit black 303 `color:` every Mermaid node in that unit also gets. 304 """ 305 assignment = assign_verbal_units(tokengraph) 306 colors, _warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment) 307 return _tokens_to_html(tokengraph, assignment, colors)
Render tokengraph as an HTML string: the same continuous text
tokengraph_to_text() produces -- identical spacing rules, and the same
punctuation/enclitic/quote-pair handling -- except every lexical
token, every numeral token, and every connecting word (any
token with relationship1 or relationship2 in _CONNECTING_RELATIONS
-- "connecting word" or "sentence connector" -- lexical or not), has
its text wrapped in a <span style="..."> colored by the verbal unit
it belongs to. Colors come from verbal_units.assign_verbal_units() /
assign_verbal_unit_colors() -- the same assignment and the same
first-appearance palette ordering tokengraph_to_mermaid() uses for its
node coloring -- so a passage rendered here and the same passage's
Mermaid diagram color each verbal unit identically.
The connecting-word carve-out exists because a connecting particle like
"μέν", "δέ", or "γάρ" might be tokenized as tokentype "enclitic" rather
than "lexical" -- see _CONNECTING_RELATIONS's own comment for the
open question there -- but assign_verbal_units() still resolves it to
one of the verbal units it coordinates or connects (see that module's
docstring): e.g. in "ἐγὼ μὲν ἄνω διῃτώμην, αἱ δὲ γυναῖκες κάτω", μέν and
δέ each resolve to the verbal unit of the clause they introduce, same
as ἐγὼ and γυναῖκες do. Leaving such a token unwrapped would visually
hide that assignment even though it's a real one, unlike the other
non-lexical, non-numeral tokentypes below. A subordinating conjunction
(e.g. "ἐπειδή", "ὡς") or a relative pronoun (e.g. "ὅν") doesn't need
this carve-out: both are always tokentype "lexical" (full words, never
enclitic), so they're already wrapped.
The numeral carve-out is for a related reason: syntax_model.md's
tokenization section restricts tokentype="numeral" to a number
written NUMERICALLY (e.g. in Milesian notation) -- a number spelled out
as an ordinary word (e.g. "δύω") is "lexical" instead -- but a numeral
is otherwise an ordinary participant in the clause, able to carry
whatever ordinary noun/case relation the RelationLabel set documents
(e.g. "attributive", "accusative"), the same relation a spelled-out
number would use in the same position. assign_verbal_units() resolves
that relation exactly like any other, so a numeral belonging to a
verbal unit is wrapped the same way a lexical token would be -- unlike
punctuation or a non-connecting enclitic, neither of which carries that
kind of ordinary syntactic relation under the current scheme.
Every other non-lexical, non-numeral token -- punctuation and a
non-connecting enclitic (e.g. the intensifying "γε" in "ἵνα σύ γε" ἔφη
"πειρᾷς...") -- is still emitted as plain (escaped) text even though
assign_verbal_units() assigns every token, including punctuation, to
whichever unit its relations resolve to; this function just doesn't
turn that assignment into a span for anything else. A lexical, numeral,
or connecting-word token belonging to no verbal unit (assignment is
None, e.g. a bare vocative left otherwise unrelated) is left
unwrapped too, as is one whose unit happens to have no non-punctuation
member at all and so never got a color slot from
assign_verbal_unit_colors() (should not occur in practice for a
lexical or numeral token, since it's always a non-punctuation member of
its own unit, but handled defensively rather than assumed).
A token whose relationship1 is specifically "sentence connector" (e.g.
γάρ tying this sentence back to the previous one) is a special case of
the connecting-word carve-out above: instead of the ordinary verbal-unit
color, it always gets _SENTENCE_CONNECTOR_STYLE's neon-yellow
background and strong black border, regardless of which unit it's
assigned to -- the same override tokengraph_to_mermaid() applies to its
own sentenceconnector node class.
An implied/elided token (models.py's IMPLIED_TOKENTYPES: "implied
eimi", "implied repetition") is omitted entirely -- same as
tokengraph_to_text() -- rather than rendered with any span: it has no
surface text (tok.token is always None), and unlike
tokengraph_to_mermaid()'s diagram (which DOES show these, as their
own specially-colored, specially-labeled node -- see that module's own
docstring), inserting placeholder text into the middle of reconstructed
prose here would misrepresent what the passage actually says. The
Mermaid diagram is the one place an implied token's presence is worth
seeing at all.
Every token's text is HTML-escaped (&, <, >, and quote characters)
before being emitted, spans or not -- real Greek text can contain a
literal " or ' (see the quote-pair handling below, and this
module's TODO on elision apostrophes), which would otherwise be
indistinguishable from markup to anything that re-parses this output.
The span's inline style sets both background-color (the verbal unit's
palette fill, the same value used as a Mermaid node's fill) and
color (the palette's text value, currently black for every slot) --
the latter so the token reads correctly regardless of whatever text
color the surrounding page has set, matching the explicit black
color: every Mermaid node in that unit also gets.
395def tokengraph_to_depth_html( 396 tokengraph: List[TokenAnalysis], 397 indent_em: float = _DEFAULT_DEPTH_INDENT_EM, 398 depth: Optional[int] = None, 399) -> Tuple[str, List[str]]: 400 """Render `tokengraph` as HTML illustrating each verbal expression's 401 *depth of subordination* (see verbal_units.compute_subordination_ 402 depths()): tokens are assembled sequentially exactly as 403 tokengraph_to_html() does -- same spacing, escaping, and verbal-unit 404 color highlighting -- but grouped into consecutive-run "blocks" by 405 which verbal unit each token belongs to (per assign_verbal_units()), 406 each rendered as its own <div> indented by a CSS margin-left of 407 `depth * indent_em` em -- 0 for an independent clause, 1 for a clause 408 it introduces (a dependent clause, a direct quote, an aside, a 409 circumstantial participle/genitive absolute, or an indirect statement 410 anchored to an infinitive or participle), 2 for a verbal expression 411 THAT one in turn introduces, and so on. All layout is CSS (margin-left/ 412 margin-bottom on each block's <div>) -- no table or nested-list 413 structure is used to produce the indentation. 414 415 `depth`, if given, caps how deep the rendering goes: ONLY blocks whose 416 own depth of subordination is <= `depth` are included in the output -- 417 a block deeper than that is dropped entirely, not rendered empty or 418 grayed out. `depth=0` shows root/independent clauses only (and direct 419 quotes, asides, and any other depth-0 construction); omit `depth` (or 420 pass `None`, the default) to show every block, same as before this 421 parameter existed. Valid values run from 0 up to 422 verbal_units.max_subordination_depth()'s own return value for this 423 `tokengraph` (that function exists specifically to help a caller pick 424 a sensible value here); a negative `depth` raises ValueError, since 425 there's no clause shallower than root. A `depth` larger than the 426 passage's actual maximum is accepted, not an error -- it just means 427 "show everything," identical to leaving `depth` unset. 428 429 Block boundaries follow assign_verbal_units()'s token-to-unit 430 assignment, with one adjustment: an **enclitic** token never starts a 431 new block, even when its own assignment differs from the block 432 currently open (see tests/test_rendering.py's connecting-word 433 word-order-mismatch case for exactly this -- an enclitic connecting 434 word can resolve to a DIFFERENT verbal unit than the word it's 435 orthographically glued to, e.g. an enclitic clitic pair, and starting a 436 new block there would split one Greek word across two <div>s). A token 437 with no verbal-unit assignment at all (None -- typically punctuation, 438 or a token syntax_model.md doesn't document a relation for) likewise 439 never starts a new block; it folds into whichever block is currently 440 open, so a stray comma or postpositive particle doesn't fragment the 441 layout. Leading tokens before the first resolvable verbal-unit token 442 (rare) default to depth 0. 443 444 Note that a circumstantial-participle/genitive-absolute noun (e.g. 445 "χρόνου" in "προϊόντος δὲ τοῦ χρόνου ἧκον...", or "ἐγώ" in "ἐγὼ ἅπαντα 446 ἐπιδείξω..., οὐδὲν παραλείπων") resolves, per assign_verbal_units()'s 447 own established convention, to whatever unit ITS OWN relation reaches 448 -- typically the outer clause -- while the participle itself is its 449 own singleton unit; this means a circumstantial-participle phrase 450 renders as the noun staying in the outer clause's block and the bare 451 participle as its own one-word indented block, rather than the whole 452 phrase indenting together. That follows directly from the noun's own 453 documented relation (it fits into the surrounding clause as subject, 454 object, etc., or points at the main verb as a genitive absolute) and 455 isn't specific to this function. 456 457 A verbal expression whose depth couldn't be resolved (see 458 compute_subordination_depths()) renders at depth 0 rather than 459 raising, with a warning explaining why -- the same "degrade visibly, 460 don't crash" convention tokengraph_to_mermaid() uses. 461 462 Returns (html, warnings), combining assign_verbal_unit_colors()'s 463 warnings (colors repeating past 8 verbal units) and 464 compute_subordination_depths()'s (an unresolved governing verbal 465 expression) -- computed the same way, and returned in full, regardless 466 of whether `depth` filters some blocks out of the rendered `html` 467 itself. 468 """ 469 if depth is not None and depth < 0: 470 raise ValueError(f"depth must be >= 0 (root clauses only), got {depth!r}") 471 472 assignment = assign_verbal_units(tokengraph) 473 colors, color_warnings = assign_verbal_unit_colors(tokengraph, assignment=assignment) 474 depths, depth_warnings = compute_subordination_depths(tokengraph) 475 warnings = color_warnings + depth_warnings 476 477 blocks = [] 478 for tok in tokengraph: 479 unit_id = assignment.get(tok.id) 480 starts_new_block = ( 481 unit_id is not None 482 and tok.tokentype != "enclitic" 483 and (not blocks or blocks[-1][0] != unit_id) 484 ) 485 if starts_new_block: 486 blocks.append((unit_id, [])) 487 elif not blocks: 488 # Leading token(s) with no verbal-unit assignment yet (or a 489 # leading enclitic, in principle) -- open a placeholder block 490 # rather than crashing on an empty blocks list below. 491 blocks.append((None, [])) 492 blocks[-1][1].append(tok) 493 494 lines = [] 495 for unit_id, block_tokens in blocks: 496 block_depth = depths.get(unit_id) if unit_id is not None else 0 497 if block_depth is None: 498 block_depth = 0 499 if depth is not None and block_depth > depth: 500 # This whole block is deeper than the requested cutoff -- 501 # drop it entirely rather than rendering an empty/grayed-out 502 # placeholder for it. 503 continue 504 block_html = _tokens_to_html(block_tokens, assignment, colors) 505 margin_left = block_depth * indent_em 506 lines.append( 507 f'<div style="margin-left: {margin_left}em; margin-bottom: 0.35em;">' 508 f"{block_html}</div>" 509 ) 510 511 return "\n".join(lines), warnings
Render tokengraph as HTML illustrating each verbal expression's
depth of subordination (see verbal_units.compute_subordination_
depths()): tokens are assembled sequentially exactly as
tokengraph_to_html() does -- same spacing, escaping, and verbal-unit
color highlighting -- but grouped into consecutive-run "blocks" by
which verbal unit each token belongs to (per assign_verbal_units()),
each rendered as its own
depth * indent_em em -- 0 for an independent clause, 1 for a clause
it introduces (a dependent clause, a direct quote, an aside, a
circumstantial participle/genitive absolute, or an indirect statement
anchored to an infinitive or participle), 2 for a verbal expression
THAT one in turn introduces, and so on. All layout is CSS (margin-left/
margin-bottom on each block's depth, if given, caps how deep the rendering goes: ONLY blocks whose
own depth of subordination is <= depth are included in the output --
a block deeper than that is dropped entirely, not rendered empty or
grayed out. depth=0 shows root/independent clauses only (and direct
quotes, asides, and any other depth-0 construction); omit depth (or
pass None, the default) to show every block, same as before this
parameter existed. Valid values run from 0 up to
verbal_units.max_subordination_depth()'s own return value for this
tokengraph (that function exists specifically to help a caller pick
a sensible value here); a negative depth raises ValueError, since
there's no clause shallower than root. A depth larger than the
passage's actual maximum is accepted, not an error -- it just means
"show everything," identical to leaving depth unset.
Block boundaries follow assign_verbal_units()'s token-to-unit assignment, with one adjustment: an enclitic token never starts a new block, even when its own assignment differs from the block currently open (see tests/test_rendering.py's connecting-word word-order-mismatch case for exactly this -- an enclitic connecting word can resolve to a DIFFERENT verbal unit than the word it's orthographically glued to, e.g. an enclitic clitic pair, and starting a new block there would split one Greek word across two
Note that a circumstantial-participle/genitive-absolute noun (e.g. "χρόνου" in "προϊόντος δὲ τοῦ χρόνου ἧκον...", or "ἐγώ" in "ἐγὼ ἅπαντα ἐπιδείξω..., οὐδὲν παραλείπων") resolves, per assign_verbal_units()'s own established convention, to whatever unit ITS OWN relation reaches -- typically the outer clause -- while the participle itself is its own singleton unit; this means a circumstantial-participle phrase renders as the noun staying in the outer clause's block and the bare participle as its own one-word indented block, rather than the whole phrase indenting together. That follows directly from the noun's own documented relation (it fits into the surrounding clause as subject, object, etc., or points at the main verb as a genitive absolute) and isn't specific to this function.
A verbal expression whose depth couldn't be resolved (see compute_subordination_depths()) renders at depth 0 rather than raising, with a warning explaining why -- the same "degrade visibly, don't crash" convention tokengraph_to_mermaid() uses.
Returns (html, warnings), combining assign_verbal_unit_colors()'s
warnings (colors repeating past 8 verbal units) and
compute_subordination_depths()'s (an unresolved governing verbal
expression) -- computed the same way, and returned in full, regardless
of whether depth filters some blocks out of the rendered html
itself.
98class SyntaxAnalysis(dspy.Signature): 99 """Analyze the syntax of a passage of Ancient Greek according to a 100 two-part scheme: 101 102 (1) a list of verbal expressions. Three constructions count as a 103 verbal expression: finite verbs, infinitives, and participles -- 104 but for participles, only some of them (see below). 105 106 - A finite verb (including a compound perfect-system form made of 107 a participle plus a conjugated form of εἰμί, e.g. ὁ νόμος 108 γεγραμμένος ἐστίν) is always a verbal expression. Classify its 109 syntactic type as 'independent' (main/principal), 'dependent' 110 (subordinate, introduced by a subordinating word), 'direct 111 quote' (occurring in directly quoted speech framed by another 112 verb, e.g. νόμιζε in '"εὐφίλητε" ἔφη "μηδεμιᾷ πολυπραγμοσύνῃ 113 προσεληλυθέναι με νόμιζε πρὸς σέ."'), or 'aside' (a verbal 114 expression that interrupts the surrounding syntax, e.g. δεῖ in 115 'πρῶτον μὲν οὖν, ὦ ἄνδρες, (δεῖ γὰρ καὶ ταῦθ᾽ ὑμῖν διηγήσασθαι) 116 οἰκίδιον ἔστι μοι διπλοῦν' interrupting the independent verbal 117 expression ἔστι). Example of independent vs. dependent: in 118 'ἐπειδὴ δὲ ἦν πρὸς ἡμέραν, ἧκεν ἐκείνη', ἧκεν is 'independent' 119 and ἦν is 'dependent' (introduced by the subordinating 120 conjunction ἐπειδή). 121 - An infinitive is a verbal expression only when part of an 122 indirect statement; its syntactic type is always 'indirect 123 statement'. Example: in 'ἔφασκε τὸν λύχνον ἀποσβεσθῆναι', ἔφασκε 124 is independent and ἀποσβεσθῆναι anchors the indirect-statement 125 verbal expression. In a compound perfect-system form (participle 126 + a conjugated form of εἰμί), the form of εἰμί anchors the 127 verbal expression, same as any other compound form. 128 - A participle constitutes a verbal expression in THREE cases, 129 each with its own dedicated syntactic_type -- unlike Latin, 130 which uses a single 'dependent' value for every predicate-sense 131 participle, Greek's scheme gives each its own name: 132 - 'indirect statement': a participle expressing indirect 133 speech after a verb of perception or thinking. Example: in 134 'εἶδε δὲ τὴν βασίλειαν φεύγουσαν', εἶδε is independent and 135 φεύγουσαν (not an infinitive here) anchors the 136 indirect-statement verbal expression. 137 - 'attributive': a participle in attributive position. 138 Example: in 'ὁ γὰρ ἀνὴρ ὁ ὑβρίζων εἰς σὲ ἐχθρὸς ὢν ἡμῖν 139 τυγχάνει', the repeated article puts ὑβρίζων in attributive 140 position with ἀνήρ, so ὑβρίζων anchors an 'attributive' 141 verbal expression. In Greek, UNLIKE Latin, every attributive 142 participle counts as its own verbal expression -- there is 143 no purely-adjectival, non-verbal-expression reading for an 144 attributive participle the way Latin's "consentiens laus" 145 was not a verbal expression at all. 146 - 'circumstantial': a participle in circumstantial position 147 (including one forming a genitive absolute). Example: in 148 'χρόνου μεταξὺ διαγενομένου, προσέρχεταί μοί τις πρεσβῦτις 149 ἄνθρωπος', προσέρχεταί is independent and διαγενομένου 150 anchors a 'circumstantial' verbal expression. 151 By contrast, a *supplementary* participle -- one that completes 152 the sense of its governing verb as a single predicate idea 153 (e.g. with τυγχάνω, λανθάνω, φαίνομαι, παύομαι, or the like), 154 rather than standing attributively with a noun or 155 circumstantially/adverbially to the clause -- is explicitly NOT 156 a verbal expression and gets no `verbalunits` entry at all. 157 Example: in that same sentence 'ὁ γὰρ ἀνὴρ ὁ ὑβρίζων εἰς σὲ 158 ἐχθρὸς ὢν ἡμῖν τυγχάνει', ὤν supplements τυγχάνει (there is a 159 single independent verbal expression, anchored to τυγχάνει) and 160 is NOT its own verbal expression. Do not over-generate 161 verbal-expression entries for participles: check first whether a 162 participle is genuinely attributive (repeated article, or 163 agreeing with a noun as its ordinary modifier), genuinely 164 circumstantial (an adverbial predication about a noun, loosely 165 attached to the clause), or genuinely reporting indirect 166 perception -- and only then give it a `verbalunits` entry; a 167 participle that instead completes one predicate idea together 168 with a governing verb like τυγχάνω does not. 169 # TODO: syntax_model.md does not name a RelationLabel for a 170 # supplementary participle's own relation to its governing verb 171 # (no "supplementary" or "complementary participle" value 172 # exists). This port leaves such a participle's own 173 # relatedtoken1/relationship1 unset -- no documented label 174 # fits -- while still letting it take its own predicate/ 175 # object/adverbial complements exactly as a linking verb would 176 # (e.g. ἐχθρός, the predicate adjective of ὤν in the example 177 # above, still relates to ὤν with relationship1 'predicate', 178 # exactly as it would to a finite linking verb). 179 180 Classify each verbal expression's semantic type too (transitive 181 active / transitive passive / intransitive / linking verb). 182 Examples: προσεῖχον in 'προσεῖχον τὸν νοῦν' is transitive active; 183 διαφθείρεται in 'ἡ ἐμὴ γυνὴ ὑπὸ τούτου τοῦ ἀνθρώπου διαφθείρεται' 184 is transitive passive; εἰσῄει in 'πάντα μου εἰς τὴν γνώμην 185 εἰσῄει' is intransitive; ἦ in 'μεστὸς ἦ ὑποψίας' is a linking 186 verb. 187 188 (2) a token-by-token dependency graph. For each token, record up to two 189 relations to other tokens (by id), using only these relation 190 labels: 191 192 - unit verb (independent): every INDEPENDENT verb has relatedtoken1 193 = the special sentinel string 'root' -- never an actual token id; 194 no real token may be assigned the id 'root' -- and relationship1 195 = 'unit verb'. Example: in 'τὴν θύραν ἀνέῳξεν', ἀνέῳξεν has 196 relatedtoken1 'root', relationship1 'unit verb'. 197 - unit verb (dependent) / subordinating conjunction / relative 198 pronoun: the verb of a DEPENDENT clause has relatedtoken1 -> the 199 id of its subordinating conjunction or relative/interrogative 200 pronoun, relationship1 = 'unit verb'. That conjunction or pronoun 201 in turn has relatedtoken1 -> the id of the verb of the clause it 202 is subordinate to, with relationship1 = 'subordinating 203 conjunction' for a conjunction, or relatedtoken1 -> its 204 antecedent's id with relationship1 = 'relative pronoun' for a 205 relative pronoun. Example: in 'ἐπειδὴ δὲ ἦν πρὸς ἡμέραν, ἧκεν 206 ἐκείνη', ἐπειδή has relatedtoken1 -> ἧκεν, relationship1 207 'subordinating conjunction', and ἦν has relatedtoken1 -> 208 ἐπειδή, relationship1 'unit verb'. Another example, with a 209 conjunction: in 'κατηγόρει ὡς μετὰ τὴν ἐκφορὰν αὐτῇ προσίοι', 210 ὡς has relatedtoken1 -> κατηγόρει, relationship1 'subordinating 211 conjunction', and προσίοι has relatedtoken1 -> ὡς, relationship1 212 'unit verb'. Indirect questions are treated as a kind of 213 dependent clause: an interrogative pronoun introducing one is 214 treated the same way as a subordinating conjunction -- it has 215 relatedtoken1 -> the id of the verb it introduces, relationship1 216 = 'subordinating conjunction' (no separate label for this case) 217 -- while the dependent verb itself has relatedtoken1 -> the 218 interrogative word's id, relationship1 = 'unit verb', exactly 219 like any other dependent clause. 220 # TODO: syntax_model.md states this indirect-question rule only 221 # in passing ("a subordinating conjunction or a relative or 222 # interrogative pronoun") with no worked example; the following 223 # is constructed by analogy, not quoted from syntax_model.md: 224 # in 'οὐκ οἶδα τίς ἦλθεν' ("I don't know who came"), τίς has 225 # relatedtoken1 -> οἶδα, relationship1 'subordinating 226 # conjunction', and ἦλθεν has relatedtoken1 -> τίς, relationship1 227 # 'unit verb'. 228 - relative pronoun (second relation): a relative pronoun ALSO 229 relates to its own function inside the relative clause, using 230 relatedtoken2/relationship2 (since relatedtoken1/relationship1 is 231 already used for the antecedent link) -- the ordinary relation 232 it would have if it were any other noun/pronoun in that clause 233 (e.g. 'direct object', 'subject', a case relation, etc). Example: 234 in 'οὐκ ἐγώ σε ἀποκτενῶ, ἀλλ᾽ ὁ τῆς πόλεως νόμος, ὃν σὺ περὶ 235 ἐλάττονος τῶν ἡδονῶν ἐποιήσω', ὅν has relatedtoken1 -> νόμος 236 (its antecedent), relationship1 'relative pronoun', AND 237 relatedtoken2 -> ἐποιήσω, relationship2 'direct object'. 238 - indirect statement (governing verb): an infinitive OR participle 239 anchoring an indirect-statement verbal expression ALSO has 240 relatedtoken1 -> the id of the verb that governs the indirect 241 statement (the verb of saying/thinking/perceiving it depends 242 on), relationship1 = 'indirect statement' -- matching its own 243 syntactic type, the same convention 'direct quote' and 'aside' 244 verbal expressions use below. There's no separate 245 subordinating-word token to point at first, so the infinitive or 246 participle points directly at its governing verb, rather than 247 via a conjunction/pronoun intermediary the way a dependent 248 finite verb's 'unit verb' relation does. Examples: in 249 'ἔφασκε τὸν λύχνον ἀποσβεσθῆναι', ἀποσβεσθῆναι has relatedtoken1 250 -> ἔφασκε, relationship1 'indirect statement'; in 'εἶδε δὲ τὴν 251 βασίλειαν φεύγουσαν', φεύγουσαν has relatedtoken1 -> εἶδε, 252 relationship1 'indirect statement'. In a compound perfect-system 253 form, this relation belongs on the conjugated form of εἰμί that 254 anchors the verbal expression, same as any other relation into 255 it. 256 - complementary infinitive: an infinitive that completes the sense 257 of a governing verb like βούλομαι, δεῖ, or ἐθέλω (rather than 258 reporting indirect speech) has relatedtoken1 -> the id of that 259 governing verb, relationship1 = 'complementary infinitive'. 260 Unlike an indirect-statement infinitive, this does NOT make the 261 infinitive its own verbal expression -- it gets no `verbalunits` 262 entry of its own; the governing verb is still the only verbal 263 expression here. Example: in 'ἔξεστι ἑλέσθαι', ἑλέσθαι has 264 relatedtoken1 -> ἔξεστι, relationship1 'complementary 265 infinitive'. 266 - modal particle: the particle ἄν has relatedtoken1 -> the id of 267 the verb of ITS OWN verbal unit (not some other unit's verb), 268 relationship1 = 'modal particle'. Example: in 'εἰ τὴν αὐτὴν 269 γνώμην περὶ τῶν ἄλλων ἔχοιτε, οὐκ ἂν εἴη, ὅστις οὐκ ἐπὶ τοῖς 270 γεγενημένοις ἀγανακτοίη' (two dependent verbal expressions plus 271 one independent verbal expression anchored to εἴη), ἂν has 272 relatedtoken1 -> εἴη, relationship1 'modal particle' -- εἴη is 273 ἂν's own verbal unit's verb, the same verb οὐκ (adverbial) also 274 relates to. 275 - infinitive used as a noun: an infinitive can also function as an 276 ordinary noun -- most often a verb's subject or object -- rather 277 than anchoring an indirect statement or completing another verb. 278 Treat it exactly like any other noun in that role: relatedtoken1 279 -> the verb it's the subject/object of, relationship1 = 280 'subject' or 'direct object' as appropriate (no dedicated label, 281 and again no `verbalunits` entry of its own). If the infinitive 282 carries a definite article (an articular infinitive, e.g. τὸ 283 ζῆν), that article relates to the infinitive exactly as it would 284 to a substantivized adjective or adverb: relatedtoken1 -> the 285 infinitive's id, relationship1 'article'. Like any verbal form, 286 an infinitive used this way can still take its own object or 287 adverb, related to it the same way they'd relate to a finite 288 verb. 289 # TODO: syntax_model.md does not discuss this construction at 290 # all; it is carried over from arsgrammatica's equivalent 291 # section by direct analogy, since substantival infinitives 292 # (often articular) are common in Greek too. No example here is 293 # quoted from syntax_model.md. 294 - sentence connector: true asyndeton is rare at the root level of 295 a sentence -- there is normally a connecting word expressing the 296 relation of the sentence to its predecessor. This connecting 297 word has relatedtoken1 -> the verb of THIS sentence (not the 298 previous one), relationship1 = 'sentence connector'. Example: in 299 'ταύτην γὰρ ἐμαυτῷ μόνην ἡγοῦμαι σωτηρίαν', γάρ has relatedtoken1 300 -> ἡγοῦμαι, relationship1 'sentence connector'. The particle μέν 301 begins a list of items, continued by δέ -- ordinarily WITHIN one 302 sentence (see 'connecting word' below), but when the items are 303 instead split across distinct, separately terminated sentences, 304 μέν or δέ is a 'sentence connector' too, exactly like γάρ, rather 305 than a 'connecting word': it has relatedtoken1 -> the verb of 306 ITS OWN sentence, relationship1 'sentence connector', with no 307 relation at all to the other sentence's verb (a sentence 308 connector never records a cross-sentence link -- only "this 309 sentence's own verb"). Examples: in the complete, terminated 310 sentence 'περὶ μὲν οὖν τοῦ μεγέθους τῆς ζημίας ἅπαντας ὑμᾶς 311 νομίζω τὴν αὐτὴν διάνοιαν ἔχειν, καὶ οὐδένα οὕτως ὀλιγώρως 312 διακεῖσθαι, ὅστις οἴεται δεῖν συγγνώμης τυγχάνειν ἢ μικρᾶς 313 ζημίας ἀξίους ἡγεῖται τοὺς τῶν τοιούτων ἔργων αἰτίους.', μέν has 314 relatedtoken1 -> νομίζω, relationship1 'sentence connector'; in 315 the following, separate sentence 'ἡγοῦμαι δέ, ὦ ἄνδρες, τοῦτό με 316 δεῖν ἐπιδεῖξαι.', δέ has relatedtoken1 -> ἡγοῦμαι, relationship1 317 'sentence connector'. 318 - connecting word: when a connecting word (coordinating conjunction 319 or connecting particle, e.g. καί, ἀλλά, τε, μέν, δέ, οὔτε) joins 320 a pair or series of nouns, adjectives, adverbs, or whole clauses 321 WITHIN a sentence (as opposed to linking one sentence to the 322 previous one -- see 'sentence connector' above), it uses 323 relationship1 = 'connecting word', in one of three shapes: 324 - a SINGLE connecting word joining a pair: relatedtoken1 -> the 325 id of the FIRST connected item, relatedtoken2 -> the id of 326 the SECOND connected item, relationship2 = 'connecting word' 327 too (both fields on the one connecting-word token). Examples: 328 in 'ἐπιτηρῶν γὰρ τὴν θεράπαιναν τὴν εἰς τὴν ἀγορὰν 329 βαδίζουσαν καὶ λόγους προσφέρων ἀπώλεσεν αὐτήν', καί joins 330 the participles ἐπιτηρῶν and προσφέρων: relatedtoken1 -> 331 ἐπιτηρῶν, relatedtoken2 -> προσφέρων. In 'ἐγὼ τοίνυν ἐξ 332 ἀρχῆς ὑμῖν ἅπαντα ἐπιδείξω τὰ ἐμαυτοῦ πράγματα, οὐδὲν 333 παραλείπων, ἀλλὰ λέγων τἀληθῆ', ἀλλά joins the participles 334 παραλείπων and λέγων the same way. In 'οἰκίδιον ἔστι μοι 335 διπλοῦν, ἴσα ἔχον τὰ ἄνω τοῖς κάτω κατὰ τὴν γυναικωνῖτιν καὶ 336 κατὰ τὴν ἀνδρωνῖτιν', καί joins the two prepositional 337 phrases' own prepositions (the first and second κατά). 338 - a PAIRED correlative (e.g. postpositive τε...καί, or a 339 repeated καὶ...καί, or -- within a single sentence -- μέν 340 continued by δέ): each of the two connecting words has 341 relatedtoken1 -> ITS OWN adjacent connected item (not "the 342 first item" generically -- whichever item that particular 343 connector itself sits next to), and relatedtoken2 -> the id 344 of the OTHER connecting word (not another connected item). 345 Example: in 'ἐφύλαττόν τε καὶ προσεῖχον τὸν νοῦν', τε and 346 καί join the verbal expressions ἐφύλαττον and προσεῖχον: τε 347 has relatedtoken1 -> ἐφύλαττον, relatedtoken2 -> καί 348 (relationship2 'connecting word' too); καί has relatedtoken1 349 -> προσεῖχον, relatedtoken2 -> τε. Example (repeated καί): 350 in 'περὶ τούτου γὰρ μόνου τοῦ ἀδικήματος καὶ ἐν δημοκρατίᾳ 351 καὶ ὀλιγαρχίᾳ ἡ αὐτὴ τιμωρία τοῖς ἀσθενεστάτοις πρὸς τοὺς τὰ 352 μέγιστα δυναμένους ἀποδέδοται', the first καί has 353 relatedtoken1 -> δημοκρατίᾳ, relatedtoken2 -> the second 354 καί; the second καί has relatedtoken1 -> ὀλιγαρχίᾳ, 355 relatedtoken2 -> the first καί. Example (μέν...δέ within one 356 sentence, not split across sentences -- contrast 'sentence 357 connector' above): in 'ἐγὼ μὲν ἄνω διῃτώμην, αἱ δὲ γυναῖκες 358 κάτω', μέν has relatedtoken1 -> διῃτώμην (its own, 359 first clause's verb), relatedtoken2 -> δέ; δέ has 360 relatedtoken1 -> the second clause's own verb (here the 361 implied-repetition token standing in for the elided verb -- 362 see 'implied repetition' below), relatedtoken2 -> μέν. 363 - a SERIES of 3 or more connected items: every connecting word 364 still has relatedtoken1 -> ITS OWN adjacent item, same as the 365 paired case; relatedtoken2 chains the series together -- 366 the FIRST connecting word's relatedtoken2 -> the SECOND 367 connecting word's id (forward), and every LATER connecting 368 word's relatedtoken2 -> the id of the connecting word 369 immediately BEFORE it (backward) -- so the whole series is 370 still traceable by following relatedtoken2 links between the 371 connecting-word tokens, even though no single token names 372 every member. Example: in 'οὔτε γὰρ συκοφαντῶν γραφάς με 373 ἐγράψατο, οὔτε ἐκβάλλειν ἐκ τῆς πόλεως ἐπεχείρησεν, οὔτε 374 ἰδίας δίκας ἐδικάζετο.', the first οὔτε has relatedtoken1 -> 375 ἐγράψατο, relatedtoken2 -> the second οὔτε; the second οὔτε 376 has relatedtoken1 -> ἐπεχείρησεν, relatedtoken2 -> the first 377 οὔτε; the third οὔτε has relatedtoken1 -> ἐδικάζετο, 378 relatedtoken2 -> the second οὔτε. The same pattern can occur 379 with μέν starting a series and δέ continuing it. 380 # TODO: syntax_model.md does not discuss καί's double life as 381 # connective ("and") vs. adverb ("also"/"even"), unlike Latin's 382 # explicit treatment of 'et'. By analogy: when καί modifies a 383 # single word rather than joining two, treat it like any other 384 # adverb instead -- relatedtoken1 -> the verb (or nearest token) 385 # it emphasizes, relationship1 'adverbial', not 'connecting 386 # word'. This guidance is an extrapolation, not sanctioned by a 387 # syntax_model.md example. 388 - direct quote / aside: a verbal expression of syntactic type 389 'direct quote' or 'aside' has relatedtoken1 -> the id of the verb 390 of the clause it interrupts or is framed by, relationship1 = 391 'direct quote' or 'aside' respectively (matching its syntactic 392 type). Examples above under (1). 393 - circumstantial participle / genitive absolute: a circumstantial 394 participial verbal expression's own relatedtoken1 -> the id of 395 the noun or pronoun it agrees with, relationship1 = 396 'circumstantial participle'. That noun in turn: if it also fits 397 a normal role in the surrounding clause (e.g. it's already the 398 main verb's subject), it takes THAT normal relation instead 399 (nothing extra to add). Example: in 'ἐγὼ ἅπαντα ἐπιδείξω τὰ 400 ἐμαυτοῦ πράγματα, οὐδὲν παραλείπων, ἀλλὰ λέγων τἀληθῆ', both 401 παραλείπων and λέγων have relatedtoken1 -> ἐγώ, relationship1 402 'circumstantial participle', and ἐγώ (already the subject of 403 ἐπιδείξω) has relatedtoken1 -> ἐπιδείξω, relationship1 404 'subject' -- nothing further added for the participles. If the 405 noun is a GENITIVE with no other syntactic connection to the 406 sentence (a genitive absolute), it instead has relatedtoken1 -> 407 the id of the main verb, relationship1 = 'genitive absolute'. 408 Example: in 'προϊόντος δὲ τοῦ χρόνου ἧκον μὲν ἀπροσδοκήτως ἐξ 409 ἀγροῦ', προϊόντος has relatedtoken1 -> χρόνου, relationship1 410 'circumstantial participle', and χρόνου in turn has 411 relatedtoken1 -> ἧκον, relationship1 'genitive absolute'. 412 - attributive participle: an attributive participial verbal 413 expression's own relatedtoken1 -> the id of the noun or pronoun 414 it agrees with, relationship1 = 'attributive participle'. 415 Example: in 'ὁ γὰρ ἀνὴρ ὁ ὑβρίζων εἰς σὲ ἐχθρὸς ὢν ἡμῖν 416 τυγχάνει', ὑβρίζων has relatedtoken1 -> ἀνήρ, relationship1 417 'attributive participle'. 418 # TODO: models.py's RelationLabel comment block also glosses 419 # "attributive" itself as covering "participle-in-attributive- 420 # position", alongside the dedicated "attributive participle" 421 # value documented above and in syntax_model.md's own worked 422 # example. Since Greek (unlike Latin) makes EVERY attributive 423 # participle its own verbal expression, this port resolves the 424 # overlap by always using 'attributive participle' for an 425 # attributive participle's relation to its noun, and reserving 426 # plain 'attributive' for ordinary adjectives and prepositional 427 # phrases (see below) -- flagged here as an unresolved tension 428 # in the fixed contract, not something syntax_model.md itself 429 # disambiguates. 430 - auxiliary: in a compound perfect-system verb form (participle + 431 a conjugated form of εἰμί), the form of εἰμί anchors the verbal 432 expression and is the target of every relation into it (subject, 433 direct object, agent, etc); the participle itself has 434 relatedtoken1 -> the id of that form of εἰμί, relationship1 = 435 'auxiliary'. Example: in 'ὁ νόμος γεγραμμένος ἐστίν', γεγραμμένος 436 has relatedtoken1 -> ἐστίν, relationship1 'auxiliary'. 437 - agent: the preposition ὑπό introducing the agent of a passive 438 verb has relatedtoken1 -> the passive verb's id (the id of the 439 form of εἰμί, for a compound form), relationship1 = 'agent'. The 440 noun/pronoun governed by that ὑπό has relatedtoken1 -> the id of 441 ὑπό, relationship1 = 'object of preposition'. Example: in 'ἡ ἐμὴ 442 γυνὴ ὑπὸ τούτου τοῦ ἀνθρώπου διαφθείρεται', ὑπό has relatedtoken1 443 -> διαφθείρεται, relationship1 'agent', and ἀνθρώπου has 444 relatedtoken1 -> ὑπό, relationship1 'object of preposition'. 445 - subject / direct object / predicate: a noun or pronoun serving 446 as subject or direct object has relatedtoken1 -> the id of the 447 verb (the id of the form of εἰμί, for a compound form), 448 relationship1 = 'subject' or 'direct object'. This applies to 449 the accusative subject of an infinitive in indirect statement 450 too. Examples: in 'ἐμοίχευεν Ἐρατοσθένης τὴν γυναῖκα τὴν ἐμήν', 451 Ἐρατοσθένης has relatedtoken1 -> ἐμοίχευεν, relationship1 452 'subject', and γυναῖκα has relatedtoken1 -> ἐμοίχευεν, 453 relationship1 'direct object'. A noun, pronoun, or adjective 454 serving as the predicate complement of a LINKING verb uses 455 relationship1 = 'predicate' instead, same relatedtoken1 target. 456 Example: in 'ᾤμην τὴν ἐμαυτοῦ γυναῖκα πασῶν σωφρονεστάτην εἶναι 457 τῶν ἐν τῇ πόλει', εἶναι anchors an 'indirect statement'/'linking 458 verb' verbal expression governed by ᾤμην; γυναῖκα is its subject 459 (relatedtoken1 -> εἶναι, relationship1 'subject') and 460 σωφρονεστάτην is its predicate adjective (relatedtoken1 -> 461 εἶναι, relationship1 'predicate'). If the token is a relative 462 pronoun already using relatedtoken1/relationship1 for its 463 antecedent link, put this relation in relatedtoken2/ 464 relationship2 instead (see 'relative pronoun' above). 465 - article: the definite article relates to the noun (or 466 substantivized adjective, adverb, or infinitive) it accompanies: 467 relatedtoken1 -> that word's id, relationship1 = 'article'. 468 Example: in 'τῷ χρόνῳ πεισθείη', τῷ has relatedtoken1 -> χρόνῳ, 469 relationship1 'article'. When an adjective is in attributive 470 position with a REPEATED article (article-noun-article- 471 adjective), the second article instead has relatedtoken1 -> the 472 id of that adjective, relationship1 = 'article' -- and the 473 adjective itself still gets the ordinary 'attributive' relation 474 to the noun (see below). Example: in 'εἵλου τοιοῦτον ἁμάρτημα 475 ἐξαμαρτάνειν εἰς τὴν γυναῖκα τὴν ἐμήν', the first τήν has 476 relatedtoken1 -> γυναῖκα, relationship1 'article'; the second 477 τήν has relatedtoken1 -> ἐμήν, relationship1 'article'; and ἐμήν 478 has relatedtoken1 -> γυναῖκα, relationship1 'attributive'. (If 479 the adjective instead stood between article and noun with no 480 second article, e.g. 'τὴν ἐμὴν γυναῖκα', the single τήν and ἐμήν 481 keep exactly those same relations -- there is simply no second 482 article token to add.) 483 - attributive: an adjective in attributive position, a participle 484 in attributive position AS AN ORDINARY MODIFIER of a noun 485 distinct from its own verbal-expression relation (see the 486 'attributive participle' TODO above), or a prepositional phrase 487 modifying a noun, has relatedtoken1 -> the noun's id, 488 relationship1 = 'attributive'. Example (adjective): ἐμήν in the 489 εἵλου example above. Example (prepositional phrase): 490 # TODO: syntax_model.md's own worked example here ("attributive 491 # to a noun: in the phrase pugna ad Cannas...") is still the 492 # untranslated Latin example, apparently left over when the 493 # document was adapted for Greek. Substituting a constructed 494 # Greek example instead: in 'ἡ μάχη ἡ ἐν Μαραθῶνι', ἐν has 495 # relatedtoken1 -> μάχη, relationship1 'attributive', and 496 # Μαραθῶνι has relatedtoken1 -> ἐν, relationship1 'object of 497 # preposition'. An adjective used as a substantive (standing in 498 # for a noun) is treated as a noun/pronoun instead, not as 499 # attributive. Example: in 'ἐκείνη μὲν ἀπηλλάγη', ἐκείνη has 500 # relatedtoken1 -> ἀπηλλάγη, relationship1 'subject' (not 501 # 'attributive' or 'demonstrative' -- it stands for a noun 502 # here, it does not modify one). 503 - demonstrative: a demonstrative pronoun modifying a noun -- unlike 504 an ordinary adjective, NOT in attributive position -- has 505 relatedtoken1 -> the noun's id, relationship1 = 'demonstrative'. 506 Example: in 'ταύτην ἔλαβον τὴν δίκην', ταύτην has relatedtoken1 507 -> δίκην, relationship1 'demonstrative'. 508 - adverbial (bare adverb): an adverb modifying a verb has 509 relatedtoken1 -> the verb's id, relationship1 = 'adverbial'. 510 Example: in 'διαρρήδην εἴρηται', διαρρήδην has relatedtoken1 -> 511 εἴρηται, relationship1 'adverbial'. An adverb can also stand in 512 attributive position modifying a noun -- same relationship1 513 value either way, just a noun instead of a verb on the other 514 end. Example: in 'δᾷδας λαβόντες ἐκ τοῦ ἐγγύτατα καπηλείου 515 εἰσερχόμεθα', ἐγγύτατα has relatedtoken1 -> καπηλείου, 516 relationship1 'adverbial'. 517 - prepositional phrases: the preposition has relatedtoken1 -> the 518 id of the verb (adverbial) or noun (attributive) it modifies, 519 relationship1 = 'adverbial' or 'attributive'. The noun/pronoun 520 it governs has relatedtoken1 -> the id of the preposition, 521 relationship1 = 'object of preposition' (or relatedtoken2/ 522 relationship2 if relatedtoken1 is already used for a 523 relative-pronoun link). Example: in 'γυναῖκα ἠγαγόμην εἰς τὴν 524 οἰκίαν', εἰς has relatedtoken1 -> ἠγαγόμην, relationship1 525 'adverbial', and οἰκίαν has relatedtoken1 -> εἰς, relationship1 526 'object of preposition'. 527 - genitive: a noun or pronoun in the genitive that modifies 528 ANOTHER NOUN -- and isn't already covered by a more specific 529 relation above (object of preposition, genitive absolute, etc) 530 -- has relatedtoken1 -> the id of that noun, relationship1 = 531 'genitive'. This is purely a syntactic (case-function) label, 532 not a semantic one -- don't distinguish e.g. possessive vs. 533 partitive genitive. Example: in 'ᾤχετο εἰς τὸ ἱερὸν μετὰ τῆς 534 μητρὸς τῆς ἐκείνου', ἐκείνου has relatedtoken1 -> μητρός, 535 relationship1 'genitive'. 536 - dative / accusative: a noun in the dative or accusative case 537 that depends on a verb or another noun -- and isn't already 538 covered by a more specific relation above (subject, direct 539 object, object of preposition, etc) -- has relatedtoken1 -> the 540 id of the verb or noun it depends on, relationship1 = the 541 matching case name ('dative' or 'accusative'). Example (dative, 542 linked to a verb): in 'οὔτε ἔχθρα ἐμοὶ καὶ ἐκείνῳ οὐδεμία ἦν 543 πλὴν ταύτης, οὔτε χρημάτων ἕνεκα ἔπραξα ταῦτα', both ἐμοί and 544 ἐκείνῳ have relatedtoken1 -> ἦν, relationship1 'dative'. Example 545 (accusative of extent of time, linked to a verb): in 'ταῦτα 546 πολὺν χρόνον οὕτως ἐγίγνετο', χρόνον has relatedtoken1 -> 547 ἐγίγνετο, relationship1 'accusative'. Note that Greek has NO 548 ablative case and NO dedicated relation label for one -- a 549 Latin-scheme 'ablative' relation simply does not arise here. 550 - vocative: a noun in the vocative case (direct address) has 551 relatedtoken1 -> the id of the verb of the clause it's addressed 552 within, relationship1 = 'vocative'. Unlike 'genitive'/'dative'/ 553 'accusative' above, a vocative relates to a verb only, never to 554 another noun. Example: in 'ἐγὼ μὲν οὖν, ὦ ἄνδρες, οὐκ ἰδίαν ὑπὲρ 555 ἐμαυτοῦ νομίζω ταύτην γενέσθαι τὴν τιμωρίαν', ἄνδρες has 556 relatedtoken1 -> νομίζω, relationship1 'vocative'. 557 - apposition: when one noun stands in apposition to another, the 558 appositive has relatedtoken1 -> the id of the first (the noun it 559 restates or further identifies), relationship1 = 'apposition'. A 560 genitive depending on either noun still gets its own ordinary 561 'genitive' relation, pointing at whichever noun it actually 562 depends on -- apposition doesn't change that. 563 # TODO: syntax_model.md gives only the general definition here, 564 # no worked Greek example. Constructed illustration: in 565 # 'Δημοσθένης ὁ ῥήτωρ ἦλθεν', ῥήτωρ has relatedtoken1 -> 566 # Δημοσθένης, relationship1 'apposition' (and ὁ has 567 # relatedtoken1 -> ῥήτωρ, relationship1 'article'). 568 - exclamation: an exclamatory word has relatedtoken1 -> the id of 569 the verb of its own verbal unit, relationship1 = 'exclamation' -- 570 EXCEPT the frequent exclamatory particle ὦ introducing a 571 vocative, which instead has relatedtoken1 -> the id of the 572 vocative noun/pronoun it introduces (not the verb directly). 573 Example: in 'ἐγὼ μὲν οὖν, ὦ ἄνδρες, οὐκ ἰδίαν ὑπὲρ ἐμαυτοῦ 574 νομίζω ταύτην γενέσθαι τὴν τιμωρίαν, ἀλλ' ὑπὲρ τῆς πόλεως 575 ἁπάσης', ὦ has relatedtoken1 -> ἄνδρες (the vocative it 576 introduces), relationship1 'exclamation' -- NOT relatedtoken1 -> 577 νομίζω directly, even though ἄνδρες's own relatedtoken1 does 578 point to νομίζω (relationship1 'vocative'). This same pattern 579 applies wherever ὦ introduces a vocative elsewhere in a passage, 580 e.g. 'πρῶτον μὲν οὖν, ὦ ἄνδρες, ...': ὦ -> ἄνδρες, 'exclamation'. 581 582 Only assign relations described above. Leave relatedtoken/ 583 relationship fields unset for tokens with no relation of these 584 kinds -- not every token will have one (e.g. a bare accusative of 585 respect not covered above). Use 586 only the token ids given in the input `tokens` list, the sentinel 587 'root', or a NEW id you create for an implied token (see below), in 588 your output; never invent an id for anything else. 589 590 (3) implied/elided tokens. `grammatike` recognizes two DIFFERENT 591 situations where a verbal expression exists grammatically but has 592 no surface realization in the passage at all -- rather than skip 593 these, add a NEW entry to `tokengraph` (and a matching new entry to 594 `verbalunits`, since an implied token always anchors its own 595 verbal expression) with: a brand-new id, not used by any entry in 596 `tokens` or elsewhere in your own output (see the naming rule 597 below); the matching tokentype below; and no `token` value (leave 598 it unset/None) -- these go together, and 'implied eimi' / 599 'implied repetition' are the ONLY two tokentype values whose id 600 isn't one of `tokens`' own ids and whose `token` is empty. 601 602 - tokentype 'implied eimi': an elided form of εἰμί ('to be') in a 603 predicate expression. The documented case is an implied 604 INFINITIVE of εἰμί inside indirect statement: the implied token 605 anchors a verbal expression classified 'indirect statement' and 606 'linking verb', relates to its governing verb of thinking/saying 607 via relatedtoken1/relationship1 = 'indirect statement' exactly 608 as a written-out infinitive would, and the subject/predicate of 609 the predication relate to it as 'subject'/'predicate' exactly as 610 they would to any linking verb. Example: 'ταύτην τὴν ὕβριν 611 ἅπαντες ἄνθρωποι δεινοτάτην ἡγοῦνται' has an independent verbal 612 expression ἡγοῦνται governing an implied infinitive of εἰμί 613 (syntactic type 'indirect statement', semantic type 'linking 614 verb') whose relatedtoken1 -> ἡγοῦνται, relationship1 'indirect 615 statement'; 'ταύτην τὴν ὕβριν' relates to it as 'subject' and 616 δεινοτάτην as 'predicate'. 617 # TODO: the two sub-cases below extrapolate from that one 618 # documented example and from the general phrasing "elided εἰμί 619 # in predicate expressions" -- syntax_model.md gives no worked 620 # example for either: 621 - a bare predicate construction with NO governing verb at all 622 (subject + predicate noun/adjective, nothing else): the 623 implied token anchors a verbal expression classified 624 'independent' (or 'dependent', if the elided-εἰμί clause is 625 itself subordinate) and 'linking verb'; subject and 626 predicate relate to it exactly as they would to any linking 627 verb. 628 - an omitted conjugated εἰμί in a compound perfect-system form 629 (the participle left standing alone for its auxiliary): the 630 implied token stands in for the omitted form of εἰμί -- 631 everything that would normally relate to that auxiliary 632 (subject, the participle's own 'auxiliary' relation, etc.) 633 relates to the implied token instead, exactly as if the 634 auxiliary had been written out. 635 - tokentype 'implied repetition': a verb elided from a later 636 verbal expression in a coordinated series because it repeats the 637 verb of an earlier one. Add ONE implied token per omitted 638 repeated verb, repeating that verb's OWN syntactic_type and 639 semantic_type exactly (whatever those happen to be in context -- 640 not necessarily 'independent'/'intransitive'), and give whatever 641 would relate to the omitted verb (subject, adverbial, a 642 connecting word, etc.) its normal relation into the implied 643 token instead, exactly as if the verb had been repeated. 644 Example: 'ἐγὼ μὲν ἄνω διῃτώμην, αἱ δὲ γυναῖκες κάτω' has an 645 explicit verbal expression διῃτώμην ('independent'/ 646 'intransitive') with subject ἐγώ, and a second, implied verbal 647 expression (tokentype 'implied repetition') repeating 648 διῃτώμην's own 'independent'/'intransitive' classification, with 649 subject γυναῖκες and adverbial κάτω relating to the implied 650 token instead of to διῃτώμην. 651 652 Naming an implied token's id (both tokentypes): append '_implied' 653 to the id of the LAST real token in `tokens` that precedes where 654 the elided word would have stood (or, if the elided word would 655 come before every real token in the sentence, the FIRST real 656 token's id instead). If more than one implied token is ever 657 needed at the same position, append '2', '3', ... after '_implied' 658 to keep them unique (e.g. 't5_implied', 't5_implied2'). Place the 659 new `tokengraph` entry at the list position where the elided word 660 would have appeared, among the tokens of its own clause -- this 661 keeps it grouped with the rest of its verbal expression for 662 anything that reads `tokengraph` in order. 663 """ 664 665 passage: str = dspy.InputField(desc="The Ancient Greek passage to analyze, exactly as written.") 666 tokens: List[Token] = dspy.InputField( 667 desc="Pre-segmented tokens of the passage, in order, with fixed ids. Reference these ids in your output; do not create new ones." 668 ) 669 verbalunits: List[VerbalExpression] = dspy.OutputField( 670 desc="One entry per verbal expression (finite verb; infinitive or participle used in indirect speech; attributive participle; or circumstantial participle) in the passage." 671 ) 672 tokengraph: List[TokenAnalysis] = dspy.OutputField( 673 desc=( 674 "One entry per token in `tokens`, in the same order, with its " 675 "type and any relations -- PLUS one additional entry for each " 676 "implied/elided token you add (see this signature's docstring), " 677 "positioned where that token's clause falls in reading order." 678 ) 679 )
Analyze the syntax of a passage of Ancient Greek according to a two-part scheme:
(1) a list of verbal expressions. Three constructions count as a verbal expression: finite verbs, infinitives, and participles -- but for participles, only some of them (see below).
- A finite verb (including a compound perfect-system form made of
a participle plus a conjugated form of εἰμί, e.g. ὁ νόμος
γεγραμμένος ἐστίν) is always a verbal expression. Classify its
syntactic type as 'independent' (main/principal), 'dependent'
(subordinate, introduced by a subordinating word), 'direct
quote' (occurring in directly quoted speech framed by another
verb, e.g. νόμιζε in '"εὐφίλητε" ἔφη "μηδεμιᾷ πολυπραγμοσύνῃ
προσεληλυθέναι με νόμιζε πρὸς σέ."'), or 'aside' (a verbal
expression that interrupts the surrounding syntax, e.g. δεῖ in
'πρῶτον μὲν οὖν, ὦ ἄνδρες, (δεῖ γὰρ καὶ ταῦθ᾽ ὑμῖν διηγήσασθαι)
οἰκίδιον ἔστι μοι διπλοῦν' interrupting the independent verbal
expression ἔστι). Example of independent vs. dependent: in
'ἐπειδὴ δὲ ἦν πρὸς ἡμέραν, ἧκεν ἐκείνη', ἧκεν is 'independent'
and ἦν is 'dependent' (introduced by the subordinating
conjunction ἐπειδή).
- An infinitive is a verbal expression only when part of an
indirect statement; its syntactic type is always 'indirect
statement'. Example: in 'ἔφασκε τὸν λύχνον ἀποσβεσθῆναι', ἔφασκε
is independent and ἀποσβεσθῆναι anchors the indirect-statement
verbal expression. In a compound perfect-system form (participle
+ a conjugated form of εἰμί), the form of εἰμί anchors the
verbal expression, same as any other compound form.
- A participle constitutes a verbal expression in THREE cases,
each with its own dedicated syntactic_type -- unlike Latin,
which uses a single 'dependent' value for every predicate-sense
participle, Greek's scheme gives each its own name:
- 'indirect statement': a participle expressing indirect
speech after a verb of perception or thinking. Example: in
'εἶδε δὲ τὴν βασίλειαν φεύγουσαν', εἶδε is independent and
φεύγουσαν (not an infinitive here) anchors the
indirect-statement verbal expression.
- 'attributive': a participle in attributive position.
Example: in 'ὁ γὰρ ἀνὴρ ὁ ὑβρίζων εἰς σὲ ἐχθρὸς ὢν ἡμῖν
τυγχάνει', the repeated article puts ὑβρίζων in attributive
position with ἀνήρ, so ὑβρίζων anchors an 'attributive'
verbal expression. In Greek, UNLIKE Latin, every attributive
participle counts as its own verbal expression -- there is
no purely-adjectival, non-verbal-expression reading for an
attributive participle the way Latin's "consentiens laus"
was not a verbal expression at all.
- 'circumstantial': a participle in circumstantial position
(including one forming a genitive absolute). Example: in
'χρόνου μεταξὺ διαγενομένου, προσέρχεταί μοί τις πρεσβῦτις
ἄνθρωπος', προσέρχεταί is independent and διαγενομένου
anchors a 'circumstantial' verbal expression.
By contrast, a *supplementary* participle -- one that completes
the sense of its governing verb as a single predicate idea
(e.g. with τυγχάνω, λανθάνω, φαίνομαι, παύομαι, or the like),
rather than standing attributively with a noun or
circumstantially/adverbially to the clause -- is explicitly NOT
a verbal expression and gets no `verbalunits` entry at all.
Example: in that same sentence 'ὁ γὰρ ἀνὴρ ὁ ὑβρίζων εἰς σὲ
ἐχθρὸς ὢν ἡμῖν τυγχάνει', ὤν supplements τυγχάνει (there is a
single independent verbal expression, anchored to τυγχάνει) and
is NOT its own verbal expression. Do not over-generate
verbal-expression entries for participles: check first whether a
participle is genuinely attributive (repeated article, or
agreeing with a noun as its ordinary modifier), genuinely
circumstantial (an adverbial predication about a noun, loosely
attached to the clause), or genuinely reporting indirect
perception -- and only then give it a `verbalunits` entry; a
participle that instead completes one predicate idea together
with a governing verb like τυγχάνω does not.
# TODO: syntax_model.md does not name a RelationLabel for a
# supplementary participle's own relation to its governing verb
# (no "supplementary" or "complementary participle" value
# exists). This port leaves such a participle's own
# relatedtoken1/relationship1 unset -- no documented label
# fits -- while still letting it take its own predicate/
# object/adverbial complements exactly as a linking verb would
# (e.g. ἐχθρός, the predicate adjective of ὤν in the example
# above, still relates to ὤν with relationship1 'predicate',
# exactly as it would to a finite linking verb).
Classify each verbal expression's semantic type too (transitive
active / transitive passive / intransitive / linking verb).
Examples: προσεῖχον in 'προσεῖχον τὸν νοῦν' is transitive active;
διαφθείρεται in 'ἡ ἐμὴ γυνὴ ὑπὸ τούτου τοῦ ἀνθρώπου διαφθείρεται'
is transitive passive; εἰσῄει in 'πάντα μου εἰς τὴν γνώμην
εἰσῄει' is intransitive; ἦ in 'μεστὸς ἦ ὑποψίας' is a linking
verb.
(2) a token-by-token dependency graph. For each token, record up to two relations to other tokens (by id), using only these relation labels:
- unit verb (independent): every INDEPENDENT verb has relatedtoken1
= the special sentinel string 'root' -- never an actual token id;
no real token may be assigned the id 'root' -- and relationship1
= 'unit verb'. Example: in 'τὴν θύραν ἀνέῳξεν', ἀνέῳξεν has
relatedtoken1 'root', relationship1 'unit verb'.
- unit verb (dependent) / subordinating conjunction / relative
pronoun: the verb of a DEPENDENT clause has relatedtoken1 -> the
id of its subordinating conjunction or relative/interrogative
pronoun, relationship1 = 'unit verb'. That conjunction or pronoun
in turn has relatedtoken1 -> the id of the verb of the clause it
is subordinate to, with relationship1 = 'subordinating
conjunction' for a conjunction, or relatedtoken1 -> its
antecedent's id with relationship1 = 'relative pronoun' for a
relative pronoun. Example: in 'ἐπειδὴ δὲ ἦν πρὸς ἡμέραν, ἧκεν
ἐκείνη', ἐπειδή has relatedtoken1 -> ἧκεν, relationship1
'subordinating conjunction', and ἦν has relatedtoken1 ->
ἐπειδή, relationship1 'unit verb'. Another example, with a
conjunction: in 'κατηγόρει ὡς μετὰ τὴν ἐκφορὰν αὐτῇ προσίοι',
ὡς has relatedtoken1 -> κατηγόρει, relationship1 'subordinating
conjunction', and προσίοι has relatedtoken1 -> ὡς, relationship1
'unit verb'. Indirect questions are treated as a kind of
dependent clause: an interrogative pronoun introducing one is
treated the same way as a subordinating conjunction -- it has
relatedtoken1 -> the id of the verb it introduces, relationship1
= 'subordinating conjunction' (no separate label for this case)
-- while the dependent verb itself has relatedtoken1 -> the
interrogative word's id, relationship1 = 'unit verb', exactly
like any other dependent clause.
# TODO: syntax_model.md states this indirect-question rule only
# in passing ("a subordinating conjunction or a relative or
# interrogative pronoun") with no worked example; the following
# is constructed by analogy, not quoted from syntax_model.md:
# in 'οὐκ οἶδα τίς ἦλθεν' ("I don't know who came"), τίς has
# relatedtoken1 -> οἶδα, relationship1 'subordinating
# conjunction', and ἦλθεν has relatedtoken1 -> τίς, relationship1
# 'unit verb'.
- relative pronoun (second relation): a relative pronoun ALSO
relates to its own function inside the relative clause, using
relatedtoken2/relationship2 (since relatedtoken1/relationship1 is
already used for the antecedent link) -- the ordinary relation
it would have if it were any other noun/pronoun in that clause
(e.g. 'direct object', 'subject', a case relation, etc). Example:
in 'οὐκ ἐγώ σε ἀποκτενῶ, ἀλλ᾽ ὁ τῆς πόλεως νόμος, ὃν σὺ περὶ
ἐλάττονος τῶν ἡδονῶν ἐποιήσω', ὅν has relatedtoken1 -> νόμος
(its antecedent), relationship1 'relative pronoun', AND
relatedtoken2 -> ἐποιήσω, relationship2 'direct object'.
- indirect statement (governing verb): an infinitive OR participle
anchoring an indirect-statement verbal expression ALSO has
relatedtoken1 -> the id of the verb that governs the indirect
statement (the verb of saying/thinking/perceiving it depends
on), relationship1 = 'indirect statement' -- matching its own
syntactic type, the same convention 'direct quote' and 'aside'
verbal expressions use below. There's no separate
subordinating-word token to point at first, so the infinitive or
participle points directly at its governing verb, rather than
via a conjunction/pronoun intermediary the way a dependent
finite verb's 'unit verb' relation does. Examples: in
'ἔφασκε τὸν λύχνον ἀποσβεσθῆναι', ἀποσβεσθῆναι has relatedtoken1
-> ἔφασκε, relationship1 'indirect statement'; in 'εἶδε δὲ τὴν
βασίλειαν φεύγουσαν', φεύγουσαν has relatedtoken1 -> εἶδε,
relationship1 'indirect statement'. In a compound perfect-system
form, this relation belongs on the conjugated form of εἰμί that
anchors the verbal expression, same as any other relation into
it.
- complementary infinitive: an infinitive that completes the sense
of a governing verb like βούλομαι, δεῖ, or ἐθέλω (rather than
reporting indirect speech) has relatedtoken1 -> the id of that
governing verb, relationship1 = 'complementary infinitive'.
Unlike an indirect-statement infinitive, this does NOT make the
infinitive its own verbal expression -- it gets no `verbalunits`
entry of its own; the governing verb is still the only verbal
expression here. Example: in 'ἔξεστι ἑλέσθαι', ἑλέσθαι has
relatedtoken1 -> ἔξεστι, relationship1 'complementary
infinitive'.
- modal particle: the particle ἄν has relatedtoken1 -> the id of
the verb of ITS OWN verbal unit (not some other unit's verb),
relationship1 = 'modal particle'. Example: in 'εἰ τὴν αὐτὴν
γνώμην περὶ τῶν ἄλλων ἔχοιτε, οὐκ ἂν εἴη, ὅστις οὐκ ἐπὶ τοῖς
γεγενημένοις ἀγανακτοίη' (two dependent verbal expressions plus
one independent verbal expression anchored to εἴη), ἂν has
relatedtoken1 -> εἴη, relationship1 'modal particle' -- εἴη is
ἂν's own verbal unit's verb, the same verb οὐκ (adverbial) also
relates to.
- infinitive used as a noun: an infinitive can also function as an
ordinary noun -- most often a verb's subject or object -- rather
than anchoring an indirect statement or completing another verb.
Treat it exactly like any other noun in that role: relatedtoken1
-> the verb it's the subject/object of, relationship1 =
'subject' or 'direct object' as appropriate (no dedicated label,
and again no `verbalunits` entry of its own). If the infinitive
carries a definite article (an articular infinitive, e.g. τὸ
ζῆν), that article relates to the infinitive exactly as it would
to a substantivized adjective or adverb: relatedtoken1 -> the
infinitive's id, relationship1 'article'. Like any verbal form,
an infinitive used this way can still take its own object or
adverb, related to it the same way they'd relate to a finite
verb.
# TODO: syntax_model.md does not discuss this construction at
# all; it is carried over from arsgrammatica's equivalent
# section by direct analogy, since substantival infinitives
# (often articular) are common in Greek too. No example here is
# quoted from syntax_model.md.
- sentence connector: true asyndeton is rare at the root level of
a sentence -- there is normally a connecting word expressing the
relation of the sentence to its predecessor. This connecting
word has relatedtoken1 -> the verb of THIS sentence (not the
previous one), relationship1 = 'sentence connector'. Example: in
'ταύτην γὰρ ἐμαυτῷ μόνην ἡγοῦμαι σωτηρίαν', γάρ has relatedtoken1
-> ἡγοῦμαι, relationship1 'sentence connector'. The particle μέν
begins a list of items, continued by δέ -- ordinarily WITHIN one
sentence (see 'connecting word' below), but when the items are
instead split across distinct, separately terminated sentences,
μέν or δέ is a 'sentence connector' too, exactly like γάρ, rather
than a 'connecting word': it has relatedtoken1 -> the verb of
ITS OWN sentence, relationship1 'sentence connector', with no
relation at all to the other sentence's verb (a sentence
connector never records a cross-sentence link -- only "this
sentence's own verb"). Examples: in the complete, terminated
sentence 'περὶ μὲν οὖν τοῦ μεγέθους τῆς ζημίας ἅπαντας ὑμᾶς
νομίζω τὴν αὐτὴν διάνοιαν ἔχειν, καὶ οὐδένα οὕτως ὀλιγώρως
διακεῖσθαι, ὅστις οἴεται δεῖν συγγνώμης τυγχάνειν ἢ μικρᾶς
ζημίας ἀξίους ἡγεῖται τοὺς τῶν τοιούτων ἔργων αἰτίους.', μέν has
relatedtoken1 -> νομίζω, relationship1 'sentence connector'; in
the following, separate sentence 'ἡγοῦμαι δέ, ὦ ἄνδρες, τοῦτό με
δεῖν ἐπιδεῖξαι.', δέ has relatedtoken1 -> ἡγοῦμαι, relationship1
'sentence connector'.
- connecting word: when a connecting word (coordinating conjunction
or connecting particle, e.g. καί, ἀλλά, τε, μέν, δέ, οὔτε) joins
a pair or series of nouns, adjectives, adverbs, or whole clauses
WITHIN a sentence (as opposed to linking one sentence to the
previous one -- see 'sentence connector' above), it uses
relationship1 = 'connecting word', in one of three shapes:
- a SINGLE connecting word joining a pair: relatedtoken1 -> the
id of the FIRST connected item, relatedtoken2 -> the id of
the SECOND connected item, relationship2 = 'connecting word'
too (both fields on the one connecting-word token). Examples:
in 'ἐπιτηρῶν γὰρ τὴν θεράπαιναν τὴν εἰς τὴν ἀγορὰν
βαδίζουσαν καὶ λόγους προσφέρων ἀπώλεσεν αὐτήν', καί joins
the participles ἐπιτηρῶν and προσφέρων: relatedtoken1 ->
ἐπιτηρῶν, relatedtoken2 -> προσφέρων. In 'ἐγὼ τοίνυν ἐξ
ἀρχῆς ὑμῖν ἅπαντα ἐπιδείξω τὰ ἐμαυτοῦ πράγματα, οὐδὲν
παραλείπων, ἀλλὰ λέγων τἀληθῆ', ἀλλά joins the participles
παραλείπων and λέγων the same way. In 'οἰκίδιον ἔστι μοι
διπλοῦν, ἴσα ἔχον τὰ ἄνω τοῖς κάτω κατὰ τὴν γυναικωνῖτιν καὶ
κατὰ τὴν ἀνδρωνῖτιν', καί joins the two prepositional
phrases' own prepositions (the first and second κατά).
- a PAIRED correlative (e.g. postpositive τε...καί, or a
repeated καὶ...καί, or -- within a single sentence -- μέν
continued by δέ): each of the two connecting words has
relatedtoken1 -> ITS OWN adjacent connected item (not "the
first item" generically -- whichever item that particular
connector itself sits next to), and relatedtoken2 -> the id
of the OTHER connecting word (not another connected item).
Example: in 'ἐφύλαττόν τε καὶ προσεῖχον τὸν νοῦν', τε and
καί join the verbal expressions ἐφύλαττον and προσεῖχον: τε
has relatedtoken1 -> ἐφύλαττον, relatedtoken2 -> καί
(relationship2 'connecting word' too); καί has relatedtoken1
-> προσεῖχον, relatedtoken2 -> τε. Example (repeated καί):
in 'περὶ τούτου γὰρ μόνου τοῦ ἀδικήματος καὶ ἐν δημοκρατίᾳ
καὶ ὀλιγαρχίᾳ ἡ αὐτὴ τιμωρία τοῖς ἀσθενεστάτοις πρὸς τοὺς τὰ
μέγιστα δυναμένους ἀποδέδοται', the first καί has
relatedtoken1 -> δημοκρατίᾳ, relatedtoken2 -> the second
καί; the second καί has relatedtoken1 -> ὀλιγαρχίᾳ,
relatedtoken2 -> the first καί. Example (μέν...δέ within one
sentence, not split across sentences -- contrast 'sentence
connector' above): in 'ἐγὼ μὲν ἄνω διῃτώμην, αἱ δὲ γυναῖκες
κάτω', μέν has relatedtoken1 -> διῃτώμην (its own,
first clause's verb), relatedtoken2 -> δέ; δέ has
relatedtoken1 -> the second clause's own verb (here the
implied-repetition token standing in for the elided verb --
see 'implied repetition' below), relatedtoken2 -> μέν.
- a SERIES of 3 or more connected items: every connecting word
still has relatedtoken1 -> ITS OWN adjacent item, same as the
paired case; relatedtoken2 chains the series together --
the FIRST connecting word's relatedtoken2 -> the SECOND
connecting word's id (forward), and every LATER connecting
word's relatedtoken2 -> the id of the connecting word
immediately BEFORE it (backward) -- so the whole series is
still traceable by following relatedtoken2 links between the
connecting-word tokens, even though no single token names
every member. Example: in 'οὔτε γὰρ συκοφαντῶν γραφάς με
ἐγράψατο, οὔτε ἐκβάλλειν ἐκ τῆς πόλεως ἐπεχείρησεν, οὔτε
ἰδίας δίκας ἐδικάζετο.', the first οὔτε has relatedtoken1 ->
ἐγράψατο, relatedtoken2 -> the second οὔτε; the second οὔτε
has relatedtoken1 -> ἐπεχείρησεν, relatedtoken2 -> the first
οὔτε; the third οὔτε has relatedtoken1 -> ἐδικάζετο,
relatedtoken2 -> the second οὔτε. The same pattern can occur
with μέν starting a series and δέ continuing it.
# TODO: syntax_model.md does not discuss καί's double life as
# connective ("and") vs. adverb ("also"/"even"), unlike Latin's
# explicit treatment of 'et'. By analogy: when καί modifies a
# single word rather than joining two, treat it like any other
# adverb instead -- relatedtoken1 -> the verb (or nearest token)
# it emphasizes, relationship1 'adverbial', not 'connecting
# word'. This guidance is an extrapolation, not sanctioned by a
# syntax_model.md example.
- direct quote / aside: a verbal expression of syntactic type
'direct quote' or 'aside' has relatedtoken1 -> the id of the verb
of the clause it interrupts or is framed by, relationship1 =
'direct quote' or 'aside' respectively (matching its syntactic
type). Examples above under (1).
- circumstantial participle / genitive absolute: a circumstantial
participial verbal expression's own relatedtoken1 -> the id of
the noun or pronoun it agrees with, relationship1 =
'circumstantial participle'. That noun in turn: if it also fits
a normal role in the surrounding clause (e.g. it's already the
main verb's subject), it takes THAT normal relation instead
(nothing extra to add). Example: in 'ἐγὼ ἅπαντα ἐπιδείξω τὰ
ἐμαυτοῦ πράγματα, οὐδὲν παραλείπων, ἀλλὰ λέγων τἀληθῆ', both
παραλείπων and λέγων have relatedtoken1 -> ἐγώ, relationship1
'circumstantial participle', and ἐγώ (already the subject of
ἐπιδείξω) has relatedtoken1 -> ἐπιδείξω, relationship1
'subject' -- nothing further added for the participles. If the
noun is a GENITIVE with no other syntactic connection to the
sentence (a genitive absolute), it instead has relatedtoken1 ->
the id of the main verb, relationship1 = 'genitive absolute'.
Example: in 'προϊόντος δὲ τοῦ χρόνου ἧκον μὲν ἀπροσδοκήτως ἐξ
ἀγροῦ', προϊόντος has relatedtoken1 -> χρόνου, relationship1
'circumstantial participle', and χρόνου in turn has
relatedtoken1 -> ἧκον, relationship1 'genitive absolute'.
- attributive participle: an attributive participial verbal
expression's own relatedtoken1 -> the id of the noun or pronoun
it agrees with, relationship1 = 'attributive participle'.
Example: in 'ὁ γὰρ ἀνὴρ ὁ ὑβρίζων εἰς σὲ ἐχθρὸς ὢν ἡμῖν
τυγχάνει', ὑβρίζων has relatedtoken1 -> ἀνήρ, relationship1
'attributive participle'.
# TODO: models.py's RelationLabel comment block also glosses
# "attributive" itself as covering "participle-in-attributive-
# position", alongside the dedicated "attributive participle"
# value documented above and in syntax_model.md's own worked
# example. Since Greek (unlike Latin) makes EVERY attributive
# participle its own verbal expression, this port resolves the
# overlap by always using 'attributive participle' for an
# attributive participle's relation to its noun, and reserving
# plain 'attributive' for ordinary adjectives and prepositional
# phrases (see below) -- flagged here as an unresolved tension
# in the fixed contract, not something syntax_model.md itself
# disambiguates.
- auxiliary: in a compound perfect-system verb form (participle +
a conjugated form of εἰμί), the form of εἰμί anchors the verbal
expression and is the target of every relation into it (subject,
direct object, agent, etc); the participle itself has
relatedtoken1 -> the id of that form of εἰμί, relationship1 =
'auxiliary'. Example: in 'ὁ νόμος γεγραμμένος ἐστίν', γεγραμμένος
has relatedtoken1 -> ἐστίν, relationship1 'auxiliary'.
- agent: the preposition ὑπό introducing the agent of a passive
verb has relatedtoken1 -> the passive verb's id (the id of the
form of εἰμί, for a compound form), relationship1 = 'agent'. The
noun/pronoun governed by that ὑπό has relatedtoken1 -> the id of
ὑπό, relationship1 = 'object of preposition'. Example: in 'ἡ ἐμὴ
γυνὴ ὑπὸ τούτου τοῦ ἀνθρώπου διαφθείρεται', ὑπό has relatedtoken1
-> διαφθείρεται, relationship1 'agent', and ἀνθρώπου has
relatedtoken1 -> ὑπό, relationship1 'object of preposition'.
- subject / direct object / predicate: a noun or pronoun serving
as subject or direct object has relatedtoken1 -> the id of the
verb (the id of the form of εἰμί, for a compound form),
relationship1 = 'subject' or 'direct object'. This applies to
the accusative subject of an infinitive in indirect statement
too. Examples: in 'ἐμοίχευεν Ἐρατοσθένης τὴν γυναῖκα τὴν ἐμήν',
Ἐρατοσθένης has relatedtoken1 -> ἐμοίχευεν, relationship1
'subject', and γυναῖκα has relatedtoken1 -> ἐμοίχευεν,
relationship1 'direct object'. A noun, pronoun, or adjective
serving as the predicate complement of a LINKING verb uses
relationship1 = 'predicate' instead, same relatedtoken1 target.
Example: in 'ᾤμην τὴν ἐμαυτοῦ γυναῖκα πασῶν σωφρονεστάτην εἶναι
τῶν ἐν τῇ πόλει', εἶναι anchors an 'indirect statement'/'linking
verb' verbal expression governed by ᾤμην; γυναῖκα is its subject
(relatedtoken1 -> εἶναι, relationship1 'subject') and
σωφρονεστάτην is its predicate adjective (relatedtoken1 ->
εἶναι, relationship1 'predicate'). If the token is a relative
pronoun already using relatedtoken1/relationship1 for its
antecedent link, put this relation in relatedtoken2/
relationship2 instead (see 'relative pronoun' above).
- article: the definite article relates to the noun (or
substantivized adjective, adverb, or infinitive) it accompanies:
relatedtoken1 -> that word's id, relationship1 = 'article'.
Example: in 'τῷ χρόνῳ πεισθείη', τῷ has relatedtoken1 -> χρόνῳ,
relationship1 'article'. When an adjective is in attributive
position with a REPEATED article (article-noun-article-
adjective), the second article instead has relatedtoken1 -> the
id of that adjective, relationship1 = 'article' -- and the
adjective itself still gets the ordinary 'attributive' relation
to the noun (see below). Example: in 'εἵλου τοιοῦτον ἁμάρτημα
ἐξαμαρτάνειν εἰς τὴν γυναῖκα τὴν ἐμήν', the first τήν has
relatedtoken1 -> γυναῖκα, relationship1 'article'; the second
τήν has relatedtoken1 -> ἐμήν, relationship1 'article'; and ἐμήν
has relatedtoken1 -> γυναῖκα, relationship1 'attributive'. (If
the adjective instead stood between article and noun with no
second article, e.g. 'τὴν ἐμὴν γυναῖκα', the single τήν and ἐμήν
keep exactly those same relations -- there is simply no second
article token to add.)
- attributive: an adjective in attributive position, a participle
in attributive position AS AN ORDINARY MODIFIER of a noun
distinct from its own verbal-expression relation (see the
'attributive participle' TODO above), or a prepositional phrase
modifying a noun, has relatedtoken1 -> the noun's id,
relationship1 = 'attributive'. Example (adjective): ἐμήν in the
εἵλου example above. Example (prepositional phrase):
# TODO: syntax_model.md's own worked example here ("attributive
# to a noun: in the phrase pugna ad Cannas...") is still the
# untranslated Latin example, apparently left over when the
# document was adapted for Greek. Substituting a constructed
# Greek example instead: in 'ἡ μάχη ἡ ἐν Μαραθῶνι', ἐν has
# relatedtoken1 -> μάχη, relationship1 'attributive', and
# Μαραθῶνι has relatedtoken1 -> ἐν, relationship1 'object of
# preposition'. An adjective used as a substantive (standing in
# for a noun) is treated as a noun/pronoun instead, not as
# attributive. Example: in 'ἐκείνη μὲν ἀπηλλάγη', ἐκείνη has
# relatedtoken1 -> ἀπηλλάγη, relationship1 'subject' (not
# 'attributive' or 'demonstrative' -- it stands for a noun
# here, it does not modify one).
- demonstrative: a demonstrative pronoun modifying a noun -- unlike
an ordinary adjective, NOT in attributive position -- has
relatedtoken1 -> the noun's id, relationship1 = 'demonstrative'.
Example: in 'ταύτην ἔλαβον τὴν δίκην', ταύτην has relatedtoken1
-> δίκην, relationship1 'demonstrative'.
- adverbial (bare adverb): an adverb modifying a verb has
relatedtoken1 -> the verb's id, relationship1 = 'adverbial'.
Example: in 'διαρρήδην εἴρηται', διαρρήδην has relatedtoken1 ->
εἴρηται, relationship1 'adverbial'. An adverb can also stand in
attributive position modifying a noun -- same relationship1
value either way, just a noun instead of a verb on the other
end. Example: in 'δᾷδας λαβόντες ἐκ τοῦ ἐγγύτατα καπηλείου
εἰσερχόμεθα', ἐγγύτατα has relatedtoken1 -> καπηλείου,
relationship1 'adverbial'.
- prepositional phrases: the preposition has relatedtoken1 -> the
id of the verb (adverbial) or noun (attributive) it modifies,
relationship1 = 'adverbial' or 'attributive'. The noun/pronoun
it governs has relatedtoken1 -> the id of the preposition,
relationship1 = 'object of preposition' (or relatedtoken2/
relationship2 if relatedtoken1 is already used for a
relative-pronoun link). Example: in 'γυναῖκα ἠγαγόμην εἰς τὴν
οἰκίαν', εἰς has relatedtoken1 -> ἠγαγόμην, relationship1
'adverbial', and οἰκίαν has relatedtoken1 -> εἰς, relationship1
'object of preposition'.
- genitive: a noun or pronoun in the genitive that modifies
ANOTHER NOUN -- and isn't already covered by a more specific
relation above (object of preposition, genitive absolute, etc)
-- has relatedtoken1 -> the id of that noun, relationship1 =
'genitive'. This is purely a syntactic (case-function) label,
not a semantic one -- don't distinguish e.g. possessive vs.
partitive genitive. Example: in 'ᾤχετο εἰς τὸ ἱερὸν μετὰ τῆς
μητρὸς τῆς ἐκείνου', ἐκείνου has relatedtoken1 -> μητρός,
relationship1 'genitive'.
- dative / accusative: a noun in the dative or accusative case
that depends on a verb or another noun -- and isn't already
covered by a more specific relation above (subject, direct
object, object of preposition, etc) -- has relatedtoken1 -> the
id of the verb or noun it depends on, relationship1 = the
matching case name ('dative' or 'accusative'). Example (dative,
linked to a verb): in 'οὔτε ἔχθρα ἐμοὶ καὶ ἐκείνῳ οὐδεμία ἦν
πλὴν ταύτης, οὔτε χρημάτων ἕνεκα ἔπραξα ταῦτα', both ἐμοί and
ἐκείνῳ have relatedtoken1 -> ἦν, relationship1 'dative'. Example
(accusative of extent of time, linked to a verb): in 'ταῦτα
πολὺν χρόνον οὕτως ἐγίγνετο', χρόνον has relatedtoken1 ->
ἐγίγνετο, relationship1 'accusative'. Note that Greek has NO
ablative case and NO dedicated relation label for one -- a
Latin-scheme 'ablative' relation simply does not arise here.
- vocative: a noun in the vocative case (direct address) has
relatedtoken1 -> the id of the verb of the clause it's addressed
within, relationship1 = 'vocative'. Unlike 'genitive'/'dative'/
'accusative' above, a vocative relates to a verb only, never to
another noun. Example: in 'ἐγὼ μὲν οὖν, ὦ ἄνδρες, οὐκ ἰδίαν ὑπὲρ
ἐμαυτοῦ νομίζω ταύτην γενέσθαι τὴν τιμωρίαν', ἄνδρες has
relatedtoken1 -> νομίζω, relationship1 'vocative'.
- apposition: when one noun stands in apposition to another, the
appositive has relatedtoken1 -> the id of the first (the noun it
restates or further identifies), relationship1 = 'apposition'. A
genitive depending on either noun still gets its own ordinary
'genitive' relation, pointing at whichever noun it actually
depends on -- apposition doesn't change that.
# TODO: syntax_model.md gives only the general definition here,
# no worked Greek example. Constructed illustration: in
# 'Δημοσθένης ὁ ῥήτωρ ἦλθεν', ῥήτωρ has relatedtoken1 ->
# Δημοσθένης, relationship1 'apposition' (and ὁ has
# relatedtoken1 -> ῥήτωρ, relationship1 'article').
- exclamation: an exclamatory word has relatedtoken1 -> the id of
the verb of its own verbal unit, relationship1 = 'exclamation' --
EXCEPT the frequent exclamatory particle ὦ introducing a
vocative, which instead has relatedtoken1 -> the id of the
vocative noun/pronoun it introduces (not the verb directly).
Example: in 'ἐγὼ μὲν οὖν, ὦ ἄνδρες, οὐκ ἰδίαν ὑπὲρ ἐμαυτοῦ
νομίζω ταύτην γενέσθαι τὴν τιμωρίαν, ἀλλ' ὑπὲρ τῆς πόλεως
ἁπάσης', ὦ has relatedtoken1 -> ἄνδρες (the vocative it
introduces), relationship1 'exclamation' -- NOT relatedtoken1 ->
νομίζω directly, even though ἄνδρες's own relatedtoken1 does
point to νομίζω (relationship1 'vocative'). This same pattern
applies wherever ὦ introduces a vocative elsewhere in a passage,
e.g. 'πρῶτον μὲν οὖν, ὦ ἄνδρες, ...': ὦ -> ἄνδρες, 'exclamation'.
Only assign relations described above. Leave relatedtoken/
relationship fields unset for tokens with no relation of these
kinds -- not every token will have one (e.g. a bare accusative of
respect not covered above). Use
only the token ids given in the input `tokens` list, the sentinel
'root', or a NEW id you create for an implied token (see below), in
your output; never invent an id for anything else.
(3) implied/elided tokens. grammatike recognizes two DIFFERENT
situations where a verbal expression exists grammatically but has
no surface realization in the passage at all -- rather than skip
these, add a NEW entry to tokengraph (and a matching new entry to
verbalunits, since an implied token always anchors its own
verbal expression) with: a brand-new id, not used by any entry in
tokens or elsewhere in your own output (see the naming rule
below); the matching tokentype below; and no token value (leave
it unset/None) -- these go together, and 'implied eimi' /
'implied repetition' are the ONLY two tokentype values whose id
isn't one of tokens' own ids and whose token is empty.
- tokentype 'implied eimi': an elided form of εἰμί ('to be') in a
predicate expression. The documented case is an implied
INFINITIVE of εἰμί inside indirect statement: the implied token
anchors a verbal expression classified 'indirect statement' and
'linking verb', relates to its governing verb of thinking/saying
via relatedtoken1/relationship1 = 'indirect statement' exactly
as a written-out infinitive would, and the subject/predicate of
the predication relate to it as 'subject'/'predicate' exactly as
they would to any linking verb. Example: 'ταύτην τὴν ὕβριν
ἅπαντες ἄνθρωποι δεινοτάτην ἡγοῦνται' has an independent verbal
expression ἡγοῦνται governing an implied infinitive of εἰμί
(syntactic type 'indirect statement', semantic type 'linking
verb') whose relatedtoken1 -> ἡγοῦνται, relationship1 'indirect
statement'; 'ταύτην τὴν ὕβριν' relates to it as 'subject' and
δεινοτάτην as 'predicate'.
# TODO: the two sub-cases below extrapolate from that one
# documented example and from the general phrasing "elided εἰμί
# in predicate expressions" -- syntax_model.md gives no worked
# example for either:
- a bare predicate construction with NO governing verb at all
(subject + predicate noun/adjective, nothing else): the
implied token anchors a verbal expression classified
'independent' (or 'dependent', if the elided-εἰμί clause is
itself subordinate) and 'linking verb'; subject and
predicate relate to it exactly as they would to any linking
verb.
- an omitted conjugated εἰμί in a compound perfect-system form
(the participle left standing alone for its auxiliary): the
implied token stands in for the omitted form of εἰμί --
everything that would normally relate to that auxiliary
(subject, the participle's own 'auxiliary' relation, etc.)
relates to the implied token instead, exactly as if the
auxiliary had been written out.
- tokentype 'implied repetition': a verb elided from a later
verbal expression in a coordinated series because it repeats the
verb of an earlier one. Add ONE implied token per omitted
repeated verb, repeating that verb's OWN syntactic_type and
semantic_type exactly (whatever those happen to be in context --
not necessarily 'independent'/'intransitive'), and give whatever
would relate to the omitted verb (subject, adverbial, a
connecting word, etc.) its normal relation into the implied
token instead, exactly as if the verb had been repeated.
Example: 'ἐγὼ μὲν ἄνω διῃτώμην, αἱ δὲ γυναῖκες κάτω' has an
explicit verbal expression διῃτώμην ('independent'/
'intransitive') with subject ἐγώ, and a second, implied verbal
expression (tokentype 'implied repetition') repeating
διῃτώμην's own 'independent'/'intransitive' classification, with
subject γυναῖκες and adverbial κάτω relating to the implied
token instead of to διῃτώμην.
Naming an implied token's id (both tokentypes): append '_implied'
to the id of the LAST real token in `tokens` that precedes where
the elided word would have stood (or, if the elided word would
come before every real token in the sentence, the FIRST real
token's id instead). If more than one implied token is ever
needed at the same position, append '2', '3', ... after '_implied'
to keep them unique (e.g. 't5_implied', 't5_implied2'). Place the
new `tokengraph` entry at the list position where the elided word
would have appeared, among the tokens of its own clause -- this
keeps it grouped with the rest of its verbal expression for
anything that reads `tokengraph` in order.
689def validate(tokens: List[Token], result) -> List[str]: 690 """Check that every id the LM produced actually exists among `tokens` 691 -- OR is a legitimately new implied token (tokentype in 692 IMPLIED_TOKENTYPES -- 'implied eimi' or 'implied repetition'; see 693 SyntaxAnalysis's docstring) -- and that implied tokens themselves are 694 well-formed. Returns a list of human-readable problem descriptions 695 (empty if clean). 696 697 'root' is a special sentinel value for an independent verb's own 698 relatedtoken1 (see SyntaxAnalysis's docstring) -- it is never treated as 699 an unknown id, but syntax_model.md also requires that no actual token 700 ever be assigned the id 'root', so that's checked here too. 701 702 Implied tokens get their own, narrower checks: a tokengraph entry 703 claiming an IMPLIED_TOKENTYPES value must use a genuinely NEW id (not 704 one already in `tokens`) and must leave `token` unset (None) -- getting 705 either wrong is exactly the kind of malformed output this function 706 exists to catch, not a legitimate implied token. A non-implied entry, 707 conversely, must use one of `tokens`' own ids and must NOT have 708 `token=None` -- only 'implied eimi'/'implied repetition' may omit real 709 surface text.""" 710 valid_ids = {t.id for t in tokens} 711 problems = [] 712 713 if "root" in valid_ids: 714 problems.append( 715 "token id 'root' is reserved as the sentinel relatedtoken1 " 716 "value for independent verbs and must not be assigned to an " 717 "actual token" 718 ) 719 720 implied_ids = {tok.id for tok in result.tokengraph if tok.tokentype in IMPLIED_TOKENTYPES} 721 known_ids = valid_ids | implied_ids 722 723 for tok in result.tokengraph: 724 if tok.tokentype in IMPLIED_TOKENTYPES: 725 if tok.id in valid_ids: 726 problems.append( 727 f"tokengraph entry {tok.id!r} is tokentype={tok.tokentype!r} but " 728 "reuses an id already in the input `tokens` list -- an " 729 "implied token must use a new id" 730 ) 731 if tok.token is not None: 732 problems.append( 733 f"tokengraph entry {tok.id!r} is tokentype={tok.tokentype!r} but " 734 f"has a non-None token value {tok.token!r} -- an implied " 735 "token's text must be left unset" 736 ) 737 else: 738 if tok.id not in valid_ids: 739 problems.append(f"tokengraph entry has unknown id {tok.id!r}") 740 if tok.token is None: 741 problems.append( 742 f"tokengraph entry {tok.id!r} has token=None but " 743 f"tokentype={tok.tokentype!r} -- only 'implied eimi'/" 744 "'implied repetition' may omit surface text" 745 ) 746 for field in ("relatedtoken1", "relatedtoken2"): 747 val = getattr(tok, field) 748 if val is not None and val != "root" and val not in known_ids: 749 problems.append(f"token {tok.id!r} {field}={val!r} is not a known token id") 750 751 for vu in result.verbalunits: 752 if vu.id not in known_ids: 753 problems.append(f"verbal expression id {vu.id!r} is not a known token id") 754 755 return problems
Check that every id the LM produced actually exists among tokens
-- OR is a legitimately new implied token (tokentype in
IMPLIED_TOKENTYPES -- 'implied eimi' or 'implied repetition'; see
SyntaxAnalysis's docstring) -- and that implied tokens themselves are
well-formed. Returns a list of human-readable problem descriptions
(empty if clean).
'root' is a special sentinel value for an independent verb's own relatedtoken1 (see SyntaxAnalysis's docstring) -- it is never treated as an unknown id, but syntax_model.md also requires that no actual token ever be assigned the id 'root', so that's checked here too.
Implied tokens get their own, narrower checks: a tokengraph entry
claiming an IMPLIED_TOKENTYPES value must use a genuinely NEW id (not
one already in tokens) and must leave token unset (None) -- getting
either wrong is exactly the kind of malformed output this function
exists to catch, not a legitimate implied token. A non-implied entry,
conversely, must use one of tokens' own ids and must NOT have
token=None -- only 'implied eimi'/'implied repetition' may omit real
surface text.
758def print_analysis(tokens: List[Token], result): 759 print("Tokens:") 760 for t in tokens: 761 print(f" {t.id:>4} {t.text}") 762 763 print("\nVerbal expressions:") 764 for vu in result.verbalunits: 765 print(f" id={vu.id} syntactic_type={vu.syntactic_type} semantic_type={vu.semantic_type}") 766 767 print("\nToken graph:") 768 for tok in result.tokengraph: 769 rels = [] 770 if tok.relationship1: 771 rels.append(f"{tok.relationship1} -> {tok.relatedtoken1}") 772 if tok.relationship2: 773 rels.append(f"{tok.relationship2} -> {tok.relatedtoken2}") 774 rel_str = "; ".join(rels) if rels else "-" 775 vu_str = f" [verbal unit {tok.verbalunitid}]" if tok.verbalunitid else "" 776 token_str = tok.token if tok.token is not None else f"({tok.tokentype})" 777 print(f" {tok.id:>4} {token_str:<15} type={tok.tokentype:<11} lemma={tok.lemma or '-':<15} {rel_str}{vu_str}")
228def segment_sources(sources: List[CitedText]) -> List[Sentence]: 229 """Deterministically segment `sources` into sentences, in two passes: 230 231 1. _group_into_sentences() splits the input into sentence-strings by 232 regex alone (a period or interrogative always ends one; nothing else 233 does) -- no tokenization involved yet. 234 2. Tokenize each sentence's string(s) in turn (see this module's own 235 docstring for the two tokenization decisions this involves), handing 236 out ids sequentially (t0, t1, ...) from ONE counter that runs across 237 the whole input -- never restarted per sentence, so a Sentence stays 238 a contiguous slice of the passage's global id sequence. 239 240 Running this on the same `sources` again always produces the same ids 241 for the same tokens -- there is nothing non-deterministic left in this 242 stage at all. 243 """ 244 next_id = 0 245 sentences: List[Sentence] = [] 246 247 for fragments in _group_into_sentences(sources): 248 tokens: List[Token] = [] 249 for citation, text in fragments: 250 for word in text.split(): 251 for piece in _split_word(word): 252 tokens.append(Token(id=f"t{next_id}", text=piece, citation=citation)) 253 next_id += 1 254 sentences.append(Sentence(tokens=tokens)) 255 256 return sentences
Deterministically segment sources into sentences, in two passes:
- _group_into_sentences() splits the input into sentence-strings by regex alone (a period or interrogative always ends one; nothing else does) -- no tokenization involved yet.
- Tokenize each sentence's string(s) in turn (see this module's own docstring for the two tokenization decisions this involves), handing out ids sequentially (t0, t1, ...) from ONE counter that runs across the whole input -- never restarted per sentence, so a Sentence stays a contiguous slice of the passage's global id sequence.
Running this on the same sources again always produces the same ids
for the same tokens -- there is nothing non-deterministic left in this
stage at all.
39def analyze_sources(sources: List[CitedText]) -> Tuple[List[Sentence], list]: 40 """Segment `sources` into citation-aware sentences, run each sentence's 41 tokens through SyntaxAnalysis, and validate each result. 42 43 Returns (sentences, results): results[i] is the SyntaxAnalysis result 44 for sentences[i], same order, one entry per sentence. 45 46 Each sentence's SyntaxAnalysis call goes through 47 `token_budget.analyze_with_retry()` rather than calling `analyze()` 48 directly, so a sentence whose analysis needs more output than a fixed 49 `max_tokens` would allow (a long or deeply subordinated sentence) gets 50 an estimated, appropriately-sized budget up front, and a retry with a 51 larger one if it still comes back truncated -- see token_budget.py's 52 module docstring for the full design. 53 """ 54 sentences = segment_sources(sources) 55 56 results = [] 57 for sentence in sentences: 58 result = analyze_with_retry(passage=_render_sentence_text(sentence), tokens=sentence.tokens) 59 60 problems = validate(sentence.tokens, result) 61 if problems: 62 first_id = sentence.tokens[0].id if sentence.tokens else "?" 63 print(f"Validation warnings (sentence starting at {first_id}):") 64 for p in problems: 65 print(f" - {p}") 66 67 results.append(result) 68 69 return sentences, results
Segment sources into citation-aware sentences, run each sentence's
tokens through SyntaxAnalysis, and validate each result.
Returns (sentences, results): results[i] is the SyntaxAnalysis result for sentences[i], same order, one entry per sentence.
Each sentence's SyntaxAnalysis call goes through
token_budget.analyze_with_retry() rather than calling analyze()
directly, so a sentence whose analysis needs more output than a fixed
max_tokens would allow (a long or deeply subordinated sentence) gets
an estimated, appropriately-sized budget up front, and a retry with a
larger one if it still comes back truncated -- see token_budget.py's
module docstring for the full design.
72def combined_tokengraph(results) -> list: 73 """Concatenate every sentence result's tokengraph, in order, into one 74 flat list spanning the whole input -- since token ids are global, 75 tokengraph_to_mermaid() (mermaid.py) needs no changes at all to render 76 this as one diagram for a multi-sentence, multi-citation passage.""" 77 combined = [] 78 for result in results: 79 combined.extend(result.tokengraph) 80 return combined
Concatenate every sentence result's tokengraph, in order, into one flat list spanning the whole input -- since token ids are global, tokengraph_to_mermaid() (mermaid.py) needs no changes at all to render this as one diagram for a multi-sentence, multi-citation passage.
83def analyze_passage(passage: str, citation: str = "") -> Tuple[List[Sentence], list]: 84 """Convenience wrapper for the common case of a single string rather 85 than a list of citation-labeled CitedText sources -- kept here so 86 existing callers (syntaxer_main.py, the marimo notebook) have a 87 one-string entry point rather than needing to build a CitedText list 88 themselves for the ordinary case of one passage from one source. 89 90 Wraps `passage` as one CitedText (using `citation` if given, else an 91 empty string -- fine for callers that don't track citations) and runs 92 it through analyze_sources(). Returns (sentences, results) -- the exact 93 same shape analyze_sources() returns, one entry per sentence 94 segmentation finds in `passage`, in order. 95 96 `passage` may contain any number of sentences: each is segmented and 97 analyzed successively, same as if you'd called analyze_sources() with 98 one CitedText yourself. (An earlier version of this function raised 99 ValueError on multi-sentence input and returned a single (tokens, 100 result) pair for exactly one sentence; callers written against that 101 contract need to change to unpack (sentences, results) and iterate.) 102 103 Example: `analyze_passage("...", citation="urn:cts:greekLit:tlg0059.tlg030.perseus-grc2:1")` 104 for a passage of Plato, or `analyze_passage("...", citation="Lysias 1.1")` 105 for a citation scheme keyed by author/work/section instead of a URN. 106 """ 107 return analyze_sources([CitedText(citation=citation, text=passage)])
Convenience wrapper for the common case of a single string rather than a list of citation-labeled CitedText sources -- kept here so existing callers (syntaxer_main.py, the marimo notebook) have a one-string entry point rather than needing to build a CitedText list themselves for the ordinary case of one passage from one source.
Wraps passage as one CitedText (using citation if given, else an
empty string -- fine for callers that don't track citations) and runs
it through analyze_sources(). Returns (sentences, results) -- the exact
same shape analyze_sources() returns, one entry per sentence
segmentation finds in passage, in order.
passage may contain any number of sentences: each is segmented and
analyzed successively, same as if you'd called analyze_sources() with
one CitedText yourself. (An earlier version of this function raised
ValueError on multi-sentence input and returned a single (tokens,
result) pair for exactly one sentence; callers written against that
contract need to change to unpack (sentences, results) and iterate.)
Example: analyze_passage("...", citation="urn:cts:greekLit:tlg0059.tlg030.perseus-grc2:1")
for a passage of Plato, or analyze_passage("...", citation="Lysias 1.1")
for a citation scheme keyed by author/work/section instead of a URN.
234def serialize_analyses( 235 sentences: List[Sentence], 236 verbalunits: List[VerbalExpression], 237 tokengraph: List[TokenAnalysis], 238 results: Optional[list] = None, 239) -> Tuple[str, List[str]]: 240 """Build the exact text write_analyses() would write to a file, and 241 return it directly as `(content, warnings)` instead of writing it 242 anywhere -- see the module docstring for why this exists alongside 243 write_analyses(). All three lists are flat and span however many 244 sentences/citation sources were analyzed -- the same shape 245 analyze_sources() (for `sentences`) and combined_tokengraph() (for 246 `tokengraph`; `verbalunits` needs the analogous concatenation, which 247 this function does not do for you) already produce. 248 249 `results` is optional -- the same list analyze_sources()/ 250 analyze_passage() return alongside `sentences` (one entry per 251 sentence, each with a `.reasoning` attribute; a dspy prediction from 252 SyntaxAnalysis has exactly this shape). When given, it must have 253 exactly one entry per entry of `sentences` (raises ValueError 254 otherwise, naming the mismatched lengths) -- one '#!llm' block is 255 written per sentence, in order, each recording the `MODEL` environment 256 variable's current value and that sentence's own `result.reasoning` 257 text (see the module docstring for the exact block shape). Omit 258 `results` (the default) to write a file with no '#!llm' blocks at all, 259 exactly as before this parameter existed. 260 261 `content` is the complete file body, including its trailing newline, 262 exactly as write_analyses() would have written it. `warnings` is a 263 list of warning strings (empty if nothing looks wrong), matching this 264 codebase's "degrade visibly, don't raise" convention for warnings 265 distinct from hard errors: 266 267 - a tokengraph or verbalunits entry whose id isn't found among any 268 given sentence's tokens (so no citation is known for it -- an empty 269 context is written, same as a token that legitimately has no 270 citation at all, but this case specifically means the id wasn't 271 found anywhere in `sentences` -- EXCEPT for an implied token 272 (tokentype in IMPLIED_TOKENTYPES), which never appears in any sentence's own 273 `tokens` by design, so this warning is suppressed for those 274 specifically rather than flagged as an anomaly); 275 - a sentence whose own tokens don't form a contiguous, matching-order 276 run in `tokengraph`'s given order -- see the module docstring for 277 why this matters for read_analyses() to recover sentence boundaries 278 correctly. 279 280 Raises ValueError for a sentence with no tokens at all (nothing to 281 derive first_token/last_token from), or if any field value contains 282 '|' or a newline (see `_field`). 283 """ 284 warnings: List[str] = [] 285 286 id_to_citation: Dict[str, Optional[str]] = {} 287 for sentence in sentences: 288 for tok in sentence.tokens: 289 id_to_citation[tok.id] = tok.citation 290 291 # Implied tokens (tokentype in IMPLIED_TOKENTYPES) never appear in any sentence's 292 # own `tokens` list by design (see the module docstring's note above) 293 # -- so having no recorded citation is expected and correct for them, 294 # not the kind of anomaly the "not found among the given sentences' 295 # tokens" warning below exists to flag. 296 implied_ids = {tok.id for tok in tokengraph if tok.tokentype in IMPLIED_TOKENTYPES} 297 298 tg_index = {tok.id: i for i, tok in enumerate(tokengraph)} 299 300 lines: List[str] = [] 301 302 lines.append(SENTENCES_LABEL) 303 lines.append(SENTENCES_HEADER) 304 for s_idx, sentence in enumerate(sentences): 305 if not sentence.tokens: 306 raise ValueError( 307 f"sentence at index {s_idx} has no tokens -- cannot derive " 308 "first_token/last_token for an empty sentence" 309 ) 310 first_tok = sentence.tokens[0] 311 last_tok = sentence.tokens[-1] 312 313 first_pos = tg_index.get(first_tok.id) 314 last_pos = tg_index.get(last_tok.id) 315 if first_pos is None or last_pos is None: 316 warnings.append( 317 f"sentence at index {s_idx} (tokens {first_tok.id!r}.." 318 f"{last_tok.id!r}) has a boundary token not present in the " 319 "given tokengraph -- reading this file back may not " 320 "reconstruct this sentence's tokens correctly" 321 ) 322 else: 323 expected_ids = [t.id for t in sentence.tokens] 324 # Implied tokens (tokentype in IMPLIED_TOKENTYPES) were never part of the 325 # original per-sentence `tokens` list -- they're synthesized by 326 # analysis itself -- so exclude them here before comparing, or 327 # every sentence containing one would spuriously warn. 328 actual_ids = [ 329 tok.id 330 for tok in tokengraph[first_pos : last_pos + 1] 331 if tok.tokentype not in IMPLIED_TOKENTYPES 332 ] 333 if actual_ids != expected_ids: 334 warnings.append( 335 f"sentence at index {s_idx} (tokens {first_tok.id!r}.." 336 f"{last_tok.id!r}) is not a contiguous, matching-order " 337 "run in the given tokengraph -- reading this file back " 338 "may not reconstruct this sentence's tokens correctly" 339 ) 340 341 where = f"#!sentences row for sentence {s_idx}" 342 lines.append( 343 "|".join( 344 [ 345 _field(first_tok.citation, where=where), 346 _field(first_tok.id, where=where), 347 _field(last_tok.citation, where=where), 348 _field(last_tok.id, where=where), 349 ] 350 ) 351 ) 352 353 lines.append("") 354 lines.append(VERBAL_UNITS_LABEL) 355 lines.append(VERBAL_UNITS_HEADER) 356 for vu in verbalunits: 357 if vu.id not in id_to_citation and vu.id not in implied_ids: 358 warnings.append( 359 f"verbal expression {vu.id!r} not found among the given " 360 "sentences' tokens -- writing an empty context for it" 361 ) 362 where = f"#!verbal_units row for {vu.id}" 363 lines.append( 364 "|".join( 365 [ 366 _field(id_to_citation.get(vu.id), where=where), 367 _field(vu.id, where=where), 368 _field(vu.syntactic_type, where=where), 369 _field(vu.semantic_type, where=where), 370 ] 371 ) 372 ) 373 374 lines.append("") 375 lines.append(TOKENS_LABEL) 376 lines.append(TOKENS_HEADER) 377 for tok in tokengraph: 378 if tok.id not in id_to_citation and tok.id not in implied_ids: 379 warnings.append( 380 f"token {tok.id!r} not found among the given sentences' " 381 "tokens -- writing an empty context for it" 382 ) 383 where = f"#!tokens row for {tok.id}" 384 lines.append( 385 "|".join( 386 [ 387 _field(id_to_citation.get(tok.id), where=where), 388 _field(tok.id, where=where), 389 _field(tok.tokentype, where=where), 390 _field(tok.token, where=where), 391 _field(tok.lemma, where=where), 392 _field(tok.verbalunitid, where=where), 393 _field(tok.relatedtoken1, where=where), 394 _field(tok.relationship1, where=where), 395 _field(tok.relatedtoken2, where=where), 396 _field(tok.relationship2, where=where), 397 ] 398 ) 399 ) 400 401 if results is not None: 402 if len(results) != len(sentences): 403 raise ValueError( 404 f"results has {len(results)} entries but sentences has " 405 f"{len(sentences)} -- serialize_analyses() needs exactly " 406 "one result per sentence to label each '#!llm' block" 407 ) 408 model = os.environ.get("MODEL") 409 for s_idx, result in enumerate(results): 410 where = f"#!llm block for sentence {s_idx}" 411 lines.append("") 412 lines.append(LLM_LABEL) 413 lines.append(_MODEL_PREFIX + _field(model, where=where)) 414 normalized_reasoning = str(result.reasoning).replace("\r\n", "\n").replace("\r", "\n") 415 for reasoning_line in normalized_reasoning.split("\n"): 416 _validate_llm_body_line(reasoning_line, where=where) 417 lines.append(reasoning_line) 418 419 return "\n".join(lines) + "\n", warnings
Build the exact text write_analyses() would write to a file, and
return it directly as (content, warnings) instead of writing it
anywhere -- see the module docstring for why this exists alongside
write_analyses(). All three lists are flat and span however many
sentences/citation sources were analyzed -- the same shape
analyze_sources() (for sentences) and combined_tokengraph() (for
tokengraph; verbalunits needs the analogous concatenation, which
this function does not do for you) already produce.
results is optional -- the same list analyze_sources()/
analyze_passage() return alongside sentences (one entry per
sentence, each with a .reasoning attribute; a dspy prediction from
SyntaxAnalysis has exactly this shape). When given, it must have
exactly one entry per entry of sentences (raises ValueError
otherwise, naming the mismatched lengths) -- one '#!llm' block is
written per sentence, in order, each recording the MODEL environment
variable's current value and that sentence's own result.reasoning
text (see the module docstring for the exact block shape). Omit
results (the default) to write a file with no '#!llm' blocks at all,
exactly as before this parameter existed.
content is the complete file body, including its trailing newline,
exactly as write_analyses() would have written it. warnings is a
list of warning strings (empty if nothing looks wrong), matching this
codebase's "degrade visibly, don't raise" convention for warnings
distinct from hard errors:
- a tokengraph or verbalunits entry whose id isn't found among any
given sentence's tokens (so no citation is known for it -- an empty
context is written, same as a token that legitimately has no
citation at all, but this case specifically means the id wasn't
found anywhere in
sentences-- EXCEPT for an implied token (tokentype in IMPLIED_TOKENTYPES), which never appears in any sentence's 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 if any field value contains
'|' or a newline (see _field).
422def write_analyses( 423 sentences: List[Sentence], 424 verbalunits: List[VerbalExpression], 425 tokengraph: List[TokenAnalysis], 426 path: str, 427 results: Optional[list] = None, 428) -> List[str]: 429 """Write `sentences`/`verbalunits`/`tokengraph` to `path` in the format 430 this module's docstring describes -- see serialize_analyses() (which 431 this is a thin wrapper around) for what's actually written and for the 432 full list of warnings this can return. `results` is optional and 433 passed straight through -- see serialize_analyses()'s own docstring 434 for the '#!llm' blocks it produces when given. 435 436 Returns a list of warning strings (empty if nothing looks wrong); see 437 serialize_analyses()'s docstring for what each one means. Raises 438 ValueError for a sentence with no tokens at all (nothing to derive 439 first_token/last_token from), if any field value contains '|' or a 440 newline (see `_field`), if `results` is given with a different length 441 than `sentences`, or if a reasoning line collides with a block label 442 (see `_validate_llm_body_line`) -- all raised by serialize_analyses() 443 before this function ever opens `path`. 444 """ 445 content, warnings = serialize_analyses(sentences, verbalunits, tokengraph, results=results) 446 with open(path, "w", encoding="utf-8") as f: 447 f.write(content) 448 return warnings
Write sentences/verbalunits/tokengraph to path in the format
this module's docstring describes -- see serialize_analyses() (which
this is a thin wrapper around) for what's actually written and for the
full list of warnings this can return. results is optional and
passed straight through -- see serialize_analyses()'s own docstring
for the '#!llm' blocks it produces when given.
Returns a list of warning strings (empty if nothing looks wrong); see
serialize_analyses()'s docstring for what each one means. Raises
ValueError for a sentence with no tokens at all (nothing to derive
first_token/last_token from), if any field value contains '|' or a
newline (see _field), if results is given with a different length
than sentences, or if a reasoning line collides with a block label
(see _validate_llm_body_line) -- all raised by serialize_analyses()
before this function ever opens path.
499def read_analyses( 500 path: str, 501) -> Tuple[List[TokenAnalysis], List[VerbalExpression], List[Sentence]]: 502 """Read `path` (as written by write_analyses()/serialize_analyses()) and 503 reconstruct `(tokengraph, verbalunits, sentences)` -- in that order, 504 matching the order these three types are usually discussed in this 505 codebase (the token-level graph, then the verbal-expression table, 506 then the sentence/citation structure that supplies context for both). 507 508 Each of the three block labels may appear more than once in `path` 509 (see the module docstring) -- every instance contributes its own rows, 510 in file order, to that label's combined row list, as if the file were 511 the concatenation of however many separate write_analyses()/ 512 serialize_analyses() outputs it actually is. Any '#!llm' blocks in 513 `path` are checked for well-formedness and then skipped -- their 514 (model, reasoning) content isn't part of this function's return shape; 515 use read_llm_notes() to get it. 516 517 Raises ValueError, naming the offending line and problem, for anything 518 that isn't a faithful, internally-consistent file written by 519 write_analyses() -- see this module's own docstring for exactly what's 520 checked. This function does not accept a file with warnings-worthy 521 inconsistencies silently patched over; if write_analyses() returned 522 warnings when the file was written, fix the input and re-write it 523 rather than expecting read_analyses() to compensate. 524 """ 525 with open(path, "r", encoding="utf-8") as f: 526 raw_lines = f.read().splitlines() 527 528 # blocks[label] accumulates (line_no, line) data rows across every 529 # instance of that label found in the file, in file order. A label 530 # line always starts a new instance and must be immediately followed 531 # by that label's header line (`awaiting_header` tracks this) before 532 # any more data rows can be appended to it -- this holds per instance, 533 # not just for the label's first appearance, so every repeated block 534 # must repeat its own header line too. 535 blocks: Dict[str, List[Tuple[int, str]]] = {label: [] for label in _EXPECTED_HEADERS} 536 seen_labels = set() 537 current_label: Optional[str] = None 538 awaiting_header = False 539 540 i = 0 541 n = len(raw_lines) 542 while i < n: 543 line_no = i + 1 544 line = raw_lines[i] 545 546 if line == LLM_LABEL: 547 if awaiting_header: 548 raise ValueError( 549 f"line {line_no}: block {current_label!r} has a label " 550 "line but no header line before the next block starts" 551 ) 552 # Validated and discarded here -- current_label/awaiting_header 553 # are left exactly as they were, so an '#!llm' block can sit 554 # between two other blocks (or inside one's own data run, 555 # though this module never writes it that way itself) without 556 # disturbing whatever block was already in progress. 557 _model, _reasoning, i = _scan_llm_block(raw_lines, i + 1, line_no) 558 continue 559 560 if line.strip() == "": 561 i += 1 562 continue 563 564 if line in _EXPECTED_HEADERS: 565 if awaiting_header: 566 raise ValueError( 567 f"line {line_no}: block {current_label!r} has a label " 568 "line but no header line before the next block starts" 569 ) 570 current_label = line 571 seen_labels.add(line) 572 awaiting_header = True 573 i += 1 574 continue 575 576 if current_label is None: 577 raise ValueError( 578 f"line {line_no}: data line {line!r} appears before any " 579 "'#!' block label" 580 ) 581 582 if awaiting_header: 583 expected = _EXPECTED_HEADERS[current_label] 584 if line != expected: 585 raise ValueError( 586 f"line {line_no}: expected header {expected!r} for " 587 f"block {current_label!r}, got {line!r}" 588 ) 589 awaiting_header = False 590 i += 1 591 continue 592 593 blocks[current_label].append((line_no, line)) 594 i += 1 595 596 missing = sorted(set(_EXPECTED_HEADERS) - seen_labels) 597 if missing: 598 raise ValueError(f"file is missing required block(s): {missing}") 599 if awaiting_header: 600 raise ValueError( 601 f"block {current_label!r} has a label line but no header line " 602 "(and no data) -- the file ends too early" 603 ) 604 605 # --- #!tokens: build the TokenAnalysis list, the id->citation map, 606 # and the row-order index sentence reconstruction relies on. --- 607 tokengraph: List[TokenAnalysis] = [] 608 id_to_citation: Dict[str, Optional[str]] = {} 609 row_order: List[str] = [] 610 611 for line_no, line in blocks[TOKENS_LABEL]: 612 parts = line.split("|") 613 if len(parts) != 10: 614 raise ValueError( 615 f"line {line_no}: #!tokens row has {len(parts)} columns, " 616 f"expected 10: {line!r}" 617 ) 618 ( 619 context, 620 tok_id, 621 tokentype, 622 text, 623 lemma, 624 verbalunit, 625 related1, 626 relationship1, 627 related2, 628 relationship2, 629 ) = parts 630 if tok_id == "": 631 raise ValueError(f"line {line_no}: #!tokens row has an empty id") 632 if tok_id in id_to_citation: 633 raise ValueError(f"line {line_no}: duplicate token id {tok_id!r} in #!tokens") 634 635 tokengraph.append( 636 TokenAnalysis( 637 id=tok_id, 638 token=_parse_optional(text), 639 tokentype=tokentype, 640 lemma=_parse_optional(lemma), 641 verbalunitid=_parse_optional(verbalunit), 642 relatedtoken1=_parse_optional(related1), 643 relationship1=_parse_optional(relationship1), 644 relatedtoken2=_parse_optional(related2), 645 relationship2=_parse_optional(relationship2), 646 ) 647 ) 648 id_to_citation[tok_id] = _parse_optional(context) 649 row_order.append(tok_id) 650 651 id_position = {tid: i for i, tid in enumerate(row_order)} 652 653 # --- #!verbal_units --- 654 verbalunits: List[VerbalExpression] = [] 655 for line_no, line in blocks[VERBAL_UNITS_LABEL]: 656 parts = line.split("|") 657 if len(parts) != 4: 658 raise ValueError( 659 f"line {line_no}: #!verbal_units row has {len(parts)} " 660 f"columns, expected 4: {line!r}" 661 ) 662 context, vu_id, syntactic_type, semantic_type = parts 663 if vu_id == "": 664 raise ValueError(f"line {line_no}: #!verbal_units row has an empty token id") 665 if vu_id not in id_to_citation: 666 raise ValueError( 667 f"line {line_no}: #!verbal_units references token id " 668 f"{vu_id!r}, which does not appear in the #!tokens block" 669 ) 670 recorded_context = _parse_optional(context) 671 expected_context = id_to_citation[vu_id] 672 if recorded_context != expected_context: 673 raise ValueError( 674 f"line {line_no}: #!verbal_units row's context " 675 f"{recorded_context!r} for token {vu_id!r} does not match " 676 f"the #!tokens block's recorded context {expected_context!r} " 677 "for the same id" 678 ) 679 680 verbalunits.append( 681 VerbalExpression( 682 id=vu_id, 683 syntactic_type=syntactic_type, 684 semantic_type=semantic_type, 685 ) 686 ) 687 688 # --- #!sentences --- 689 sentences: List[Sentence] = [] 690 for line_no, line in blocks[SENTENCES_LABEL]: 691 parts = line.split("|") 692 if len(parts) != 4: 693 raise ValueError( 694 f"line {line_no}: #!sentences row has {len(parts)} " 695 f"columns, expected 4: {line!r}" 696 ) 697 context_begin, first_id, context_end, last_id = parts 698 if first_id == "" or last_id == "": 699 raise ValueError( 700 f"line {line_no}: #!sentences row is missing first_token " 701 f"or last_token: {line!r}" 702 ) 703 if first_id not in id_position or last_id not in id_position: 704 raise ValueError( 705 f"line {line_no}: #!sentences references a first_token/" 706 "last_token id not found in the #!tokens block" 707 ) 708 709 start = id_position[first_id] 710 end = id_position[last_id] 711 if start > end: 712 raise ValueError( 713 f"line {line_no}: #!sentences row's first_token " 714 f"{first_id!r} comes after last_token {last_id!r} in the " 715 "#!tokens block's row order" 716 ) 717 718 parsed_begin = _parse_optional(context_begin) 719 parsed_end = _parse_optional(context_end) 720 if parsed_begin != id_to_citation[first_id]: 721 raise ValueError( 722 f"line {line_no}: #!sentences row's context_begin " 723 f"{parsed_begin!r} does not match the #!tokens block's " 724 f"recorded context {id_to_citation[first_id]!r} for token " 725 f"{first_id!r}" 726 ) 727 if parsed_end != id_to_citation[last_id]: 728 raise ValueError( 729 f"line {line_no}: #!sentences row's context_end " 730 f"{parsed_end!r} does not match the #!tokens block's " 731 f"recorded context {id_to_citation[last_id]!r} for token " 732 f"{last_id!r}" 733 ) 734 735 sentence_ids = [ 736 tid 737 for tid in row_order[start : end + 1] 738 if tokengraph[id_position[tid]].tokentype not in IMPLIED_TOKENTYPES 739 ] 740 sentences.append( 741 Sentence( 742 tokens=[ 743 Token( 744 id=tid, 745 text=tokengraph[id_position[tid]].token, 746 citation=id_to_citation[tid], 747 ) 748 for tid in sentence_ids 749 ] 750 ) 751 ) 752 753 return tokengraph, verbalunits, sentences
Read path (as written by write_analyses()/serialize_analyses()) and
reconstruct (tokengraph, verbalunits, sentences) -- in that order,
matching the order these three types are usually discussed in this
codebase (the token-level graph, then the verbal-expression table,
then the sentence/citation structure that supplies context for both).
Each of the three block labels may appear more than once in path
(see the module docstring) -- every instance contributes its own rows,
in file order, to that label's combined row list, as if the file were
the concatenation of however many separate write_analyses()/
serialize_analyses() outputs it actually is. Any '#!llm' blocks in
path are checked for well-formedness and then skipped -- their
(model, reasoning) content isn't part of this function's return shape;
use read_llm_notes() to get it.
Raises ValueError, naming the offending line and problem, for anything that isn't a faithful, internally-consistent file written by write_analyses() -- see this module's own docstring for exactly what's checked. This function does not accept a file with warnings-worthy inconsistencies silently patched over; if write_analyses() returned warnings when the file was written, fix the input and re-write it rather than expecting read_analyses() to compensate.
756def read_llm_notes(path: str) -> List[Tuple[Optional[str], str]]: 757 """Read `path` (as written by write_analyses()/serialize_analyses()) 758 and return every '#!llm' block's own `(model, reasoning)` pair, in 759 file order -- the counterpart to read_analyses(), which parses the 760 same file but deliberately discards '#!llm' content (see the module 761 docstring for why: none of Sentence/VerbalExpression/TokenAnalysis has 762 a `reasoning` field to reconstruct one into, and changing 763 read_analyses()'s own 3-tuple return would break every existing 764 caller). Concatenates every '#!llm' block found in `path`, the same 765 "multiple instances, in file order" convention read_analyses() already 766 applies to the three core blocks -- so a file built by literally 767 concatenating several write_analyses(..., results=...) outputs returns 768 every one of their reasoning entries, in order, exactly as if they'd 769 all been written by a single call with a longer `results` list. 770 771 `model` is None wherever the 'MODEL' environment variable was unset at 772 write time (an empty 'MODEL=' line, same None-as-empty-field 773 convention used everywhere else in this format); `reasoning` is the 774 exact, verbatim multiline text originally passed as that sentence's 775 own `result.reasoning`, with exactly one trailing blank line stripped 776 (the writer's own block separator -- see the module docstring). 777 778 Returns an empty list for a file with no '#!llm' blocks at all -- 779 including any file written before this parameter existed, or any 780 write_analyses()/serialize_analyses() call that omitted `results`. 781 782 Raises ValueError, naming the line, for a malformed '#!llm' block (a 783 label line with nothing after it before the next block or EOF, or a 784 first line that doesn't start with 'MODEL=') -- the same check 785 read_analyses() applies to every '#!llm' block it skips over, so a 786 file that reads cleanly with one of these two functions reads cleanly 787 with the other. 788 """ 789 with open(path, "r", encoding="utf-8") as f: 790 raw_lines = f.read().splitlines() 791 792 notes: List[Tuple[Optional[str], str]] = [] 793 i = 0 794 n = len(raw_lines) 795 while i < n: 796 if raw_lines[i] == LLM_LABEL: 797 model, reasoning, i = _scan_llm_block(raw_lines, i + 1, i + 1) 798 notes.append((model, reasoning)) 799 else: 800 i += 1 801 802 return notes
Read path (as written by write_analyses()/serialize_analyses())
and return every '#!llm' block's own (model, reasoning) pair, in
file order -- the counterpart to read_analyses(), which parses the
same file but deliberately discards '#!llm' content (see the module
docstring for why: none of Sentence/VerbalExpression/TokenAnalysis has
a reasoning field to reconstruct one into, and changing
read_analyses()'s own 3-tuple return would break every existing
caller). Concatenates every '#!llm' block found in path, the same
"multiple instances, in file order" convention read_analyses() already
applies to the three core blocks -- so a file built by literally
concatenating several write_analyses(..., results=...) outputs returns
every one of their reasoning entries, in order, exactly as if they'd
all been written by a single call with a longer results list.
model is None wherever the 'MODEL' environment variable was unset at
write time (an empty 'MODEL=' line, same None-as-empty-field
convention used everywhere else in this format); reasoning is the
exact, verbatim multiline text originally passed as that sentence's
own result.reasoning, with exactly one trailing blank line stripped
(the writer's own block separator -- see the module docstring).
Returns an empty list for a file with no '#!llm' blocks at all --
including any file written before this parameter existed, or any
write_analyses()/serialize_analyses() call that omitted results.
Raises ValueError, naming the line, for a malformed '#!llm' block (a label line with nothing after it before the next block or EOF, or a first line that doesn't start with 'MODEL=') -- the same check read_analyses() applies to every '#!llm' block it skips over, so a file that reads cleanly with one of these two functions reads cleanly with the other.
805def split_analysis_by_sentence( 806 tokengraph: List[TokenAnalysis], 807 verbalunits: List[VerbalExpression], 808 sentences: List[Sentence], 809) -> List[Tuple[List[TokenAnalysis], List[VerbalExpression]]]: 810 """The inverse of what write_analyses()/serialize_analyses() flatten 811 together: given the same `(tokengraph, verbalunits, sentences)` triple 812 read_analyses() returns (or that analyze_sources()/combined_tokengraph() 813 produce before ever being written to a file), split `tokengraph` and 814 `verbalunits` back into one slice per sentence. 815 816 Returns a list the same length and order as `sentences` -- entry i is 817 `(sentence_tokengraph, sentence_verbalunits)` for `sentences[i]`. Useful 818 for anything that wants to review or render one sentence's analysis at 819 a time (e.g. a sentence-picker UI, like marimo/greek_syntaxer_review.py) 820 without re-running analysis or re-deriving the same id-position 821 bookkeeping read_analyses()/write_analyses() already do internally. 822 823 Relies on the same invariant read_analyses() and write_analyses() 824 already depend on: a sentence's own tokens form a contiguous, 825 matching-order run in `tokengraph` (see this module's own docstring). 826 `sentence_tokengraph` is the slice of `tokengraph` between that 827 sentence's first and last token's positions, inclusive -- which also 828 picks up any implied/elided tokens (tokentype in IMPLIED_TOKENTYPES) 829 interspersed within that range, since those were never part of 830 `sentence.tokens` to begin with but do belong to that sentence's own 831 analysis. `sentence_verbalunits` is every VerbalExpression whose id 832 falls within that same slice. 833 834 One consequence of using [first, last] *real* token positions as the 835 slice boundary, shared with read_analyses()'s own sentence 836 reconstruction: an implied token placed AFTER a sentence's last real 837 token (rather than nested between two real tokens) falls just outside 838 that slice, since there's no further real token of the same sentence 839 to bound it from above -- e.g. a one-real-token sentence whose only 840 verbal expression is an implied eimi that comes after it (see 841 tests/test_serialization.py's 842 test_split_excludes_a_trailing_implied_token_past_the_sentences_last_real_token). 843 An implied token nested between two real tokens of the same sentence 844 is included as expected; only this specific trailing case isn't. 845 846 Raises ValueError for a sentence with no tokens at all, or whose first 847 or last token id isn't present in `tokengraph` -- both should be 848 impossible for a triple that actually came from read_analyses(), which 849 already guarantees this by construction, but this function checks 850 explicitly anyway rather than trusting the caller, since nothing stops 851 it being called with a hand-built triple too. 852 """ 853 id_position: Dict[str, int] = {tok.id: i for i, tok in enumerate(tokengraph)} 854 855 result: List[Tuple[List[TokenAnalysis], List[VerbalExpression]]] = [] 856 for s_idx, sentence in enumerate(sentences): 857 if not sentence.tokens: 858 raise ValueError(f"sentence at index {s_idx} has no tokens") 859 860 first_id = sentence.tokens[0].id 861 last_id = sentence.tokens[-1].id 862 if first_id not in id_position or last_id not in id_position: 863 raise ValueError( 864 f"sentence at index {s_idx} (tokens {first_id!r}.." 865 f"{last_id!r}) has a boundary token not present in the " 866 "given tokengraph" 867 ) 868 869 start = id_position[first_id] 870 end = id_position[last_id] 871 sentence_tokengraph = tokengraph[start : end + 1] 872 sentence_ids = {tok.id for tok in sentence_tokengraph} 873 sentence_verbalunits = [vu for vu in verbalunits if vu.id in sentence_ids] 874 result.append((sentence_tokengraph, sentence_verbalunits)) 875 876 return result
The inverse of what write_analyses()/serialize_analyses() flatten
together: given the same (tokengraph, verbalunits, sentences) triple
read_analyses() returns (or that analyze_sources()/combined_tokengraph()
produce before ever being written to a file), split tokengraph and
verbalunits back into one slice per sentence.
Returns a list the same length and order as sentences -- entry i is
(sentence_tokengraph, sentence_verbalunits) for sentences[i]. Useful
for anything that wants to review or render one sentence's analysis at
a time (e.g. a sentence-picker UI, like marimo/greek_syntaxer_review.py)
without re-running analysis or re-deriving the same id-position
bookkeeping read_analyses()/write_analyses() already do internally.
Relies on the same invariant read_analyses() and write_analyses()
already depend on: a sentence's own tokens form a contiguous,
matching-order run in tokengraph (see this module's own docstring).
sentence_tokengraph is the slice of tokengraph between that
sentence's first and last token's positions, inclusive -- which also
picks up any implied/elided tokens (tokentype in IMPLIED_TOKENTYPES)
interspersed within that range, since those were never part of
sentence.tokens to begin with but do belong to that sentence's own
analysis. sentence_verbalunits is every VerbalExpression whose id
falls within that same slice.
One consequence of using [first, last] real token positions as the slice boundary, shared with read_analyses()'s own sentence reconstruction: an implied token placed AFTER a sentence's last real token (rather than nested between two real tokens) falls just outside that slice, since there's no further real token of the same sentence to bound it from above -- e.g. a one-real-token sentence whose only verbal expression is an implied eimi that comes after it (see tests/test_serialization.py's test_split_excludes_a_trailing_implied_token_past_the_sentences_last_real_token). An implied token nested between two real tokens of the same sentence is included as expected; only this specific trailing case isn't.
Raises ValueError for a sentence with no tokens at all, or whose first
or last token id isn't present in tokengraph -- both should be
impossible for a triple that actually came from read_analyses(), which
already guarantees this by construction, but this function checks
explicitly anyway rather than trusting the caller, since nothing stops
it being called with a hand-built triple too.
54@dataclass 55class CtsDataRow: 56 """One passage from a `#!ctsdata` source file: `urnbase` (the first 4 57 colon-separated parts of the row's own CTS URN, rejoined with ':', plus 58 a trailing ':') and `citation` (the URN's 5th part) together 59 reconstruct the full URN as `urnbase + citation` -- the same 60 concatenation greek_syntaxer_workflow.py's manual-entry form uses for its own 61 `urnbase`/`citation_context` fields. `text` is the passage's own 62 surface text, verbatim.""" 63 64 urnbase: str 65 citation: str 66 text: str
One passage from a #!ctsdata source file: urnbase (the first 4
colon-separated parts of the row's own CTS URN, rejoined with ':', plus
a trailing ':') and citation (the URN's 5th part) together
reconstruct the full URN as urnbase + citation -- the same
concatenation greek_syntaxer_workflow.py's manual-entry form uses for its own
urnbase/citation_context fields. text is the passage's own
surface text, verbatim.
69def read_ctsdata(path: str, delimiter: str = "|") -> List[CtsDataRow]: 70 """Read every `#!ctsdata` block in `path` and return their rows, 71 concatenated in file order, as a list of CtsDataRow -- see this 72 module's docstring for the file shape and what counts as malformed. 73 74 `delimiter` is the column separator used both for the header line 75 ('urn' + delimiter + 'text') and for splitting each data row; '|' by 76 default, matching serialization.py's own convention. Pass a different 77 character if the source file's own text content might contain '|' (the 78 same escaping caveat serialization.py's module docstring notes for its 79 own fields applies here too -- there is no escaping mechanism for 80 whichever character is chosen as the delimiter). 81 82 Raises ValueError, naming the offending line, for: a data line 83 appearing before any '#!ctsdata' label; a label line with no header 84 line before the next block or before the file ends; a header line that 85 doesn't match `delimiter`-joined 'urn'/'text' exactly; a data row that 86 isn't exactly 2 columns; a blank urn or text column; or a urn that 87 doesn't split into exactly 5 colon-separated parts. Raises ValueError 88 (not returning an empty list) if the file has no '#!ctsdata' block at 89 all, so a caller can't mistake "wrong file" for "file with zero 90 passages". 91 """ 92 expected_header = delimiter.join(["urn", "text"]) 93 94 with open(path, "r", encoding="utf-8") as f: 95 raw_lines = f.read().splitlines() 96 97 rows: List[CtsDataRow] = [] 98 seen_block = False 99 awaiting_header = False 100 101 for line_no, line in enumerate(raw_lines, start=1): 102 if line.strip() == "": 103 continue 104 105 if line == CTSDATA_LABEL: 106 if awaiting_header: 107 raise ValueError( 108 f"line {line_no}: a {CTSDATA_LABEL!r} block has a label " 109 "line but no header line before the next block starts" 110 ) 111 seen_block = True 112 awaiting_header = True 113 continue 114 115 if not seen_block: 116 raise ValueError( 117 f"line {line_no}: data line {line!r} appears before any " 118 f"{CTSDATA_LABEL!r} block label" 119 ) 120 121 if awaiting_header: 122 if line != expected_header: 123 raise ValueError( 124 f"line {line_no}: expected header {expected_header!r} " 125 f"for a {CTSDATA_LABEL!r} block, got {line!r}" 126 ) 127 awaiting_header = False 128 continue 129 130 parts = line.split(delimiter) 131 if len(parts) != 2: 132 raise ValueError( 133 f"line {line_no}: {CTSDATA_LABEL!r} row has {len(parts)} " 134 f"column(s) (delimiter {delimiter!r}), expected 2: {line!r}" 135 ) 136 urn, text = parts 137 if urn == "": 138 raise ValueError(f"line {line_no}: {CTSDATA_LABEL!r} row has an empty urn column") 139 if text == "": 140 raise ValueError(f"line {line_no}: {CTSDATA_LABEL!r} row has an empty text column") 141 142 urn_parts = urn.split(":") 143 if len(urn_parts) != 5: 144 raise ValueError( 145 f"line {line_no}: urn {urn!r} has {len(urn_parts)} " 146 "colon-separated part(s), expected 5 (e.g. " 147 "'urn:cts:greekLit:tlg0059.tlg030.perseus-grc2:1')" 148 ) 149 citation = urn_parts[4] 150 if citation == "": 151 raise ValueError( 152 f"line {line_no}: urn {urn!r} has an empty final (citation) part" 153 ) 154 urnbase = ":".join(urn_parts[:4]) + ":" 155 156 rows.append(CtsDataRow(urnbase=urnbase, citation=citation, text=text)) 157 158 if not seen_block: 159 raise ValueError(f"file has no {CTSDATA_LABEL!r} block") 160 if awaiting_header: 161 raise ValueError( 162 f"a {CTSDATA_LABEL!r} block has a label line but no header " 163 "line (and no data) -- the file ends too early" 164 ) 165 166 return rows
Read every #!ctsdata block in path and return their rows,
concatenated in file order, as a list of CtsDataRow -- see this
module's docstring for the file shape and what counts as malformed.
delimiter is the column separator used both for the header line
('urn' + delimiter + 'text') and for splitting each data row; '|' by
default, matching serialization.py's own convention. Pass a different
character if the source file's own text content might contain '|' (the
same escaping caveat serialization.py's module docstring notes for its
own fields applies here too -- there is no escaping mechanism for
whichever character is chosen as the delimiter).
Raises ValueError, naming the offending line, for: a data line
appearing before any '#!ctsdata' label; a label line with no header
line before the next block or before the file ends; a header line that
doesn't match delimiter-joined 'urn'/'text' exactly; a data row that
isn't exactly 2 columns; a blank urn or text column; or a urn that
doesn't split into exactly 5 colon-separated parts. Raises ValueError
(not returning an empty list) if the file has no '#!ctsdata' block at
all, so a caller can't mistake "wrong file" for "file with zero
passages".
148def estimate_max_tokens( 149 num_tokens: int, 150 *, 151 safety_margin: float = DEFAULT_SAFETY_MARGIN, 152 floor: int = DEFAULT_FLOOR, 153 ceiling: int = DEFAULT_CEILING, 154) -> int: 155 """Estimate a `max_tokens` budget for a SyntaxAnalysis call over a 156 sentence with `num_tokens` input tokens. 157 158 `raw = intercept + slope * num_tokens` comes from the calibrated (or 159 fallback) linear fit (see _load_calibration()); `safety_margin` 160 multiplies that to leave room for the reasoning field's length being 161 only roughly, not exactly, a function of passage length. The result is 162 clamped to `[floor, ceiling]` -- `floor` guards against a degenerate 163 tiny estimate for a 1-2 token sentence, `ceiling` is a hard cap you 164 should set to your actual model's real max-output-tokens limit (see 165 DEFAULT_CEILING's docstring note). 166 167 Raises ValueError if `num_tokens` is negative. 168 """ 169 if num_tokens < 0: 170 raise ValueError(f"num_tokens must be >= 0, got {num_tokens}") 171 172 calibration = _load_calibration() 173 raw = calibration["intercept"] + calibration["slope"] * num_tokens 174 budget = math.ceil(raw * safety_margin) 175 return max(floor, min(ceiling, budget))
Estimate a max_tokens budget for a SyntaxAnalysis call over a
sentence with num_tokens input tokens.
raw = intercept + slope * num_tokens comes from the calibrated (or
fallback) linear fit (see _load_calibration()); safety_margin
multiplies that to leave room for the reasoning field's length being
only roughly, not exactly, a function of passage length. The result is
clamped to [floor, ceiling] -- floor guards against a degenerate
tiny estimate for a 1-2 token sentence, ceiling is a hard cap you
should set to your actual model's real max-output-tokens limit (see
DEFAULT_CEILING's docstring note).
Raises ValueError if num_tokens is negative.
224def analyze_with_retry( 225 passage: str, 226 tokens: List[Token], 227 *, 228 max_retries: int = 1, 229 growth_factor: float = 2.0, 230 safety_margin: float = DEFAULT_SAFETY_MARGIN, 231 floor: int = DEFAULT_FLOOR, 232 ceiling: int = DEFAULT_CEILING, 233 initial_max_tokens: Optional[int] = None, 234 disable_cache: bool = False, 235): 236 """Call `analyze()`, detecting truncation and retrying with a larger 237 `max_tokens` budget instead of either crashing or silently returning an 238 incomplete result. 239 240 The starting budget is `initial_max_tokens` if given, else 241 `estimate_max_tokens(len(tokens), safety_margin=safety_margin, 242 floor=floor, ceiling=ceiling)`. 243 244 `disable_cache` is the caller-facing counterpart to the automatic 245 cache-bypass described below: pass `True` to force EVERY attempt in 246 this call (not just an internal malformed-output retry) to bypass 247 DSPy's own LM response cache. This is for a human deliberately 248 resubmitting the exact same `passage`/`tokens` -- e.g. from a "disable 249 cache" checkbox in a notebook, while iterating on the configured 250 model, the `SyntaxAnalysis` prompt, or the schema -- where the whole 251 point is a genuinely fresh LM call, not a replay of whatever this 252 passage returned last time. Leave it `False` (the default) for 253 ordinary pipeline use, where the cache is a real cost/latency win. 254 255 After each attempt, truncation is checked two ways: `_missing_token_ids` 256 against the result (the primary, LM-independent signal -- works 257 whenever a result exists at all, parsed or not, including under 258 DummyLM in tests) and, if the call raised `AdapterParseError` instead 259 of returning a result (the JSON was cut off badly enough to not parse 260 at all), `_finish_reason_was_length()` as a corroborating check. 261 262 An `AdapterParseError` whose `finish_reason` ISN'T "length" means the 263 response was well-terminated but still malformed somewhere -- e.g. one 264 `tokengraph` entry coming back as a bare `["id"]` list instead of a 265 full `TokenAnalysis` object. A bigger budget wouldn't have fixed that, 266 but the malformation itself is very often a one-off sampling glitch 267 rather than a systematic prompt/schema problem, so it's retried once 268 too (still counted against `max_retries`, at the SAME budget) with 269 dspy's own response cache explicitly bypassed for that one attempt 270 (`config={"cache": False, ...}`) -- without that, an identical request 271 would just replay the identical broken response, retrying nothing. If 272 that retry also fails to parse, or `max_retries` is already exhausted, 273 the exception propagates. (When `disable_cache` is already `True`, this 274 one-shot bypass is redundant but harmless -- the cache is off either 275 way.) 276 277 If truncation is detected and there's still a retry available (fewer 278 than `max_retries` attempts so far, and the budget hasn't already hit 279 `ceiling`), the budget is multiplied by `growth_factor` (capped at 280 `ceiling`) and the call is retried. `max_tokens` is part of DSPy's own 281 LM cache key, so a retry with a different budget always reaches the LM 282 again rather than replaying a cached truncated response. 283 284 Once retries are exhausted: if the last attempt raised, that exception 285 propagates (there's no result to fall back to). If the last attempt 286 returned a still-incomplete result, it's returned anyway -- with a 287 `UserWarning` naming the missing token ids -- rather than raising, 288 matching this codebase's existing convention of surfacing analysis 289 problems as warnings (see pipeline.py's own validate() warning-printing 290 and this module's docstring) instead of treating an imperfect LM 291 result as fatal. 292 """ 293 budget = initial_max_tokens if initial_max_tokens is not None else estimate_max_tokens( 294 len(tokens), safety_margin=safety_margin, floor=floor, ceiling=ceiling 295 ) 296 297 attempt = 0 298 retry_bypass_cache = False # one-shot: set only for the malformed-output retry below 299 while True: 300 old_budget = budget 301 call_config = {"max_tokens": budget} 302 if disable_cache or retry_bypass_cache: 303 call_config["cache"] = False 304 retry_bypass_cache = False # only meant for the one attempt it was set for 305 try: 306 result = analyze(passage=passage, tokens=tokens, config=call_config) 307 except AdapterParseError as exc: 308 if attempt >= max_retries: 309 raise 310 if budget < ceiling and _finish_reason_was_length(): 311 attempt += 1 312 budget = min(ceiling, math.ceil(budget * growth_factor)) 313 warnings.warn( 314 f"SyntaxAnalysis call truncated at max_tokens={old_budget} before it " 315 f"could be parsed at all; retrying with max_tokens={budget} " 316 f"(attempt {attempt}/{max_retries}).", 317 stacklevel=2, 318 ) 319 continue 320 # Not a (detectable) truncation -- the response finished 321 # normally but was malformed somewhere (see this function's own 322 # docstring). Retry once more at the SAME budget, but with 323 # dspy's cache explicitly bypassed for that one attempt, so a 324 # retry is a genuinely fresh LM call rather than a replay of 325 # the same broken response -- otherwise, if this exact request 326 # was already served from cache (e.g. a repeat of an earlier, 327 # already-broken run), simply calling analyze() again would 328 # just return the identical malformed result again and again, 329 # even with dspy's cache enabled as normal for every other call. 330 attempt += 1 331 retry_bypass_cache = True 332 warnings.warn( 333 f"SyntaxAnalysis call at max_tokens={old_budget} returned output that " 334 f"failed to parse, but doesn't look like a truncation (finish_reason " 335 f"wasn't 'length'): {exc} Retrying once at the same budget with the LM " 336 f"cache bypassed, in case this was a one-off malformed-output glitch " 337 f"(attempt {attempt}/{max_retries}).", 338 stacklevel=2, 339 ) 340 continue 341 342 missing = _missing_token_ids(tokens, result) 343 truncated = bool(missing) or _finish_reason_was_length() 344 if truncated and attempt < max_retries and budget < ceiling: 345 attempt += 1 346 budget = min(ceiling, math.ceil(budget * growth_factor)) 347 warnings.warn( 348 f"SyntaxAnalysis call at max_tokens={old_budget} returned a tokengraph " 349 f"missing {len(missing)} input token id(s) ({sorted(missing)}); retrying " 350 f"with a larger max_tokens={budget} (attempt {attempt}/{max_retries}).", 351 stacklevel=2, 352 ) 353 continue 354 355 if truncated: 356 missing_desc = sorted(missing) if missing else "(finish_reason indicated truncation, but no ids are directly missing)" 357 warnings.warn( 358 f"SyntaxAnalysis call still looks truncated after {attempt} retry(ies) " 359 f"(max_tokens={old_budget}) -- returning it anyway. Missing input token " 360 f"id(s): {missing_desc}.", 361 stacklevel=2, 362 ) 363 364 return result
Call analyze(), detecting truncation and retrying with a larger
max_tokens budget instead of either crashing or silently returning an
incomplete result.
The starting budget is initial_max_tokens if given, else
estimate_max_tokens(len(tokens), safety_margin=safety_margin,
floor=floor, ceiling=ceiling).
disable_cache is the caller-facing counterpart to the automatic
cache-bypass described below: pass True to force EVERY attempt in
this call (not just an internal malformed-output retry) to bypass
DSPy's own LM response cache. This is for a human deliberately
resubmitting the exact same passage/tokens -- e.g. from a "disable
cache" checkbox in a notebook, while iterating on the configured
model, the SyntaxAnalysis prompt, or the schema -- where the whole
point is a genuinely fresh LM call, not a replay of whatever this
passage returned last time. Leave it False (the default) for
ordinary pipeline use, where the cache is a real cost/latency win.
After each attempt, truncation is checked two ways: _missing_token_ids
against the result (the primary, LM-independent signal -- works
whenever a result exists at all, parsed or not, including under
DummyLM in tests) and, if the call raised AdapterParseError instead
of returning a result (the JSON was cut off badly enough to not parse
at all), _finish_reason_was_length() as a corroborating check.
An AdapterParseError whose finish_reason ISN'T "length" means the
response was well-terminated but still malformed somewhere -- e.g. one
tokengraph entry coming back as a bare ["id"] list instead of a
full TokenAnalysis object. A bigger budget wouldn't have fixed that,
but the malformation itself is very often a one-off sampling glitch
rather than a systematic prompt/schema problem, so it's retried once
too (still counted against max_retries, at the SAME budget) with
dspy's own response cache explicitly bypassed for that one attempt
(config={"cache": False, ...}) -- without that, an identical request
would just replay the identical broken response, retrying nothing. If
that retry also fails to parse, or max_retries is already exhausted,
the exception propagates. (When disable_cache is already True, this
one-shot bypass is redundant but harmless -- the cache is off either
way.)
If truncation is detected and there's still a retry available (fewer
than max_retries attempts so far, and the budget hasn't already hit
ceiling), the budget is multiplied by growth_factor (capped at
ceiling) and the call is retried. max_tokens is part of DSPy's own
LM cache key, so a retry with a different budget always reaches the LM
again rather than replaying a cached truncated response.
Once retries are exhausted: if the last attempt raised, that exception
propagates (there's no result to fall back to). If the last attempt
returned a still-incomplete result, it's returned anyway -- with a
UserWarning naming the missing token ids -- rather than raising,
matching this codebase's existing convention of surfacing analysis
problems as warnings (see pipeline.py's own validate() warning-printing
and this module's docstring) instead of treating an imperfect LM
result as fatal.
140def get_calibration() -> dict: 141 """Public introspection: what (intercept, slope) is estimate_max_tokens() 142 currently using, and did it come from calibrate_max_tokens.py's fit or 143 from this module's untuned fallback? See _load_calibration()'s 144 docstring for the shape returned.""" 145 return _load_calibration()
Public introspection: what (intercept, slope) is estimate_max_tokens() currently using, and did it come from calibrate_max_tokens.py's fit or from this module's untuned fallback? See _load_calibration()'s docstring for the shape returned.
85def syntax_metric( 86 gold: "dspy.Example", 87 pred: "dspy.Prediction", 88 trace: Optional[Any] = None, 89 pred_name: Optional[str] = None, 90 pred_trace: Optional[Any] = None, 91 program_trace: Optional[Any] = None, 92) -> "dspy.Prediction": 93 """Score a SyntaxAnalysis prediction against a gold answer built from a 94 GoldExample (see optimize_gepa.py's build_trainset()). 95 96 `gold` must have `.tokengraph` (list of TokenAnalysis) and 97 `.verbalunits` (list of VerbalExpression) fields -- the same shape 98 SyntaxAnalysis itself outputs, so the exact same dict-or-model access 99 pattern works for both the gold example and a live prediction. 100 101 Returns a dspy.Prediction(score=..., feedback=...) -- GEPA's expected 102 "ScoreWithFeedback" shape (see dspy.teleprompt.gepa.gepa_utils). `score` 103 is a single float in [0, 1], a weighted blend of three dimensions 104 (relations, verbal-expression classification, and basic per-token 105 fields); `feedback` is a human-readable list of every mismatch found, 106 for GEPA's reflection LM to read. There's only one predictor in 107 `analyze` (SyntaxAnalysis is a single dspy.ChainOfThought), so 108 `pred_name`/`pred_trace` are accepted for protocol compatibility but 109 not used to change scoring -- GEPA will use the same feedback whether 110 it's asking at the program level or the (only) predictor level. 111 112 The returned Prediction also carries the three unblended dimension 113 scores as their own fields -- `field_score`, `relation_score`, 114 `vu_score` -- alongside `score` and `feedback`. GEPA itself only reads 115 `score`/`feedback`, so this is purely additive (existing callers that 116 only look at those two are unaffected), but it's useful for any caller 117 that wants to know WHERE a prediction fell down rather than just by how 118 much -- e.g. a model-bakeoff script uses these to tell "gets the 119 surface tokenization right but can't chase multi-hop relations" apart 120 from "just generally worse," which a single blended number can't 121 distinguish. 122 123 The 0.2/0.5/0.3 weighting (fields/relations/verbal-expressions) is a 124 judgment call, not something syntax_model.md specifies -- relations are 125 weighted highest since they're the heart of the scheme, but this is an 126 easy knob to retune once real GEPA runs are observed. 127 """ 128 problems = [] 129 130 gold_tg = {_get(t, "id"): t for t in gold.tokengraph} 131 pred_tokengraph = list(getattr(pred, "tokengraph", None) or []) 132 pred_tg = {} 133 for tok in pred_tokengraph: 134 tid = _get(tok, "id") 135 if tid in pred_tg: 136 problems.append( 137 f"tokengraph has more than one entry for id {tid!r} " 138 "(only the first is scored)" 139 ) 140 continue 141 pred_tg[tid] = tok 142 143 gold_ids = set(gold_tg) 144 pred_ids = set(pred_tg) 145 146 field_total = 0 147 field_correct = 0 148 149 for tid in gold_ids: 150 g = gold_tg[tid] 151 gtext = _get(g, "token") 152 if tid not in pred_tg: 153 problems.append(f"token {tid} ({gtext!r}) is missing from tokengraph entirely") 154 field_total += 2 155 continue 156 p = pred_tg[tid] 157 for field in ("tokentype", "verbalunitid"): 158 gval = _get(g, field) 159 pval = _get(p, field) 160 field_total += 1 161 if gval == pval: 162 field_correct += 1 163 else: 164 problems.append(f"token {tid} ({gtext!r}): expected {field}={gval!r}, got {pval!r}") 165 gval = _get(g, "lemma") 166 if gval is not None: 167 pval = _get(p, "lemma") 168 field_total += 1 169 if gval == pval: 170 field_correct += 1 171 else: 172 problems.append(f"token {tid} ({gtext!r}): expected lemma={gval!r}, got {pval!r}") 173 174 extra_tok_ids = pred_ids - gold_ids 175 if extra_tok_ids: 176 problems.append( 177 f"tokengraph has entries for id(s) not in the input tokens: {sorted(extra_tok_ids)}" 178 ) 179 180 gold_rels = _relation_triples(gold_tg) 181 pred_rels = _relation_triples(pred_tg) 182 missing_rels = gold_rels - pred_rels 183 extra_rels = pred_rels - gold_rels 184 185 for tid, label, related in sorted(missing_rels): 186 gtext = _get(gold_tg.get(tid), "token") 187 problems.append(f"token {tid} ({gtext!r}) is missing the relation {label!r} -> {related}") 188 for tid, label, related in sorted(extra_rels): 189 text = _get(pred_tg.get(tid), "token") 190 problems.append( 191 f"token {tid} ({text!r}) has an unexpected relation {label!r} -> {related} " 192 "not in the gold answer" 193 ) 194 195 relation_denominator = len(gold_rels) + len(extra_rels) 196 relation_correct = len(gold_rels) - len(missing_rels) 197 relation_score = _safe_ratio(relation_correct, relation_denominator) 198 199 gold_vu = {_get(v, "id"): v for v in gold.verbalunits} 200 pred_vu = {_get(v, "id"): v for v in (getattr(pred, "verbalunits", None) or [])} 201 202 vu_total = 0 203 vu_correct = 0 204 for vid, gv in gold_vu.items(): 205 gtext = _get(gold_tg.get(vid), "token") or vid 206 if vid not in pred_vu: 207 problems.append(f"verbal expression at {vid} ({gtext!r}) is missing from verbalunits entirely") 208 vu_total += 2 209 continue 210 pv = pred_vu[vid] 211 for field in ("syntactic_type", "semantic_type"): 212 vu_total += 1 213 if _get(gv, field) == _get(pv, field): 214 vu_correct += 1 215 else: 216 problems.append( 217 f"verbal expression at {vid} ({gtext!r}): expected {field}={_get(gv, field)!r}, " 218 f"got {_get(pv, field)!r}" 219 ) 220 221 extra_vu_ids = set(pred_vu) - set(gold_vu) 222 if extra_vu_ids: 223 problems.append( 224 "verbalunits has unexpected extra entries not anchored on a gold verbal " 225 f"expression: {sorted(extra_vu_ids)}" 226 ) 227 228 field_score = _safe_ratio(field_correct, field_total) 229 vu_score = _safe_ratio(vu_correct, vu_total) 230 231 score = 0.2 * field_score + 0.5 * relation_score + 0.3 * vu_score 232 233 if not problems: 234 feedback = ( 235 "Perfect match with the gold analysis: every token's fields, relations, " 236 "and verbal-expression classification are correct." 237 ) 238 else: 239 feedback = ( 240 f"Score {score:.2f} (fields {field_score:.2f}, relations {relation_score:.2f}, " 241 f"verbal expressions {vu_score:.2f}). Problems found:\n- " + "\n- ".join(problems) 242 ) 243 244 return dspy.Prediction( 245 score=score, 246 feedback=feedback, 247 field_score=field_score, 248 relation_score=relation_score, 249 vu_score=vu_score, 250 )
Score a SyntaxAnalysis prediction against a gold answer built from a GoldExample (see optimize_gepa.py's build_trainset()).
gold must have .tokengraph (list of TokenAnalysis) and
.verbalunits (list of VerbalExpression) fields -- the same shape
SyntaxAnalysis itself outputs, so the exact same dict-or-model access
pattern works for both the gold example and a live prediction.
Returns a dspy.Prediction(score=..., feedback=...) -- GEPA's expected
"ScoreWithFeedback" shape (see dspy.teleprompt.gepa.gepa_utils). score
is a single float in [0, 1], a weighted blend of three dimensions
(relations, verbal-expression classification, and basic per-token
fields); feedback is a human-readable list of every mismatch found,
for GEPA's reflection LM to read. There's only one predictor in
analyze (SyntaxAnalysis is a single dspy.ChainOfThought), so
pred_name/pred_trace are accepted for protocol compatibility but
not used to change scoring -- GEPA will use the same feedback whether
it's asking at the program level or the (only) predictor level.
The returned Prediction also carries the three unblended dimension
scores as their own fields -- field_score, relation_score,
vu_score -- alongside score and feedback. GEPA itself only reads
score/feedback, so this is purely additive (existing callers that
only look at those two are unaffected), but it's useful for any caller
that wants to know WHERE a prediction fell down rather than just by how
much -- e.g. a model-bakeoff script uses these to tell "gets the
surface tokenization right but can't chase multi-hop relations" apart
from "just generally worse," which a single blended number can't
distinguish.
The 0.2/0.5/0.3 weighting (fields/relations/verbal-expressions) is a judgment call, not something syntax_model.md specifies -- relations are weighted highest since they're the heart of the scheme, but this is an easy knob to retune once real GEPA runs are observed.