aatgraph 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aat/__init__.py ADDED
@@ -0,0 +1,74 @@
1
+ """
2
+ aat: a reductive Agent-Action-Target (AAT) model of natural-language
3
+ syntax (see aat-model.md), plus a DSPy-based pipeline applying it to
4
+ English (aat.english).
5
+
6
+ This top-level module re-exports only aat.core -- the language-agnostic
7
+ model itself -- so `import aat` never requires dspy to be installed.
8
+ English-specific application code lives in aat.english and is imported
9
+ separately:
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
+
14
+ See README.md for the reasoning behind this split, and USAGE.md for
15
+ worked examples of both.
16
+ """
17
+
18
+ from .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
+ ]
aat/core/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ """
2
+ aat.core: the Agent-Action-Target model itself (aat-model.md), with no
3
+ dependency on any particular language or on dspy.
4
+
5
+ Everything in this subpackage represents, validates, serializes, or
6
+ renders an AAT graph; nothing here knows how to *produce* one from real
7
+ text -- that's aat.english's job (or, eventually, another language-
8
+ specific sibling subpackage). Keeping this boundary means aat.core can be
9
+ imported and reused on its own by any downstream project that just wants
10
+ the data model, the file format, and the Mermaid/Graphviz renderers,
11
+ without pulling in dspy at all.
12
+ """
13
+
14
+ from .tokens import CitableToken, CitedPassage
15
+ from .graph import AATGraph, AATNode, Role, ROLES
16
+ from .validate import validate
17
+ from .serialization import (
18
+ serialize_nodes,
19
+ write_nodes,
20
+ read_nodes,
21
+ read_graph,
22
+ serialize_tokens,
23
+ write_tokens,
24
+ read_tokens,
25
+ serialize_analysis,
26
+ write_analysis,
27
+ read_analysis,
28
+ )
29
+ from .html import tokens_to_html
30
+ from .mermaid import graph_to_mermaid, save_mermaid
31
+ from .graphviz import graph_to_dot, save_dot
32
+ from .coloring import assign_action_colors
33
+ from .cex import parse_cex_ctsdata, read_cex_passages
34
+
35
+ __all__ = [
36
+ "CitableToken",
37
+ "CitedPassage",
38
+ "AATGraph",
39
+ "AATNode",
40
+ "Role",
41
+ "ROLES",
42
+ "validate",
43
+ "serialize_nodes",
44
+ "write_nodes",
45
+ "read_nodes",
46
+ "read_graph",
47
+ "serialize_tokens",
48
+ "write_tokens",
49
+ "read_tokens",
50
+ "serialize_analysis",
51
+ "write_analysis",
52
+ "read_analysis",
53
+ "tokens_to_html",
54
+ "graph_to_mermaid",
55
+ "save_mermaid",
56
+ "graph_to_dot",
57
+ "save_dot",
58
+ "assign_action_colors",
59
+ "parse_cex_ctsdata",
60
+ "read_cex_passages",
61
+ ]
aat/core/cex.py ADDED
@@ -0,0 +1,93 @@
1
+ """
2
+ Read a corpus of citable text passages from a CEX (CITE Exchange) file --
3
+ an external plain-text interchange format from the CITE architecture
4
+ (https://cite-architecture.github.io/citedx/CEX-spec-3.0.1/), not this
5
+ project's own aat.core.serialization format (a different, purpose-built
6
+ convention -- see that module's own docstring). A CEX file can carry many
7
+ kinds of data in labelled '#!'-blocks ('#!citelibrary', '#!ctscatalog',
8
+ '#!imagedata', ...); this module reads only the one block aat cares
9
+ about -- '#!ctsdata' -- and ignores every other block type entirely, the
10
+ same way aat.core.serialization's own readers ignore block types other
11
+ than their own.
12
+
13
+ A '#!ctsdata' block is two columns per line -- a CTS URN, then that
14
+ node's own text -- separated by a delimiter the file's own author chose:
15
+ per the CEX spec, the delimiter is never declared inside the file itself,
16
+ just used consistently once picked. '#' is the most common convention in
17
+ practice (and this module's own default) -- pass `delimiter` for a file
18
+ that uses something else. Blank lines are ignored, and a line starting
19
+ with '//' is a CEX comment and is also ignored -- both per the CEX spec.
20
+
21
+ Splitting is done on the delimiter's *first* occurrence per line, not a
22
+ strict two-column check (contrast aat.core.serialization's own readers,
23
+ which require an exact column count) -- a CTS URN never contains '#'
24
+ (or any other reasonable delimiter choice), but the citable text half of
25
+ the line is real prose this module doesn't control, and (especially with
26
+ the default '#' delimiter) can plausibly contain the delimiter character
27
+ again somewhere in the text itself. Splitting on the first occurrence
28
+ only keeps that text intact rather than raising over it.
29
+ """
30
+
31
+ from typing import List
32
+
33
+ from .tokens import CitedPassage
34
+
35
+ CTSDATA_LABEL = "#!ctsdata"
36
+
37
+
38
+ def parse_cex_ctsdata(text: str, delimiter: str = "#") -> List[CitedPassage]:
39
+ """Parse every '#!ctsdata' block in `text` (a CEX file's own full
40
+ contents, already read into a string) and return its rows,
41
+ concatenated in file order, as a list of CitedPassage -- `context` is
42
+ each row's own CTS URN, `text` is its citable text. Every other CEX
43
+ block type is ignored entirely, whether it appears before, between,
44
+ or after the '#!ctsdata' block(s); multiple '#!ctsdata' blocks in one
45
+ file are concatenated, in file order, same as
46
+ aat.core.serialization's own multi-block handling.
47
+
48
+ Raises ValueError, naming the offending line, for a '#!ctsdata' row
49
+ that has no `delimiter` in it at all -- most often a sign `delimiter`
50
+ is wrong for this particular file. Raises ValueError (not returning
51
+ an empty list) if the file has no '#!ctsdata' block at all, so a
52
+ caller can't mistake "wrong file" for "file with zero passages" --
53
+ same reasoning as aat.core.serialization.read_nodes()/read_tokens().
54
+ """
55
+ passages: List[CitedPassage] = []
56
+ in_ctsdata = False
57
+ seen = False
58
+
59
+ for line_no, raw_line in enumerate(text.splitlines(), start=1):
60
+ line = raw_line.strip("\r\n")
61
+ if line == "" or line.startswith("//"):
62
+ continue
63
+
64
+ if line.startswith("#!"):
65
+ in_ctsdata = line == CTSDATA_LABEL
66
+ if in_ctsdata:
67
+ seen = True
68
+ continue
69
+
70
+ if not in_ctsdata:
71
+ continue
72
+
73
+ urn, sep, passage_text = line.partition(delimiter)
74
+ if not sep:
75
+ raise ValueError(
76
+ f"line {line_no}: no {delimiter!r} delimiter found in a "
77
+ f"{CTSDATA_LABEL!r} row -- wrong --delimiter for this file? {line!r}"
78
+ )
79
+ passages.append(CitedPassage(context=urn, text=passage_text))
80
+
81
+ if not seen:
82
+ raise ValueError(f"file has no {CTSDATA_LABEL!r} block")
83
+
84
+ return passages
85
+
86
+
87
+ def read_cex_passages(path: str, delimiter: str = "#") -> List[CitedPassage]:
88
+ """Read `path` as a CEX file and return parse_cex_ctsdata() of its
89
+ contents -- see that function's own docstring for the exact format
90
+ and error cases."""
91
+ with open(path, "r", encoding="utf-8") as f:
92
+ text = f.read()
93
+ return parse_cex_ctsdata(text, delimiter=delimiter)
aat/core/coloring.py ADDED
@@ -0,0 +1,96 @@
1
+ """
2
+ Shared color-by-action-cluster assignment.
3
+
4
+ Every action node, plus every agent or target node whose `related_node`
5
+ points at it, shares one color -- assigned from a small palette in the
6
+ order each action first appears in a graph's own node list. This lives in
7
+ its own module, separate from mermaid.py, so it can be reused wherever a
8
+ token or node needs to be colored the same way it is in a Mermaid diagram
9
+ without depending on Mermaid-specific rendering code -- e.g.
10
+ aat.core.html's tokens_to_html(), which highlights passage text with
11
+ these same colors. This mirrors why arsgrammatica's verbal_units.py is
12
+ shared between its own mermaid.py and rendering.py.
13
+
14
+ A dependent action gets its OWN color, not its governor's -- it's still
15
+ its own cluster's anchor, even though something else (a Mermaid edge, an
16
+ HTML border) may also point at or otherwise reference another cluster's
17
+ anchor.
18
+ """
19
+
20
+ from typing import Dict, List, Tuple
21
+
22
+ from .graph import AATGraph, AATNode
23
+
24
+ NodeKey = Tuple[str, str]
25
+ ColorTriple = Tuple[str, str, str]
26
+
27
+ # (fill, stroke, text) hex triples, chosen for readable contrast between
28
+ # fill and text in both light- and dark-themed renderers. Cycles (with a
29
+ # warning -- see assign_action_colors()'s own docstring) if a graph has
30
+ # more distinct actions than this palette has slots.
31
+ _ACTION_COLOR_PALETTE: List[ColorTriple] = [
32
+ ("#E3F2FD", "#1565C0", "#0D47A1"), # blue
33
+ ("#FFF3E0", "#EF6C00", "#E65100"), # orange
34
+ ("#E8F5E9", "#2E7D32", "#1B5E20"), # green
35
+ ("#FCE4EC", "#AD1457", "#880E4F"), # pink
36
+ ("#EDE7F6", "#5E35B1", "#4527A0"), # purple
37
+ ("#FFFDE7", "#F9A825", "#F57F17"), # yellow
38
+ ("#E0F7FA", "#00838F", "#006064"), # cyan
39
+ ("#FBE9E7", "#D84315", "#BF360C"), # deep orange
40
+ ]
41
+
42
+
43
+ def _node_key(node: AATNode) -> NodeKey:
44
+ return (node.context, node.id)
45
+
46
+
47
+ def assign_action_colors(graph: AATGraph) -> Tuple[Dict[NodeKey, ColorTriple], List[str]]:
48
+ """Return (color_of_node, warnings).
49
+
50
+ `color_of_node` maps every node's (context, id) to a (fill, stroke,
51
+ text) hex triple, for every node that clusters with some action: an
52
+ action node itself (dependent or not -- see this module's own
53
+ docstring), or an agent/target node whose `related_node` resolves to
54
+ an action node in the same context. A node with no resolvable cluster
55
+ (e.g. an agent/target with a broken related_node -- shouldn't happen
56
+ in a graph that's passed aat.core.validate.validate()) is simply
57
+ absent from the mapping.
58
+
59
+ Colors are assigned to actions in the order they first appear in
60
+ `graph.nodes`, cycling through the palette (currently 8 colors) if
61
+ there are more distinct actions than that -- in which case two
62
+ distinct actions (and their own agent/target nodes) end up sharing the
63
+ identical color. `warnings` has one entry naming this when it happens;
64
+ otherwise it's empty.
65
+ """
66
+ actions = [n for n in graph.nodes if n.role == "action"]
67
+
68
+ warnings: List[str] = []
69
+ if len(actions) > len(_ACTION_COLOR_PALETTE):
70
+ warnings.append(
71
+ f"{len(actions)} actions but only {len(_ACTION_COLOR_PALETTE)} palette "
72
+ "colors -- colors repeat and some distinct actions will share a color"
73
+ )
74
+
75
+ action_index: Dict[NodeKey, int] = {_node_key(a): i for i, a in enumerate(actions)}
76
+
77
+ by_key: Dict[NodeKey, AATNode] = {_node_key(n): n for n in graph.nodes}
78
+ cluster_of_node: Dict[NodeKey, NodeKey] = {}
79
+ for node in graph.nodes:
80
+ key = _node_key(node)
81
+ if node.role == "action":
82
+ cluster_of_node[key] = key
83
+ elif node.related_node is not None:
84
+ related_key = (node.context, node.related_node)
85
+ if related_key in by_key:
86
+ cluster_of_node[key] = related_key
87
+
88
+ color_of_node: Dict[NodeKey, ColorTriple] = {}
89
+ for node in graph.nodes:
90
+ key = _node_key(node)
91
+ cluster_key = cluster_of_node.get(key)
92
+ if cluster_key is None or cluster_key not in action_index:
93
+ continue
94
+ color_of_node[key] = _ACTION_COLOR_PALETTE[action_index[cluster_key] % len(_ACTION_COLOR_PALETTE)]
95
+
96
+ return color_of_node, warnings
aat/core/graph.py ADDED
@@ -0,0 +1,125 @@
1
+ """
2
+ The AAT graph itself: the output side of the AAT model (aat-model.md, "The
3
+ AAT graph for English" -- despite that section header, the graph *shape*
4
+ it defines is not English-specific; only the rules for how to populate it
5
+ from English text are English-specific. See aat.english for those rules.)
6
+
7
+ An AATGraph is a selection of nodes drawn from a passage's citable tokens.
8
+ Every node keeps the context/id/value of the token(s) it derives from (for
9
+ a compound action, see AATNode's own docstring on id/value), and adds two
10
+ fields the raw tokens don't have: `role` (agent/action/target) and
11
+ `related_node`.
12
+ """
13
+
14
+ from typing import List, Literal, Optional
15
+
16
+ from pydantic import BaseModel, Field
17
+
18
+ Role = Literal["agent", "action", "target"]
19
+
20
+ ROLES: tuple = ("agent", "action", "target")
21
+
22
+
23
+ class AATNode(BaseModel):
24
+ """One node in an AAT graph.
25
+
26
+ `context` and `id` identify the token this node is built from -- for a
27
+ single-token action, an agent, or a target, `id` is just that token's
28
+ own id. For a *compound* action (aat-model.md's "Actions" section --
29
+ e.g. "was eating"), `id` is instead the id of the *principal verb*
30
+ token within the compound (e.g. the token for "eating"), and `value`
31
+ is the space-joined text of every component token in surface order
32
+ (e.g. "was eating"), not just the principal verb's own text.
33
+
34
+ `related_node` is:
35
+ - `None`, for an *independent* action node (aat-model.md's
36
+ "Actions" section);
37
+ - another action node's `id`, for a *dependent* action node (the id
38
+ of the governing action it depends on);
39
+ - another action node's `id`, for an agent or target node (the id
40
+ of the action it's the agent/target of) -- agent and target nodes
41
+ always have a related_node; only an action node's related_node
42
+ can be `None`.
43
+ """
44
+
45
+ context: str = Field(description="Context reference this node belongs to.")
46
+ id: str = Field(
47
+ description=(
48
+ "Token id this node is built from -- the principal verb's id "
49
+ "for a compound action, otherwise the single underlying "
50
+ "token's id."
51
+ )
52
+ )
53
+ value: str = Field(
54
+ description=(
55
+ "This node's string value -- a single token's own text, or, "
56
+ "for a compound action, every component token's text joined "
57
+ "by spaces in surface order."
58
+ )
59
+ )
60
+ role: Role = Field(description="'agent', 'action', or 'target'.")
61
+ related_node: Optional[str] = Field(
62
+ default=None,
63
+ description=(
64
+ "For an action node: the id of the governing action node, if "
65
+ "this action is dependent/subordinate; None if independent. "
66
+ "For an agent or target node: the id of the action node it "
67
+ "relates to (always set)."
68
+ ),
69
+ )
70
+
71
+
72
+ class AATGraph(BaseModel):
73
+ """A full AAT graph for one or more citable passages: an ordered list
74
+ of AATNode. Node order is not semantically significant -- the
75
+ convenience accessors below don't depend on it -- but callers that
76
+ built the graph from a specific passage will typically find document
77
+ order convenient for display."""
78
+
79
+ nodes: List[AATNode] = Field(default_factory=list)
80
+
81
+ def by_id(self, context: str, id: str) -> Optional[AATNode]:
82
+ """The node with this (context, id), or None. `id` alone isn't a
83
+ reliable key across contexts (CitableToken/AATNode ids are only
84
+ unique *within* one context -- see CitableToken's docstring), so
85
+ both are required here."""
86
+ for node in self.nodes:
87
+ if node.context == context and node.id == id:
88
+ return node
89
+ return None
90
+
91
+ def actions(self) -> List[AATNode]:
92
+ """Every action node, in list order."""
93
+ return [n for n in self.nodes if n.role == "action"]
94
+
95
+ def agents(self) -> List[AATNode]:
96
+ """Every agent node, in list order."""
97
+ return [n for n in self.nodes if n.role == "agent"]
98
+
99
+ def targets(self) -> List[AATNode]:
100
+ """Every target node, in list order."""
101
+ return [n for n in self.nodes if n.role == "target"]
102
+
103
+ def agents_for(self, action: AATNode) -> List[AATNode]:
104
+ """Every agent node whose related_node points at `action`'s id,
105
+ within the same context."""
106
+ return [
107
+ n for n in self.nodes
108
+ if n.role == "agent" and n.context == action.context and n.related_node == action.id
109
+ ]
110
+
111
+ def targets_for(self, action: AATNode) -> List[AATNode]:
112
+ """Every target node whose related_node points at `action`'s id,
113
+ within the same context."""
114
+ return [
115
+ n for n in self.nodes
116
+ if n.role == "target" and n.context == action.context and n.related_node == action.id
117
+ ]
118
+
119
+ def governing_action(self, action: AATNode) -> Optional[AATNode]:
120
+ """For a dependent action node, the action node it's subordinate
121
+ to; None if `action` is independent (related_node is None) or if
122
+ related_node doesn't resolve to any node in this graph."""
123
+ if action.related_node is None:
124
+ return None
125
+ return self.by_id(action.context, action.related_node)
aat/core/graphviz.py ADDED
@@ -0,0 +1,171 @@
1
+ """
2
+ Render an AATGraph as a Graphviz DOT digraph -- the same graph
3
+ aat.core.mermaid.graph_to_mermaid() renders as a Mermaid flowchart,
4
+ translated into Graphviz's own syntax instead. The underlying node/edge/
5
+ coloring model is identical (see that module's own docstring); only the
6
+ target syntax differs:
7
+
8
+ - Every node becomes a DOT node, labelled with its own `value` and
9
+ shaped by its `role`. Graphviz has no shape literally called
10
+ "stadium", so target's shape is chosen as the closest visual analogue
11
+ to Mermaid's `([...])` (fully rounded, no square corners at all):
12
+ - action: `shape=box` (plain rectangle, matches Mermaid's `[...]`)
13
+ - agent: `shape=box, style=rounded` (rounded-corner rectangle,
14
+ matches Mermaid's `(...)`)
15
+ - target: `shape=ellipse` (fully rounded oval, matches Mermaid's
16
+ `([...])`)
17
+ - Every node with a `related_node` becomes a labelled edge FROM that node
18
+ TO the node it relates to, exactly as graph_to_mermaid() describes.
19
+ - By default (`color_by_action=True`), every node is colored by the same
20
+ action-cluster assignment graph_to_mermaid() uses
21
+ (aat.core.coloring.assign_action_colors()) -- but applied as inline
22
+ `style=filled, fillcolor=..., color=..., fontcolor=...` attributes
23
+ directly on each node's own line. DOT has no equivalent to Mermaid's
24
+ separate classDef/class mechanism, so there's no separate "class"
25
+ grouping step here; each node just carries its own color attributes.
26
+
27
+ Multiple contexts in one `graph` are all drawn into a single digraph,
28
+ with no special separation between them -- same caveat as
29
+ graph_to_mermaid(); filter `graph.nodes` first for one digraph per
30
+ context.
31
+
32
+ `orientation` (default "BT", bottom-to-top) is validated the same way
33
+ graph_to_mermaid() validates it (aat.core.orientation, shared between
34
+ both renderers) -- but Graphviz's `rankdir` graph attribute has no "TD"
35
+ synonym of its own, so an orientation of "TD" (top-down) is mapped to
36
+ Graphviz's "TB" (top-to-bottom -- the same direction, just Graphviz's own
37
+ spelling of it) when it's written out; see graph_to_dot()'s own
38
+ docstring.
39
+ """
40
+
41
+ from typing import Dict, List, Tuple
42
+
43
+ from .coloring import ColorTriple, assign_action_colors
44
+ from .graph import AATGraph, AATNode
45
+ from .orientation import normalize_orientation
46
+
47
+ # Graphviz node shape/style, keyed by AATNode.role -- see this module's
48
+ # own docstring for why target maps to shape=ellipse rather than a
49
+ # "stadium" Graphviz has no name for. A role this module doesn't
50
+ # recognize (shouldn't happen -- Role is a Literal of exactly these
51
+ # three -- but AATNode itself doesn't enforce that at the type level for
52
+ # a hand-built or deserialized node) falls back to a plain box, same
53
+ # fallback graph_to_mermaid() uses for an unrecognized role.
54
+ _ROLE_SHAPE = {
55
+ "action": "box",
56
+ "agent": "box",
57
+ "target": "ellipse",
58
+ }
59
+
60
+ # Extra `style=` keywords layered on top of a node's own shape, keyed the
61
+ # same way -- only agent needs one of its own (rounded corners); a
62
+ # color_by_action fill (see graph_to_dot()) adds "filled" to whatever's
63
+ # here rather than overwriting it, so a colored agent node still ends up
64
+ # rounded (style="rounded,filled"), not a plain filled rectangle.
65
+ _ROLE_EXTRA_STYLES: Dict[str, List[str]] = {
66
+ "agent": ["rounded"],
67
+ }
68
+
69
+
70
+ def _node_key(node: AATNode) -> Tuple[str, str]:
71
+ return (node.context, node.id)
72
+
73
+
74
+ def _escape_label(text: str) -> str:
75
+ """Escape `text` for use inside a DOT double-quoted string literal --
76
+ backslash first (so escaping the quote below doesn't get re-escaped
77
+ itself), then the quote character."""
78
+ return text.replace("\\", "\\\\").replace('"', '\\"')
79
+
80
+
81
+ def graph_to_dot(
82
+ graph: AATGraph,
83
+ orientation: str = "BT",
84
+ color_by_action: bool = True,
85
+ ) -> Tuple[str, List[str]]:
86
+ """Build a Graphviz DOT digraph from an AATGraph -- see this module's
87
+ own docstring for the node-shape/edge/coloring mapping, which mirrors
88
+ aat.core.mermaid.graph_to_mermaid() exactly except for the target
89
+ syntax.
90
+
91
+ `orientation` is validated exactly like graph_to_mermaid()'s (see
92
+ aat.core.orientation) -- `BT` (bottom-to-top, the default here),
93
+ `TB`/`TD` (top-down -- synonyms), `LR`, or `RL`. Written out as
94
+ Graphviz's own `rankdir` graph attribute; since `rankdir` has no
95
+ "TD" of its own, "TD" is mapped to "TB" here (same direction, just
96
+ Graphviz's own name for it) -- every other value is used verbatim.
97
+ Anything outside that vocabulary raises `ValueError` naming the
98
+ valid options, rather than silently producing invalid DOT syntax.
99
+
100
+ `color_by_action` (default True) -- see this module's own docstring.
101
+ Pass False for a plain, uncolored digraph.
102
+
103
+ Returns (dot_text, warnings) -- same warning cases as
104
+ graph_to_mermaid(): a node whose `related_node` doesn't resolve to
105
+ another node actually present in `graph` (same context) is still
106
+ drawn, but its edge is skipped and reported as a warning; and, if
107
+ `color_by_action` is True and the graph has more distinct actions
108
+ than the palette has colors (currently 8), one warning notes that
109
+ colors repeat.
110
+ """
111
+ orientation = normalize_orientation(orientation)
112
+ rankdir = "TB" if orientation == "TD" else orientation
113
+
114
+ by_key: Dict[Tuple[str, str], AATNode] = {_node_key(n): n for n in graph.nodes}
115
+
116
+ color_of_node: Dict[Tuple[str, str], ColorTriple] = {}
117
+ warnings: List[str] = []
118
+ if color_by_action:
119
+ color_of_node, color_warnings = assign_action_colors(graph)
120
+ warnings.extend(color_warnings)
121
+
122
+ lines = ["digraph aat {", f" rankdir={rankdir};"]
123
+
124
+ for node in graph.nodes:
125
+ attrs = [f"shape={_ROLE_SHAPE.get(node.role, 'box')}"]
126
+ styles = list(_ROLE_EXTRA_STYLES.get(node.role, []))
127
+
128
+ color = color_of_node.get(_node_key(node))
129
+ if color is not None:
130
+ fill, stroke, text = color
131
+ styles.append("filled")
132
+ attrs.append(f'fillcolor="{fill}"')
133
+ attrs.append(f'color="{stroke}"')
134
+ attrs.append(f'fontcolor="{text}"')
135
+
136
+ if styles:
137
+ attrs.append(f'style="{",".join(styles)}"')
138
+ attrs.append(f'label="{_escape_label(node.value)}"')
139
+
140
+ lines.append(f' {node.id} [{", ".join(attrs)}];')
141
+
142
+ for node in graph.nodes:
143
+ if node.related_node is None:
144
+ continue
145
+ target_key = (node.context, node.related_node)
146
+ if target_key not in by_key:
147
+ warnings.append(
148
+ f"skipped edge {node.id} -[{node.role}]-> {node.related_node}: "
149
+ "target is not a node in this graph"
150
+ )
151
+ continue
152
+ edge_label = "dependent" if node.role == "action" else node.role
153
+ lines.append(f' {node.id} -> {node.related_node} [label="{edge_label}"];')
154
+
155
+ lines.append("}")
156
+ return "\n".join(lines), warnings
157
+
158
+
159
+ def save_dot(
160
+ graph: AATGraph,
161
+ path: str,
162
+ orientation: str = "BT",
163
+ color_by_action: bool = True,
164
+ ) -> List[str]:
165
+ """Write the digraph to `path` (e.g. 'analysis.dot') and return any
166
+ warnings from graph_to_dot(). `orientation` is validated the same
167
+ way -- see graph_to_dot()'s own docstring."""
168
+ dot_text, warnings = graph_to_dot(graph, orientation=orientation, color_by_action=color_by_action)
169
+ with open(path, "w", encoding="utf-8") as f:
170
+ f.write(dot_text + "\n")
171
+ return warnings