aat

aat: a reductive Agent-Action-Target (AAT) model of natural-language syntax (see aat-model.md), plus a DSPy-based pipeline applying it to English (aat.english).

This top-level module re-exports only aat.core -- the language-agnostic model itself -- so import aat never requires dspy to be installed. English-specific application code lives in aat.english and is imported separately:

from aat import CitableToken, AATNode, AATGraph, graph_to_mermaid   # always available
from aat.english import analyze_passage                              # needs the 'english' extra

See README.md for the reasoning behind this split, and USAGE.md for worked examples of both.

 1"""
 2aat: a reductive Agent-Action-Target (AAT) model of natural-language
 3syntax (see aat-model.md), plus a DSPy-based pipeline applying it to
 4English (aat.english).
 5
 6This top-level module re-exports only aat.core -- the language-agnostic
 7model itself -- so `import aat` never requires dspy to be installed.
 8English-specific application code lives in aat.english and is imported
 9separately:
10
11    from aat import CitableToken, AATNode, AATGraph, graph_to_mermaid   # always available
12    from aat.english import analyze_passage                              # needs the 'english' extra
13
14See README.md for the reasoning behind this split, and USAGE.md for
15worked examples of both.
16"""
17
18from .core import (
19    CitableToken,
20    CitedPassage,
21    AATGraph,
22    AATNode,
23    Role,
24    ROLES,
25    validate,
26    tokens_to_html,
27    serialize_nodes,
28    write_nodes,
29    read_nodes,
30    read_graph,
31    serialize_tokens,
32    write_tokens,
33    read_tokens,
34    serialize_analysis,
35    write_analysis,
36    read_analysis,
37    graph_to_mermaid,
38    save_mermaid,
39    graph_to_dot,
40    save_dot,
41    assign_action_colors,
42    parse_cex_ctsdata,
43    read_cex_passages,
44)
45
46__version__ = "0.1.0"
47
48__all__ = [
49    "CitableToken",
50    "CitedPassage",
51    "AATGraph",
52    "AATNode",
53    "Role",
54    "ROLES",
55    "validate",
56    "tokens_to_html",
57    "serialize_nodes",
58    "write_nodes",
59    "read_nodes",
60    "read_graph",
61    "serialize_tokens",
62    "write_tokens",
63    "read_tokens",
64    "serialize_analysis",
65    "write_analysis",
66    "read_analysis",
67    "graph_to_mermaid",
68    "save_mermaid",
69    "graph_to_dot",
70    "save_dot",
71    "assign_action_colors",
72    "parse_cex_ctsdata",
73    "read_cex_passages",
74]
class CitableToken(pydantic.main.BaseModel):
30class CitableToken(BaseModel):
31    """One token belonging to a citable passage.
32
33    `id` is unique *within* `context`, not globally -- aat-model.md's own
34    worked example uses a per-context sequence like 't1', 't2', ...,
35    starting over for each new context. A caller working across multiple
36    contexts should treat (context, id) as the real compound key, not id
37    alone -- see AATGraph.by_id().
38    """
39
40    context: str = Field(description="Context reference this token belongs to, matching its source CitedPassage.")
41    id: str = Field(description="Token id, unique within `context` (e.g. 't1', 't2', ...).")
42    value: str = Field(description="The token's own string value (its surface text).")

One token belonging to a citable passage.

id is unique within context, not globally -- aat-model.md's own worked example uses a per-context sequence like 't1', 't2', ..., starting over for each new context. A caller working across multiple contexts should treat (context, id) as the real compound key, not id alone -- see AATGraph.by_id().

context: str = PydanticUndefined

Context reference this token belongs to, matching its source CitedPassage.

id: str = PydanticUndefined

Token id, unique within context (e.g. 't1', 't2', ...).

value: str = PydanticUndefined

The token's own string value (its surface text).

class CitedPassage(pydantic.main.BaseModel):
20class CitedPassage(BaseModel):
21    """One citable passage of text: a reference identifying its context
22    (aat-model.md's example is a CTS URN, but any stable identifier a
23    caller chooses works -- nothing here assumes CTS specifically) paired
24    with the passage's own raw text."""
25
26    context: str = Field(description="Reference identifying this passage's context, e.g. a CTS URN.")
27    text: str = Field(description="This passage's raw text, exactly as written.")

One citable passage of text: a reference identifying its context (aat-model.md's example is a CTS URN, but any stable identifier a caller chooses works -- nothing here assumes CTS specifically) paired with the passage's own raw text.

context: str = PydanticUndefined

Reference identifying this passage's context, e.g. a CTS URN.

text: str = PydanticUndefined

This passage's raw text, exactly as written.

class AATGraph(pydantic.main.BaseModel):
 73class AATGraph(BaseModel):
 74    """A full AAT graph for one or more citable passages: an ordered list
 75    of AATNode. Node order is not semantically significant -- the
 76    convenience accessors below don't depend on it -- but callers that
 77    built the graph from a specific passage will typically find document
 78    order convenient for display."""
 79
 80    nodes: List[AATNode] = Field(default_factory=list)
 81
 82    def by_id(self, context: str, id: str) -> Optional[AATNode]:
 83        """The node with this (context, id), or None. `id` alone isn't a
 84        reliable key across contexts (CitableToken/AATNode ids are only
 85        unique *within* one context -- see CitableToken's docstring), so
 86        both are required here."""
 87        for node in self.nodes:
 88            if node.context == context and node.id == id:
 89                return node
 90        return None
 91
 92    def actions(self) -> List[AATNode]:
 93        """Every action node, in list order."""
 94        return [n for n in self.nodes if n.role == "action"]
 95
 96    def agents(self) -> List[AATNode]:
 97        """Every agent node, in list order."""
 98        return [n for n in self.nodes if n.role == "agent"]
 99
100    def targets(self) -> List[AATNode]:
101        """Every target node, in list order."""
102        return [n for n in self.nodes if n.role == "target"]
103
104    def agents_for(self, action: AATNode) -> List[AATNode]:
105        """Every agent node whose related_node points at `action`'s id,
106        within the same context."""
107        return [
108            n for n in self.nodes
109            if n.role == "agent" and n.context == action.context and n.related_node == action.id
110        ]
111
112    def targets_for(self, action: AATNode) -> List[AATNode]:
113        """Every target node whose related_node points at `action`'s id,
114        within the same context."""
115        return [
116            n for n in self.nodes
117            if n.role == "target" and n.context == action.context and n.related_node == action.id
118        ]
119
120    def governing_action(self, action: AATNode) -> Optional[AATNode]:
121        """For a dependent action node, the action node it's subordinate
122        to; None if `action` is independent (related_node is None) or if
123        related_node doesn't resolve to any node in this graph."""
124        if action.related_node is None:
125            return None
126        return self.by_id(action.context, action.related_node)

A full AAT graph for one or more citable passages: an ordered list of AATNode. Node order is not semantically significant -- the convenience accessors below don't depend on it -- but callers that built the graph from a specific passage will typically find document order convenient for display.

nodes: List[AATNode] = PydanticUndefined
def by_id(self, context: str, id: str) -> AATNode | None:
82    def by_id(self, context: str, id: str) -> Optional[AATNode]:
83        """The node with this (context, id), or None. `id` alone isn't a
84        reliable key across contexts (CitableToken/AATNode ids are only
85        unique *within* one context -- see CitableToken's docstring), so
86        both are required here."""
87        for node in self.nodes:
88            if node.context == context and node.id == id:
89                return node
90        return None

The node with this (context, id), or None. id alone isn't a reliable key across contexts (CitableToken/AATNode ids are only unique within one context -- see CitableToken's docstring), so both are required here.

def actions(self) -> List[AATNode]:
92    def actions(self) -> List[AATNode]:
93        """Every action node, in list order."""
94        return [n for n in self.nodes if n.role == "action"]

Every action node, in list order.

def agents(self) -> List[AATNode]:
96    def agents(self) -> List[AATNode]:
97        """Every agent node, in list order."""
98        return [n for n in self.nodes if n.role == "agent"]

Every agent node, in list order.

def targets(self) -> List[AATNode]:
100    def targets(self) -> List[AATNode]:
101        """Every target node, in list order."""
102        return [n for n in self.nodes if n.role == "target"]

Every target node, in list order.

def agents_for(self, action: AATNode) -> List[AATNode]:
104    def agents_for(self, action: AATNode) -> List[AATNode]:
105        """Every agent node whose related_node points at `action`'s id,
106        within the same context."""
107        return [
108            n for n in self.nodes
109            if n.role == "agent" and n.context == action.context and n.related_node == action.id
110        ]

Every agent node whose related_node points at action's id, within the same context.

def targets_for(self, action: AATNode) -> List[AATNode]:
112    def targets_for(self, action: AATNode) -> List[AATNode]:
113        """Every target node whose related_node points at `action`'s id,
114        within the same context."""
115        return [
116            n for n in self.nodes
117            if n.role == "target" and n.context == action.context and n.related_node == action.id
118        ]

Every target node whose related_node points at action's id, within the same context.

def governing_action(self, action: AATNode) -> AATNode | None:
120    def governing_action(self, action: AATNode) -> Optional[AATNode]:
121        """For a dependent action node, the action node it's subordinate
122        to; None if `action` is independent (related_node is None) or if
123        related_node doesn't resolve to any node in this graph."""
124        if action.related_node is None:
125            return None
126        return self.by_id(action.context, action.related_node)

For a dependent action node, the action node it's subordinate to; None if action is independent (related_node is None) or if related_node doesn't resolve to any node in this graph.

class AATNode(pydantic.main.BaseModel):
24class AATNode(BaseModel):
25    """One node in an AAT graph.
26
27    `context` and `id` identify the token this node is built from -- for a
28    single-token action, an agent, or a target, `id` is just that token's
29    own id. For a *compound* action (aat-model.md's "Actions" section --
30    e.g. "was eating"), `id` is instead the id of the *principal verb*
31    token within the compound (e.g. the token for "eating"), and `value`
32    is the space-joined text of every component token in surface order
33    (e.g. "was eating"), not just the principal verb's own text.
34
35    `related_node` is:
36      - `None`, for an *independent* action node (aat-model.md's
37        "Actions" section);
38      - another action node's `id`, for a *dependent* action node (the id
39        of the governing action it depends on);
40      - another action node's `id`, for an agent or target node (the id
41        of the action it's the agent/target of) -- agent and target nodes
42        always have a related_node; only an action node's related_node
43        can be `None`.
44    """
45
46    context: str = Field(description="Context reference this node belongs to.")
47    id: str = Field(
48        description=(
49            "Token id this node is built from -- the principal verb's id "
50            "for a compound action, otherwise the single underlying "
51            "token's id."
52        )
53    )
54    value: str = Field(
55        description=(
56            "This node's string value -- a single token's own text, or, "
57            "for a compound action, every component token's text joined "
58            "by spaces in surface order."
59        )
60    )
61    role: Role = Field(description="'agent', 'action', or 'target'.")
62    related_node: Optional[str] = Field(
63        default=None,
64        description=(
65            "For an action node: the id of the governing action node, if "
66            "this action is dependent/subordinate; None if independent. "
67            "For an agent or target node: the id of the action node it "
68            "relates to (always set)."
69        ),
70    )

One node in an AAT graph.

context and id identify the token this node is built from -- for a single-token action, an agent, or a target, id is just that token's own id. For a compound action (aat-model.md's "Actions" section -- e.g. "was eating"), id is instead the id of the principal verb token within the compound (e.g. the token for "eating"), and value is the space-joined text of every component token in surface order (e.g. "was eating"), not just the principal verb's own text.

related_node is:

  • None, for an independent action node (aat-model.md's "Actions" section);
  • another action node's id, for a dependent action node (the id of the governing action it depends on);
  • another action node's id, for an agent or target node (the id of the action it's the agent/target of) -- agent and target nodes always have a related_node; only an action node's related_node can be None.
context: str = PydanticUndefined

Context reference this node belongs to.

id: str = PydanticUndefined

Token id this node is built from -- the principal verb's id for a compound action, otherwise the single underlying token's id.

value: str = PydanticUndefined

This node's string value -- a single token's own text, or, for a compound action, every component token's text joined by spaces in surface order.

role: Literal['agent', 'action', 'target'] = PydanticUndefined

'agent', 'action', or 'target'.

related_node: str | None = None

For an action node: the id of the governing action node, if this action is dependent/subordinate; None if independent. For an agent or target node: the id of the action node it relates to (always set).

Role = typing.Literal['agent', 'action', 'target']
ROLES = ('agent', 'action', 'target')
def validate( tokens: List[CitableToken], graph: AATGraph) -> List[str]:
16def validate(tokens: List[CitableToken], graph: AATGraph) -> List[str]:
17    """Return a list of human-readable problem descriptions found in
18    `graph`, given the `tokens` it was built from. An empty list means no
19    referential problems were found (not that the analysis is correct).
20
21    Checks:
22      - every node's (context, id) matches a real token in `tokens`;
23      - every node's `value` is non-empty;
24      - an action node's `related_node`, if set, resolves to another
25        action node's id in the same context;
26      - an agent or target node's `related_node` is set (not None) and
27        resolves to an action node's id in the same context.
28    """
29    problems: List[str] = []
30
31    token_ids = {(t.context, t.id) for t in tokens}
32    action_ids = {(n.context, n.id) for n in graph.nodes if n.role == "action"}
33
34    for node in graph.nodes:
35        if (node.context, node.id) not in token_ids:
36            problems.append(
37                f"node ({node.context!r}, {node.id!r}, role={node.role!r}) "
38                "does not match any token in the input token list"
39            )
40        if not node.value:
41            problems.append(
42                f"node ({node.context!r}, {node.id!r}) has an empty value"
43            )
44
45        if node.role == "action":
46            if node.related_node is not None and (node.context, node.related_node) not in action_ids:
47                problems.append(
48                    f"action node ({node.context!r}, {node.id!r}) has related_node "
49                    f"{node.related_node!r}, which is not the id of any action node "
50                    "in this context"
51                )
52        else:  # agent or target
53            if node.related_node is None:
54                problems.append(
55                    f"{node.role} node ({node.context!r}, {node.id!r}) has no "
56                    "related_node -- every agent/target node must relate to an "
57                    "action node"
58                )
59            elif (node.context, node.related_node) not in action_ids:
60                problems.append(
61                    f"{node.role} node ({node.context!r}, {node.id!r}) has related_node "
62                    f"{node.related_node!r}, which is not the id of any action node "
63                    "in this context"
64                )
65
66    return problems

Return a list of human-readable problem descriptions found in graph, given the tokens it was built from. An empty list means no referential problems were found (not that the analysis is correct).

Checks:
  • every node's (context, id) matches a real token in tokens;
  • every node's value is non-empty;
  • an action node's related_node, if set, resolves to another action node's id in the same context;
  • an agent or target node's related_node is set (not None) and resolves to an action node's id in the same context.
def tokens_to_html( tokens: List[CitableToken], graph: AATGraph | None = None) -> str:
119def tokens_to_html(tokens: List[CitableToken], graph: Optional[AATGraph] = None) -> str:
120    """Render `tokens` as one continuous HTML string: ordinary words get
121    a space before them, punctuation attaches per this module's own
122    docstring (left-joining by default; opening brackets and the first
123    of a quote pair right-join instead).
124
125    If `graph` is given, every token whose (context, id) matches a node
126    in `graph` is highlighted: background/text colored the same way
127    that node is colored in `aat.core.mermaid.graph_to_mermaid()`'s
128    diagram (via `aat.core.coloring.assign_action_colors()`), with a
129    role-specific border -- a box around an `action` token, a rounded
130    box around an `agent` token, an underline under a `target` token.
131    Omit `graph` (or pass `None`) for plain, unhighlighted text.
132
133    Every token's text is HTML-escaped before being emitted, span or
134    not -- real passage text can contain a literal `<`, `>`, `&`, or
135    quote character, which would otherwise be indistinguishable from
136    markup to anything that re-parses this output.
137    """
138    color_of_node: Dict[Tuple[str, str], Tuple[str, str, str]] = {}
139    role_of_node: Dict[Tuple[str, str], str] = {}
140    if graph is not None:
141        color_of_node, _warnings = assign_action_colors(graph)
142        role_of_node = {(n.context, n.id): n.role for n in graph.nodes}
143
144    quote_counts: Dict[str, int] = {}
145    pieces: List[str] = []
146    previous_class: Optional[str] = None
147
148    for token in tokens:
149        cls = _classify(token, quote_counts)
150        rendered = html.escape(token.value)
151
152        key = (token.context, token.id)
153        color = color_of_node.get(key)
154        if color is not None:
155            fill, stroke, text_color = color
156            role = role_of_node.get(key, "")
157            role_style = _ROLE_STYLE.get(role, "").format(stroke=stroke)
158            rendered = (
159                f'<span style="background-color: {fill}; color: {text_color}; {role_style}">'
160                f"{rendered}</span>"
161            )
162
163        if not pieces:
164            # Nothing precedes the first token -- never prepend a space.
165            pieces.append(rendered)
166        elif cls == _LEFT:
167            pieces.append(rendered)
168        elif cls == _RIGHT:
169            pieces.append(" " + rendered)
170        else:  # _NORMAL
171            if previous_class == _RIGHT:
172                pieces.append(rendered)
173            else:
174                pieces.append(" " + rendered)
175
176        previous_class = cls
177
178    return "".join(pieces)

Render tokens as one continuous HTML string: ordinary words get a space before them, punctuation attaches per this module's own docstring (left-joining by default; opening brackets and the first of a quote pair right-join instead).

If graph is given, every token whose (context, id) matches a node in graph is highlighted: background/text colored the same way that node is colored in aat.core.mermaid.graph_to_mermaid()'s diagram (via aat.core.coloring.assign_action_colors()), with a role-specific border -- a box around an action token, a rounded box around an agent token, an underline under a target token. Omit graph (or pass None) for plain, unhighlighted text.

Every token's text is HTML-escaped before being emitted, span or not -- real passage text can contain a literal <, >, &, or quote character, which would otherwise be indistinguishable from markup to anything that re-parses this output.

def serialize_nodes(nodes: List[AATNode]) -> str:
169def serialize_nodes(nodes: List[AATNode]) -> str:
170    """Render `nodes` as one '#!aatnodes' block, pipe-delimited, and
171    return it as a string. Does not check for '|' inside any field's own
172    value -- there is no escaping mechanism, so avoid the delimiter
173    character in `value` if you plan to round-trip through this format."""
174    lines = [AATNODES_LABEL, _AATNODES_HEADER]
175    for node in nodes:
176        related = node.related_node or ""
177        lines.append(f"{node.context}|{node.id}|{node.value}|{node.role}|{related}")
178    return "\n".join(lines) + "\n"

Render nodes as one '#!aatnodes' block, pipe-delimited, and return it as a string. Does not check for '|' inside any field's own value -- there is no escaping mechanism, so avoid the delimiter character in value if you plan to round-trip through this format.

def write_nodes(nodes: List[AATNode], path: str) -> None:
181def write_nodes(nodes: List[AATNode], path: str) -> None:
182    """Write serialize_nodes(nodes) to `path`."""
183    with open(path, "w", encoding="utf-8") as f:
184        f.write(serialize_nodes(nodes))

Write serialize_nodes(nodes) to path.

def read_nodes(path: str) -> List[AATNode]:
187def read_nodes(path: str) -> List[AATNode]:
188    """Read every '#!aatnodes' block in `path` and return their rows,
189    concatenated in file order, as a list of AATNode -- other block
190    types in the same file (e.g. a '#!tokens' block) are ignored, see
191    _read_blocks().
192
193    Raises ValueError (via _read_blocks()) for a malformed file -- see
194    that function's own docstring for the exact cases. Also raises
195    ValueError (not returning an empty list) if the file has no
196    '#!aatnodes' block at all, so a caller can't mistake "wrong file" for
197    "file with zero nodes".
198    """
199    rows, seen = _read_blocks(path, AATNODES_LABEL, _AATNODES_HEADER)
200    if not seen:
201        raise ValueError(f"file has no {AATNODES_LABEL!r} block")
202
203    nodes: List[AATNode] = []
204    for context, id_, value, role, related in rows:
205        nodes.append(
206            AATNode(
207                context=context,
208                id=id_,
209                value=value,
210                role=role,
211                related_node=related or None,
212            )
213        )
214    return nodes

Read every '#!aatnodes' block in path and return their rows, concatenated in file order, as a list of AATNode -- other block types in the same file (e.g. a '#!tokens' block) are ignored, see _read_blocks().

Raises ValueError (via _read_blocks()) for a malformed file -- see that function's own docstring for the exact cases. Also raises ValueError (not returning an empty list) if the file has no '#!aatnodes' block at all, so a caller can't mistake "wrong file" for "file with zero nodes".

def read_graph(path: str) -> AATGraph:
217def read_graph(path: str) -> AATGraph:
218    """Convenience wrapper: read_nodes(path) wrapped as an AATGraph."""
219    return AATGraph(nodes=read_nodes(path))

Convenience wrapper: read_nodes(path) wrapped as an AATGraph.

def serialize_tokens(tokens: List[CitableToken]) -> str:
222def serialize_tokens(tokens: List[CitableToken]) -> str:
223    """Render `tokens` as one '#!tokens' block, pipe-delimited, and
224    return it as a string, in `tokens`' own order -- unlike
225    serialize_nodes(), row order here is meaningful (it's the only thing
226    that records reading order; a token's `id` alone doesn't, especially
227    for a composite sentence-spanning id like "1.14.t3" -- see this
228    module's own top docstring). Same no-escaping caveat as
229    serialize_nodes(): avoid '|' in `value` if you plan to round-trip
230    through this format."""
231    lines = [TOKENS_LABEL, _TOKENS_HEADER]
232    for token in tokens:
233        lines.append(f"{token.context}|{token.id}|{token.value}")
234    return "\n".join(lines) + "\n"

Render tokens as one '#!tokens' block, pipe-delimited, and return it as a string, in tokens' own order -- unlike serialize_nodes(), row order here is meaningful (it's the only thing that records reading order; a token's id alone doesn't, especially for a composite sentence-spanning id like "1.14.t3" -- see this module's own top docstring). Same no-escaping caveat as serialize_nodes(): avoid '|' in value if you plan to round-trip through this format.

def write_tokens(tokens: List[CitableToken], path: str) -> None:
237def write_tokens(tokens: List[CitableToken], path: str) -> None:
238    """Write serialize_tokens(tokens) to `path`."""
239    with open(path, "w", encoding="utf-8") as f:
240        f.write(serialize_tokens(tokens))

Write serialize_tokens(tokens) to path.

def read_tokens(path: str) -> List[CitableToken]:
243def read_tokens(path: str) -> List[CitableToken]:
244    """Read every '#!tokens' block in `path` and return their rows,
245    concatenated in file order (which is also reading order -- see
246    serialize_tokens()'s own docstring), as a list of CitableToken --
247    other block types in the same file (e.g. a '#!aatnodes' block) are
248    ignored, see _read_blocks().
249
250    Raises ValueError (via _read_blocks()) for a malformed file. Also
251    raises ValueError if the file has no '#!tokens' block at all, same
252    reasoning as read_nodes()."""
253    rows, seen = _read_blocks(path, TOKENS_LABEL, _TOKENS_HEADER)
254    if not seen:
255        raise ValueError(f"file has no {TOKENS_LABEL!r} block")
256    return [CitableToken(context=context, id=id_, value=value) for context, id_, value in rows]

Read every '#!tokens' block in path and return their rows, concatenated in file order (which is also reading order -- see serialize_tokens()'s own docstring), as a list of CitableToken -- other block types in the same file (e.g. a '#!aatnodes' block) are ignored, see _read_blocks().

Raises ValueError (via _read_blocks()) for a malformed file. Also raises ValueError if the file has no '#!tokens' block at all, same reasoning as read_nodes().

def serialize_analysis( tokens: List[CitableToken], graph: AATGraph) -> str:
259def serialize_analysis(tokens: List[CitableToken], graph: AATGraph) -> str:
260    """Render a complete, re-displayable analysis as one string: a
261    '#!tokens' block (the complete, already-tokenized input every node
262    in `graph` refers back to -- no re-tokenization needed to reload it,
263    see this module's own top docstring) followed by a '#!aatnodes'
264    block (the AAT graph an earlier, LM-dependent analysis step
265    produced). write_analysis() is a thin wrapper that writes this same
266    string to a file; call this directly instead when the text itself is
267    what's wanted -- e.g. to hand to a UI that writes the file somewhere
268    else (a chosen directory, a download, ...), or to embed the analysis
269    in something larger without touching disk here at all. read_analysis()
270    is the matching reader for a file written either way -- see that
271    function's own docstring for the intended round trip."""
272    return serialize_tokens(tokens) + "\n" + serialize_nodes(graph.nodes)

Render a complete, re-displayable analysis as one string: a '#!tokens' block (the complete, already-tokenized input every node in graph refers back to -- no re-tokenization needed to reload it, see this module's own top docstring) followed by a '#!aatnodes' block (the AAT graph an earlier, LM-dependent analysis step produced). write_analysis() is a thin wrapper that writes this same string to a file; call this directly instead when the text itself is what's wanted -- e.g. to hand to a UI that writes the file somewhere else (a chosen directory, a download, ...), or to embed the analysis in something larger without touching disk here at all. read_analysis() is the matching reader for a file written either way -- see that function's own docstring for the intended round trip.

def write_analysis( tokens: List[CitableToken], graph: AATGraph, path: str) -> None:
275def write_analysis(tokens: List[CitableToken], graph: AATGraph, path: str) -> None:
276    """Write serialize_analysis(tokens, graph) to `path`. See that
277    function's own docstring for exactly what gets written, and
278    read_analysis() for the matching reader."""
279    with open(path, "w", encoding="utf-8") as f:
280        f.write(serialize_analysis(tokens, graph))

Write serialize_analysis(tokens, graph) to path. See that function's own docstring for exactly what gets written, and read_analysis() for the matching reader.

def read_analysis( path: str) -> Tuple[List[CitableToken], AATGraph]:
283def read_analysis(path: str) -> Tuple[List[CitableToken], AATGraph]:
284    """Read a file written by write_analysis(): both its '#!tokens'
285    block (as a list of CitableToken, in reading order) and its
286    '#!aatnodes' block (as an AATGraph). Raises ValueError if either
287    block is missing -- see read_tokens()/read_nodes().
288
289    Unlike the earlier '#!passages'-based format this replaced, the
290    returned `tokens` are already exactly what the original analysis
291    used -- e.g. straight into aat.core.tokens_to_html(tokens,
292    graph=graph) (also re-exported as aat.english.tokens_to_html) -- no
293    re-tokenization step, and so no dependency on
294    aat.english.tokenize() (or aat.english.tokenize_corpus_by_sentence()
295    for a sentence-spanning file) at all. This whole round trip still
296    needs no LM access at any point; it just no longer needs to re-run
297    any deterministic code either.
298    """
299    tokens = read_tokens(path)
300    graph = read_graph(path)
301    return tokens, graph

Read a file written by write_analysis(): both its '#!tokens' block (as a list of CitableToken, in reading order) and its '#!aatnodes' block (as an AATGraph). Raises ValueError if either block is missing -- see read_tokens()/read_nodes().

Unlike the earlier '#!passages'-based format this replaced, the returned tokens are already exactly what the original analysis used -- e.g. straight into aat.core.tokens_to_html(tokens, graph=graph) (also re-exported as aat.english.tokens_to_html) -- no re-tokenization step, and so no dependency on aat.english.tokenize (or aat.english.tokenize_corpus_by_sentence() for a sentence-spanning file) at all. This whole round trip still needs no LM access at any point; it just no longer needs to re-run any deterministic code either.

def graph_to_mermaid( graph: AATGraph, orientation: str = 'BT', color_by_action: bool = True) -> Tuple[str, List[str]]:
 76def graph_to_mermaid(
 77    graph: AATGraph,
 78    orientation: str = "BT",
 79    color_by_action: bool = True,
 80) -> Tuple[str, List[str]]:
 81    """Build a Mermaid `graph` diagram from an AATGraph.
 82
 83    `orientation` is Mermaid's own flowchart direction code -- `BT`
 84    (bottom-to-top, the default here), `TB`/`TD` (top-down -- synonyms),
 85    `LR`, or `RL` -- used verbatim (uppercased) in the diagram's opening
 86    line (`graph BT`, etc.). Validated by aat.core.orientation (shared
 87    with graph_to_dot()); anything else raises `ValueError` naming the
 88    valid options, rather than silently producing invalid Mermaid syntax.
 89    See https://mermaid.js.org/syntax/flowchart.html#direction.
 90
 91    `color_by_action` (default True) -- see this module's own docstring.
 92    Pass False for a plain, uncolored diagram.
 93
 94    Returns (diagram_text, warnings). A node whose `related_node` doesn't
 95    resolve to any node actually present in `graph` (same context) is
 96    still drawn, but its edge is skipped and reported as a warning --
 97    normally a sign `graph` failed aat.core.validate.validate() upstream,
 98    worth checking there first. If `color_by_action` is True and the graph
 99    has more distinct actions than the palette has colors (currently 8),
100    one warning notes that colors repeat.
101    """
102    orientation = normalize_orientation(orientation)
103
104    by_key: Dict[Tuple[str, str], AATNode] = {_node_key(n): n for n in graph.nodes}
105
106    lines = [f"graph {orientation}"]
107    for node in graph.nodes:
108        open_b, close_b = _ROLE_BRACKETS.get(node.role, ("[", "]"))
109        lines.append(f'    {node.id}{open_b}"{_escape_label(node.value)}"{close_b}')
110
111    warnings: List[str] = []
112    for node in graph.nodes:
113        if node.related_node is None:
114            continue
115        target_key = (node.context, node.related_node)
116        if target_key not in by_key:
117            warnings.append(
118                f"skipped edge {node.id} -[{node.role}]-> {node.related_node}: "
119                "target is not a node in this graph"
120            )
121            continue
122        edge_label = "dependent" if node.role == "action" else node.role
123        lines.append(f"    {node.id} -->|{edge_label}| {node.related_node}")
124
125    if color_by_action:
126        color_of_node, color_warnings = assign_action_colors(graph)
127        warnings.extend(color_warnings)
128
129        if color_of_node:
130            lines.append("")
131
132            # Group by color VALUE, not by action -- two actions that
133            # cycle to the same palette color (more actions than the
134            # palette has slots) share a single classDef/class pair here
135            # rather than emitting two redundant classes with identical
136            # colors.
137            colors_in_order: List[ColorTriple] = []
138            seen_colors = set()
139            for node in graph.nodes:
140                color = color_of_node.get(_node_key(node))
141                if color is not None and color not in seen_colors:
142                    seen_colors.add(color)
143                    colors_in_order.append(color)
144
145            class_name_of_color: Dict[ColorTriple, str] = {
146                color: f"c{i}" for i, color in enumerate(colors_in_order)
147            }
148            for color in colors_in_order:
149                fill, stroke, text = color
150                lines.append(
151                    f"    classDef {class_name_of_color[color]} "
152                    f"fill:{fill},stroke:{stroke},color:{text};"
153                )
154
155            nodes_by_class: Dict[str, List[str]] = {}
156            for node in graph.nodes:
157                color = color_of_node.get(_node_key(node))
158                if color is None:
159                    continue
160                class_name = class_name_of_color[color]
161                nodes_by_class.setdefault(class_name, []).append(node.id)
162
163            for class_name, node_ids in nodes_by_class.items():
164                lines.append(f"    class {','.join(node_ids)} {class_name};")
165
166    return "\n".join(lines), warnings

Build a Mermaid graph diagram from an AATGraph.

orientation is Mermaid's own flowchart direction code -- BT (bottom-to-top, the default here), TB/TD (top-down -- synonyms), LR, or RL -- used verbatim (uppercased) in the diagram's opening line (graph BT, etc.). Validated by aat.core.orientation (shared with graph_to_dot()); anything else raises ValueError naming the valid options, rather than silently producing invalid Mermaid syntax. See https://mermaid.js.org/syntax/flowchart.html#direction.

color_by_action (default True) -- see this module's own docstring. Pass False for a plain, uncolored diagram.

Returns (diagram_text, warnings). A node whose related_node doesn't resolve to any node actually present in graph (same context) is still drawn, but its edge is skipped and reported as a warning -- normally a sign graph failed aat.core.validate.validate() upstream, worth checking there first. If color_by_action is True and the graph has more distinct actions than the palette has colors (currently 8), one warning notes that colors repeat.

def save_mermaid( graph: AATGraph, path: str, orientation: str = 'BT', color_by_action: bool = True) -> List[str]:
169def save_mermaid(
170    graph: AATGraph,
171    path: str,
172    orientation: str = "BT",
173    color_by_action: bool = True,
174) -> List[str]:
175    """Write the diagram to `path` (e.g. 'analysis.mmd') and return any
176    warnings from graph_to_mermaid(). `orientation` is validated the same
177    way -- see graph_to_mermaid()'s own docstring."""
178    diagram, warnings = graph_to_mermaid(graph, orientation=orientation, color_by_action=color_by_action)
179    with open(path, "w", encoding="utf-8") as f:
180        f.write(diagram + "\n")
181    return warnings

Write the diagram to path (e.g. 'analysis.mmd') and return any warnings from graph_to_mermaid(). orientation is validated the same way -- see graph_to_mermaid()'s own docstring.

def graph_to_dot( graph: AATGraph, orientation: str = 'BT', color_by_action: bool = True) -> Tuple[str, List[str]]:
 82def graph_to_dot(
 83    graph: AATGraph,
 84    orientation: str = "BT",
 85    color_by_action: bool = True,
 86) -> Tuple[str, List[str]]:
 87    """Build a Graphviz DOT digraph from an AATGraph -- see this module's
 88    own docstring for the node-shape/edge/coloring mapping, which mirrors
 89    aat.core.mermaid.graph_to_mermaid() exactly except for the target
 90    syntax.
 91
 92    `orientation` is validated exactly like graph_to_mermaid()'s (see
 93    aat.core.orientation) -- `BT` (bottom-to-top, the default here),
 94    `TB`/`TD` (top-down -- synonyms), `LR`, or `RL`. Written out as
 95    Graphviz's own `rankdir` graph attribute; since `rankdir` has no
 96    "TD" of its own, "TD" is mapped to "TB" here (same direction, just
 97    Graphviz's own name for it) -- every other value is used verbatim.
 98    Anything outside that vocabulary raises `ValueError` naming the
 99    valid options, rather than silently producing invalid DOT syntax.
100
101    `color_by_action` (default True) -- see this module's own docstring.
102    Pass False for a plain, uncolored digraph.
103
104    Returns (dot_text, warnings) -- same warning cases as
105    graph_to_mermaid(): a node whose `related_node` doesn't resolve to
106    another node actually present in `graph` (same context) is still
107    drawn, but its edge is skipped and reported as a warning; and, if
108    `color_by_action` is True and the graph has more distinct actions
109    than the palette has colors (currently 8), one warning notes that
110    colors repeat.
111    """
112    orientation = normalize_orientation(orientation)
113    rankdir = "TB" if orientation == "TD" else orientation
114
115    by_key: Dict[Tuple[str, str], AATNode] = {_node_key(n): n for n in graph.nodes}
116
117    color_of_node: Dict[Tuple[str, str], ColorTriple] = {}
118    warnings: List[str] = []
119    if color_by_action:
120        color_of_node, color_warnings = assign_action_colors(graph)
121        warnings.extend(color_warnings)
122
123    lines = ["digraph aat {", f"    rankdir={rankdir};"]
124
125    for node in graph.nodes:
126        attrs = [f"shape={_ROLE_SHAPE.get(node.role, 'box')}"]
127        styles = list(_ROLE_EXTRA_STYLES.get(node.role, []))
128
129        color = color_of_node.get(_node_key(node))
130        if color is not None:
131            fill, stroke, text = color
132            styles.append("filled")
133            attrs.append(f'fillcolor="{fill}"')
134            attrs.append(f'color="{stroke}"')
135            attrs.append(f'fontcolor="{text}"')
136
137        if styles:
138            attrs.append(f'style="{",".join(styles)}"')
139        attrs.append(f'label="{_escape_label(node.value)}"')
140
141        lines.append(f'    {node.id} [{", ".join(attrs)}];')
142
143    for node in graph.nodes:
144        if node.related_node is None:
145            continue
146        target_key = (node.context, node.related_node)
147        if target_key not in by_key:
148            warnings.append(
149                f"skipped edge {node.id} -[{node.role}]-> {node.related_node}: "
150                "target is not a node in this graph"
151            )
152            continue
153        edge_label = "dependent" if node.role == "action" else node.role
154        lines.append(f'    {node.id} -> {node.related_node} [label="{edge_label}"];')
155
156    lines.append("}")
157    return "\n".join(lines), warnings

Build a Graphviz DOT digraph from an AATGraph -- see this module's own docstring for the node-shape/edge/coloring mapping, which mirrors aat.core.mermaid.graph_to_mermaid() exactly except for the target syntax.

orientation is validated exactly like graph_to_mermaid()'s (see aat.core.orientation) -- BT (bottom-to-top, the default here), TB/TD (top-down -- synonyms), LR, or RL. Written out as Graphviz's own rankdir graph attribute; since rankdir has no "TD" of its own, "TD" is mapped to "TB" here (same direction, just Graphviz's own name for it) -- every other value is used verbatim. Anything outside that vocabulary raises ValueError naming the valid options, rather than silently producing invalid DOT syntax.

color_by_action (default True) -- see this module's own docstring. Pass False for a plain, uncolored digraph.

Returns (dot_text, warnings) -- same warning cases as graph_to_mermaid(): a node whose related_node doesn't resolve to another node actually present in graph (same context) is still drawn, but its edge is skipped and reported as a warning; and, if color_by_action is True and the graph has more distinct actions than the palette has colors (currently 8), one warning notes that colors repeat.

def save_dot( graph: AATGraph, path: str, orientation: str = 'BT', color_by_action: bool = True) -> List[str]:
160def save_dot(
161    graph: AATGraph,
162    path: str,
163    orientation: str = "BT",
164    color_by_action: bool = True,
165) -> List[str]:
166    """Write the digraph to `path` (e.g. 'analysis.dot') and return any
167    warnings from graph_to_dot(). `orientation` is validated the same
168    way -- see graph_to_dot()'s own docstring."""
169    dot_text, warnings = graph_to_dot(graph, orientation=orientation, color_by_action=color_by_action)
170    with open(path, "w", encoding="utf-8") as f:
171        f.write(dot_text + "\n")
172    return warnings

Write the digraph to path (e.g. 'analysis.dot') and return any warnings from graph_to_dot(). orientation is validated the same way -- see graph_to_dot()'s own docstring.

def assign_action_colors( graph: AATGraph) -> Tuple[Dict[Tuple[str, str], Tuple[str, str, str]], List[str]]:
48def assign_action_colors(graph: AATGraph) -> Tuple[Dict[NodeKey, ColorTriple], List[str]]:
49    """Return (color_of_node, warnings).
50
51    `color_of_node` maps every node's (context, id) to a (fill, stroke,
52    text) hex triple, for every node that clusters with some action: an
53    action node itself (dependent or not -- see this module's own
54    docstring), or an agent/target node whose `related_node` resolves to
55    an action node in the same context. A node with no resolvable cluster
56    (e.g. an agent/target with a broken related_node -- shouldn't happen
57    in a graph that's passed aat.core.validate.validate()) is simply
58    absent from the mapping.
59
60    Colors are assigned to actions in the order they first appear in
61    `graph.nodes`, cycling through the palette (currently 8 colors) if
62    there are more distinct actions than that -- in which case two
63    distinct actions (and their own agent/target nodes) end up sharing the
64    identical color. `warnings` has one entry naming this when it happens;
65    otherwise it's empty.
66    """
67    actions = [n for n in graph.nodes if n.role == "action"]
68
69    warnings: List[str] = []
70    if len(actions) > len(_ACTION_COLOR_PALETTE):
71        warnings.append(
72            f"{len(actions)} actions but only {len(_ACTION_COLOR_PALETTE)} palette "
73            "colors -- colors repeat and some distinct actions will share a color"
74        )
75
76    action_index: Dict[NodeKey, int] = {_node_key(a): i for i, a in enumerate(actions)}
77
78    by_key: Dict[NodeKey, AATNode] = {_node_key(n): n for n in graph.nodes}
79    cluster_of_node: Dict[NodeKey, NodeKey] = {}
80    for node in graph.nodes:
81        key = _node_key(node)
82        if node.role == "action":
83            cluster_of_node[key] = key
84        elif node.related_node is not None:
85            related_key = (node.context, node.related_node)
86            if related_key in by_key:
87                cluster_of_node[key] = related_key
88
89    color_of_node: Dict[NodeKey, ColorTriple] = {}
90    for node in graph.nodes:
91        key = _node_key(node)
92        cluster_key = cluster_of_node.get(key)
93        if cluster_key is None or cluster_key not in action_index:
94            continue
95        color_of_node[key] = _ACTION_COLOR_PALETTE[action_index[cluster_key] % len(_ACTION_COLOR_PALETTE)]
96
97    return color_of_node, warnings

Return (color_of_node, warnings).

color_of_node maps every node's (context, id) to a (fill, stroke, text) hex triple, for every node that clusters with some action: an action node itself (dependent or not -- see this module's own docstring), or an agent/target node whose related_node resolves to an action node in the same context. A node with no resolvable cluster (e.g. an agent/target with a broken related_node -- shouldn't happen in a graph that's passed aat.core.validate.validate()) is simply absent from the mapping.

Colors are assigned to actions in the order they first appear in graph.nodes, cycling through the palette (currently 8 colors) if there are more distinct actions than that -- in which case two distinct actions (and their own agent/target nodes) end up sharing the identical color. warnings has one entry naming this when it happens; otherwise it's empty.

def parse_cex_ctsdata(text: str, delimiter: str = '#') -> List[CitedPassage]:
39def parse_cex_ctsdata(text: str, delimiter: str = "#") -> List[CitedPassage]:
40    """Parse every '#!ctsdata' block in `text` (a CEX file's own full
41    contents, already read into a string) and return its rows,
42    concatenated in file order, as a list of CitedPassage -- `context` is
43    each row's own CTS URN, `text` is its citable text. Every other CEX
44    block type is ignored entirely, whether it appears before, between,
45    or after the '#!ctsdata' block(s); multiple '#!ctsdata' blocks in one
46    file are concatenated, in file order, same as
47    aat.core.serialization's own multi-block handling.
48
49    Raises ValueError, naming the offending line, for a '#!ctsdata' row
50    that has no `delimiter` in it at all -- most often a sign `delimiter`
51    is wrong for this particular file. Raises ValueError (not returning
52    an empty list) if the file has no '#!ctsdata' block at all, so a
53    caller can't mistake "wrong file" for "file with zero passages" --
54    same reasoning as aat.core.serialization.read_nodes()/read_tokens().
55    """
56    passages: List[CitedPassage] = []
57    in_ctsdata = False
58    seen = False
59
60    for line_no, raw_line in enumerate(text.splitlines(), start=1):
61        line = raw_line.strip("\r\n")
62        if line == "" or line.startswith("//"):
63            continue
64
65        if line.startswith("#!"):
66            in_ctsdata = line == CTSDATA_LABEL
67            if in_ctsdata:
68                seen = True
69            continue
70
71        if not in_ctsdata:
72            continue
73
74        urn, sep, passage_text = line.partition(delimiter)
75        if not sep:
76            raise ValueError(
77                f"line {line_no}: no {delimiter!r} delimiter found in a "
78                f"{CTSDATA_LABEL!r} row -- wrong --delimiter for this file? {line!r}"
79            )
80        passages.append(CitedPassage(context=urn, text=passage_text))
81
82    if not seen:
83        raise ValueError(f"file has no {CTSDATA_LABEL!r} block")
84
85    return passages

Parse every '#!ctsdata' block in text (a CEX file's own full contents, already read into a string) and return its rows, concatenated in file order, as a list of CitedPassage -- context is each row's own CTS URN, text is its citable text. Every other CEX block type is ignored entirely, whether it appears before, between, or after the '#!ctsdata' block(s); multiple '#!ctsdata' blocks in one file are concatenated, in file order, same as aat.core.serialization's own multi-block handling.

Raises ValueError, naming the offending line, for a '#!ctsdata' row that has no delimiter in it at all -- most often a sign delimiter is wrong for this particular file. 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" -- same reasoning as aat.core.serialization.read_nodes()/read_tokens().

def read_cex_passages(path: str, delimiter: str = '#') -> List[CitedPassage]:
88def read_cex_passages(path: str, delimiter: str = "#") -> List[CitedPassage]:
89    """Read `path` as a CEX file and return parse_cex_ctsdata() of its
90    contents -- see that function's own docstring for the exact format
91    and error cases."""
92    with open(path, "r", encoding="utf-8") as f:
93        text = f.read()
94    return parse_cex_ctsdata(text, delimiter=delimiter)

Read path as a CEX file and return parse_cex_ctsdata() of its contents -- see that function's own docstring for the exact format and error cases.