├── .dir-locals.el ├── .gitignore ├── .mypy.ini ├── LICENSE.MIT ├── README.md ├── doc ├── sequence.pdf └── sequence.svg ├── examples ├── MChannel.tla ├── pingpong.cfg └── pingpong.tla ├── setup.py └── tlsd ├── __main__.py └── parse_messages.py /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ;;; Directory Local Variables 2 | ;;; For more information see (info "(emacs) Directory Variables") 3 | 4 | ((tla-mode 5 | (tla-tab-width . 3))) 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.dot 2 | *~ 3 | *.pdf 4 | *_TTrace_*.tla 5 | states 6 | check.txt 7 | __pycache__ 8 | 9 | # tlatex 10 | *.aux 11 | *.dvi 12 | *.log 13 | *.tex 14 | sequence.svg 15 | sequence.pdf 16 | sequence-*.svg 17 | sequence-*.pdf 18 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | no_implicit_optional = True 4 | warn_unreachable = True 5 | strict_equality = True 6 | # too strict in presence of the untyped drawSVG package 7 | #strict = True 8 | show_error_context = True 9 | show_column_numbers = True 10 | -------------------------------------------------------------------------------- /LICENSE.MIT: -------------------------------------------------------------------------------- 1 | Copyright 2021-2022 Erkki Seppälä 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tla-sequence-diagram 2 | 3 | This is a tool for generating sequence diagrams from 4 | [TLC](https://github.com/tlaplus/tlaplus/) state traces. It produces 5 | SVGs that look like: 6 | 7 | ![Sequence diagram](doc/sequence.svg) 8 | 9 | or [like this PDF](doc/sequence.pdf). 10 | 11 | This tool is licensed under the [MIT license](LICENSE.MIT). 12 | 13 | Copyright: Erkki Seppälä 2022 14 | 15 | You can contact me also [via 16 | Matrix](https://matrix.to/#/@flux:matrix.org). 17 | 18 | ## What is TLA+? 19 | 20 | TLA+ (Temporal Logic of Actions+) is a way to describe the behavior of 21 | an algorithm or a system at a high, yet in a very mathematically 22 | precise manner. This allows one to reason about the system behavior in 23 | a more accurate way than with a textual description of system 24 | behavior. Combined with the tool TLC (Temporal Logic Checker) to do 25 | checks with those models it increases the confidence of getting the 26 | design right from the beginning—or later on finding corner cases in 27 | the design that had not been detected by rigorous testing or code 28 | reviews. 29 | 30 | You can learn more about TLA+ at [the TLA+ home 31 | page](http://lamport.azurewebsites.net/tla/tla.html). There's even [a 32 | book](http://lamport.azurewebsites.net/tla/book.html?back-link=learning.html#book)! 33 | 34 | ## So what is this tool then? 35 | 36 | When using TLC and an invariant you have set up for it fails, you end 37 | up with a state dump. Sometimes this state dump can become unwieldy 38 | or at least very slow to to analyze. This tool aims to help analyzing 39 | certain kind of systems: the ones that are composed of individual 40 | nodes exchanging messages with each other. 41 | 42 | It achieves this by converting translated state traces into something 43 | that's very close to standard sequence diagrams. The only difference I 44 | see compared to standard sequence diagrams is that the message sending 45 | and reception are decoupled in the diagram: messages will be received 46 | later—sometimes much later—compared to when they've been sent, and 47 | other behavior can be interleaved during that time. I'm not sure if 48 | the standard diagrams would also be able to express this, though, but 49 | if this is the case then perhaps a lot of this SVG rendering code 50 | would be needles :). 51 | 52 | Any node can exchange messages with any other node, though the example 53 | doesn't yet demonstrate this. 54 | 55 | The tool doesn't try to avoid overlapping labels or lines with other 56 | objects yet, but this is also something I'm planning to implement at 57 | some point. 58 | 59 | ## Trying out the example 60 | 61 | 1) clone the repository, `cd` to it 62 | 2) `pip install .` You may wish to `sudo apt install python3-pillow` first in Debian-based systems. 63 | A good alternative to plain `pip` is to use [pipx](https://pypi.org/project/pipx/): `pipx install .`. 64 | 3) `cd examples` 65 | 4) run `tlc pingpong | tlsd` to get `sequence.svg` 66 | 67 | [pingpong.cfg](examples/pingpong.cfg) refers to `ALIAS AliasMessages` 68 | where the `ALIAS` is defined in [pingpong.tla](examples/pingpong.tla) 69 | to produce output in the form the tool currently expects it at. 70 | 71 | To produce decent pdfs out of the svg files you can use [Inkscape](https://inkscape.org/): 72 | ``` 73 | inkscape --export-pdf=sequence.pdf sequence.svg 74 | ``` 75 | 76 | ## Theory of operation 77 | 78 | At each state (in the dump) there is JSON value with the key 79 | `messages_json`, which contains all the pending messages between 80 | server and the clients. Currently the tool assumes there is a central 81 | server all want to exchange messages with, as there is no way to 82 | indicate which server a client is interacting with. There are two 83 | kinds of channels in the example: ones from the server to the clients 84 | and ones from the clients to the server. 85 | 86 | Once a message appears in a channel (e.g. the channel is `busy`), it 87 | is considered to be sent by the tool. Once a message disappears (no 88 | longer `busy`) from the channel, it is considered to have been 89 | received by the peer. 90 | -------------------------------------------------------------------------------- /doc/sequence.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eras/tlsd/fbb6265b1c560fd61996e77f859035e2c241d542/doc/sequence.pdf -------------------------------------------------------------------------------- /doc/sequence.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Invariant NotFinished is violated. 20 | 21 | State 1 22 | 23 | State 2 24 | 25 | State 3 26 | 27 | State 4 28 | 29 | State 5 30 | 31 | State 6 32 | 33 | State 7 34 | 35 | ('server', 1) 36 | 37 | SendPing 38 | 39 | SendPing 40 | 41 | HandlePong 42 | 43 | HandlePong 44 | 45 | ('client', 1) 46 | 47 | HandlePings 48 | 49 | ('client', 2) 50 | 51 | HandlePings 52 | 53 | message = "ping" 54 | 55 | message = "ping" 56 | 57 | message = "pong" 58 | 59 | message = "pong" 60 | -------------------------------------------------------------------------------- /examples/MChannel.tla: -------------------------------------------------------------------------------- 1 | ------------------------------ MODULE MChannel ------------------------------ 2 | 3 | (* MChannel is an asynchronous channel between with a buffer of at most 4 | one message. 5 | 6 | The state is stored in the channels function. Channels maps via Id to the 7 | actual channels. 8 | 9 | Copyright 2022 Erkki Seppälä 10 | *) 11 | 12 | ---------------------------------------------------------------------------- 13 | LOCAL INSTANCE TLC (* Assert *) 14 | 15 | CONSTANT Id (* Id is used to find this instance from channels *) 16 | CONSTANT Data (* Data constrains the kind of messages this module processes*) 17 | 18 | VARIABLE channels (* A function of channels: [Id -> Channel] *) 19 | 20 | (* When a channel is not busy, it has this value. Redundant really to 21 | have the 'busy' flag at all, but maybe it makes things more clear 22 | *) 23 | Null == <<>> 24 | 25 | Channel == [val: Data \cup {Null}, busy: BOOLEAN] 26 | 27 | TypeOK == channels[Id] \in Channel 28 | 29 | Send(data) == 30 | /\ Assert(data \in Data, <<"Sending invalid data", data, "while expecting", Data>>) 31 | /\ \lnot channels[Id].busy 32 | /\ channels' = [channels EXCEPT ![Id] = [@ EXCEPT !.val = data, !.busy = TRUE]] 33 | 34 | Recv(data) == 35 | /\ Assert(data \in Data, <<"Receiving invalid data", data, "while expecting", Data>>) 36 | /\ channels[Id].busy 37 | /\ data = channels[Id].val 38 | /\ channels' = [channels EXCEPT ![Id] = [@ EXCEPT !.val=Null, !.busy = FALSE]] 39 | 40 | Sending == 41 | IF channels[Id].busy 42 | THEN {channels[Id].val} 43 | ELSE {} 44 | 45 | InitValue == [val |-> Null, busy |-> FALSE] 46 | 47 | ============================================================================= 48 | -------------------------------------------------------------------------------- /examples/pingpong.cfg: -------------------------------------------------------------------------------- 1 | SPECIFICATION Spec 2 | CONSTANT NumberOfClients=2 3 | CONSTANT NumberOfPings=4 4 | INVARIANT TypeOK 5 | INVARIANT NotFinished 6 | ALIAS AliasMessages 7 | -------------------------------------------------------------------------------- /examples/pingpong.tla: -------------------------------------------------------------------------------- 1 | ---- MODULE pingpong ----------------------------------------------------------- 2 | 3 | (* pingpong is a model where there exists one server and multiple 4 | clients. The server my send the clients a "ping" message, which 5 | they will always respond with a "pong" message. 6 | 7 | Once NumberOfPings pings have been sent and the same number of 8 | pongs have been received by the server, is the INVARIANT 9 | NotFinished violated and a state trace is produced. The state trace 10 | is transformed from the actual system state via the ALIAS 11 | AliasMessage, which only shows the messages in a format expected by 12 | the tool. 13 | 14 | Copyright 2022 Erkki Seppälä 15 | *) 16 | 17 | -------------------------------------------------------------------------------- 18 | 19 | LOCAL INSTANCE Naturals (* .. *) 20 | LOCAL INSTANCE Json (* ToJson *) 21 | 22 | CONSTANT 23 | NumberOfClients 24 | (* Number of pings the server sends and receives before we consider the 25 | scenario Finished *) 26 | , NumberOfPings 27 | 28 | VARIABLES 29 | server_to_client (* channels from the server to the clients *) 30 | , client_to_server (* channels from the clients to the server *) 31 | , num_pings_sent (* number of pings sent by the server *) 32 | , num_pongs_received (* number of pongs received by the server *) 33 | 34 | ClientIds == 1..NumberOfClients 35 | 36 | vars == <> 37 | 38 | Data == [message: {"ping"}] \cup [message: {"pong"}] 39 | 40 | (* Channels for messages from the server to the clients *) 41 | ServerToClientChannel(Id) == INSTANCE MChannel WITH channels <- server_to_client 42 | (* Channels for messages from the clients to the server *) 43 | ClientToServerChannel(Id) == INSTANCE MChannel WITH channels <- client_to_server 44 | 45 | (* Server sends ping to a client *) 46 | ServerSendPing == 47 | /\ num_pings_sent < NumberOfPings 48 | /\ \E client_id \in ClientIds: 49 | ServerToClientChannel(client_id)!Send([message |-> "ping"]) 50 | /\ num_pings_sent' = num_pings_sent + 1 51 | /\ UNCHANGED<> 52 | 53 | (* Server receives a ping from a client *) 54 | ServerHandlePong == 55 | /\ \E client_id \in ClientIds: 56 | /\ ClientToServerChannel(client_id)!Recv([message |-> "pong"]) 57 | /\ num_pongs_received' = num_pongs_received + 1 58 | /\ UNCHANGED<> 59 | 60 | (* Handle pings one client at a time: upon receiving ping, respond with pong *) 61 | ClientHandlePing(client_id) == 62 | /\ ServerToClientChannel(client_id)!Recv([message |-> "ping"]) 63 | /\ ClientToServerChannel(client_id)!Send([message |-> "pong"]) 64 | /\ UNCHANGED<> 65 | 66 | Stutter == UNCHANGED<> 67 | 68 | Next == 69 | \/ ServerSendPing 70 | \/ ServerHandlePong 71 | \/ \E client_id \in ClientIds: 72 | ClientHandlePing(client_id) 73 | \/ Stutter 74 | 75 | Init == 76 | /\ server_to_client = [client_id \in ClientIds |-> ServerToClientChannel(client_id)!InitValue] 77 | /\ client_to_server = [client_id \in ClientIds |-> ClientToServerChannel(client_id)!InitValue] 78 | /\ num_pings_sent = 0 79 | /\ num_pongs_received = 0 80 | 81 | Spec == 82 | /\ Init 83 | /\ [][Next]_vars 84 | 85 | TypeOK == 86 | /\ num_pings_sent \in Nat 87 | /\ num_pongs_received \in Nat 88 | /\ \A client_id \in ClientIds: 89 | /\ ServerToClientChannel(client_id)!TypeOK 90 | /\ ClientToServerChannel(client_id)!TypeOK 91 | 92 | Finished == 93 | /\ num_pings_sent = num_pongs_received 94 | /\ num_pings_sent = NumberOfPings 95 | 96 | NotFinished == \lnot Finished 97 | 98 | AllMessages == 99 | UNION({UNION({ 100 | {{<<"server", 1>>} \X {<<"client", client_id>>} \X ServerToClientChannel(client_id)!Sending} 101 | , {{<<"client", client_id>>} \X {<<"server", 1>>} \X ClientToServerChannel(client_id)!Sending} 102 | }) : client_id \in ClientIds}) 103 | 104 | State == 105 | [server |-> <<[pings |-> num_pings_sent, 106 | pongs |-> num_pongs_received]>>] 107 | 108 | AliasMessages == 109 | [lane_order_json |-> ToJson(<<"client", "server">>), 110 | messages_json |-> ToJson(AllMessages), 111 | state_json |-> ToJson(State) 112 | ] 113 | 114 | ================================================================================ 115 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='tlsd', 5 | version='0.1.0', 6 | author='Erkki Seppälä', 7 | author_email='flux@inside.org', 8 | packages=['tlsd'], 9 | scripts=[], 10 | url='http://pypi.python.org/pypi/tlsd/', 11 | license='LICENSE.MIT', 12 | description='Tool for generating sequence diagrams from TLC state traces', 13 | long_description=open('README.md').read(), 14 | install_requires=[ 15 | "drawSvg==1.8.3", 16 | "pillow==5.4.1" 17 | ], 18 | entry_points = { 19 | 'console_scripts': [ 20 | 'tlsd = tlsd.parse_messages:main', 21 | ], 22 | }, 23 | ) 24 | -------------------------------------------------------------------------------- /tlsd/__main__.py: -------------------------------------------------------------------------------- 1 | from . import parse_messages 2 | 3 | parse_messages.main() 4 | -------------------------------------------------------------------------------- /tlsd/parse_messages.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import fileinput 5 | import re 6 | import textwrap 7 | from dataclasses import dataclass, field 8 | from typing import * 9 | 10 | import drawSvg as draw 11 | 12 | state_id_re = re.compile(r"^State ([0-9][0-9]*): <([^ ]*)") 13 | variable_re = re.compile(r"^(/\\ )?([^ ]*_json) = \"(.*)\"$") 14 | quoted_dquote_re = re.compile(r"\\\"") 15 | error_starts_re = re.compile(r"^Error: (.*)") 16 | error_occurred_re = re.compile(r"Error: The error occurred when TLC was evaluating the nested") 17 | 18 | NodeId = Tuple[str, int] 19 | 20 | # from https://github.com/python/typing/issues/182#issuecomment-899624078 21 | if TYPE_CHECKING: 22 | class JSONArray(list[JSONType], Protocol): # type: ignore 23 | __class__: Type[list[JSONType]] # type: ignore 24 | 25 | class JSONObject(dict[str, JSONType], Protocol): # type: ignore 26 | __class__: Type[dict[str, JSONType]] # type: ignore 27 | 28 | JSONType = Union[None, int, float, str, JSONArray, JSONObject] 29 | else: 30 | JSONType = Any 31 | 32 | def make_node_id_key_comparison(node_comparison_values: Dict[str, int]): 33 | def node_id_key(node_id: NodeId) -> Tuple[Union[str, int], int]: 34 | if node_id[0] in node_comparison_values: 35 | return (node_comparison_values[node_id[0]], node_id[1]) 36 | else: 37 | return node_id 38 | return node_id_key 39 | 40 | class UnreadableInput: 41 | """An input that can unread lines. Lines pushed to the unread queue are 42 | returned next before consulting the underlying input.""" 43 | _input: fileinput.FileInput 44 | _buffer: List[str] 45 | 46 | def __init__(self, stream: fileinput.FileInput) -> None: 47 | self._input = stream 48 | self._buffer = [] 49 | 50 | def __iter__(self) -> "UnreadableInput": 51 | return self 52 | 53 | def unread(self, line: str): 54 | self._buffer.append(line) 55 | 56 | def has_unread(self) -> bool: 57 | return bool(self._buffer) 58 | 59 | def __next__(self) -> str: 60 | if self._buffer: 61 | return self._buffer.pop(0) 62 | else: 63 | return self._input.__next__() 64 | 65 | class Environment: 66 | nodes : Dict[NodeId, "Node"] 67 | lane_order : Dict[str, int] 68 | 69 | def __init__(self) -> None: 70 | self.nodes = {} 71 | self.lane_order = {} 72 | 73 | def get_node(self, node_id: NodeId) -> "Node": 74 | if node_id not in self.nodes: 75 | self.nodes[node_id] = Node(self, node_id) 76 | return self.nodes[node_id] 77 | 78 | def get_lane(self, node_id: NodeId) -> int: 79 | # slow :). could be cached. 80 | return [index 81 | for index, cur_node_id 82 | in enumerate(sorted(self.nodes.keys(), key=make_node_id_key_comparison(self.lane_order))) 83 | if node_id == cur_node_id][0] 84 | 85 | 86 | Message = Dict[str, JSONType] 87 | State = Dict[str, JSONType] 88 | 89 | StateId = int 90 | 91 | STATE_ID_WIDTH = 100 92 | LANE_WIDTH = 10 93 | LANE_GAP = 250 94 | STATE_HEIGHT = 80 95 | STATE_WIDTH = 160 96 | 97 | @dataclass 98 | class StateMessage: 99 | state_id: StateId 100 | message: Message 101 | 102 | @dataclass 103 | class MessageInfo: 104 | message: Message 105 | sent_at: StateId 106 | received_at: Optional[StateId] 107 | 108 | @dataclass 109 | class PeerReceived: 110 | peer: NodeId 111 | sent_at: StateId 112 | 113 | @dataclass 114 | class Diff: 115 | fragment: str 116 | different: bool = False # this was the smallest different component in the subtree 117 | 118 | T = TypeVar("T") 119 | def default(value: T, x: Optional[T]) -> T: 120 | if x is None: 121 | return value 122 | else: 123 | return x 124 | 125 | U = TypeVar("U") 126 | def invert_dict(kvs: Dict[T, U]) -> Dict[U, T]: 127 | result: Dict[U, T] = {} 128 | for key, value in kvs.items(): 129 | result[value] = key 130 | return result 131 | 132 | def compare(old: JSONType, new: JSONType) -> List[Diff]: 133 | diff = [] 134 | if isinstance(old, dict) or isinstance(new, dict): 135 | if new is None: 136 | diff.append(Diff(fragment="None", different=True)) 137 | else: 138 | assert(isinstance(default(old, {}), dict)) 139 | assert(isinstance(new, dict)) 140 | old2: Dict[str, JSONType] = {} 141 | if old is not None: 142 | assert(isinstance(old, dict)) 143 | old2 = old 144 | first = True 145 | diff.append(Diff(fragment="{ ")) 146 | assert(isinstance(new, dict)) 147 | for key, new_value in new.items(): 148 | if not first: 149 | diff.append(Diff(fragment=", ")) 150 | first = False 151 | diff.append(Diff(fragment=f"{key}:")) 152 | if key in old2: 153 | old_value = old2[key] 154 | diff.extend(compare(old_value, new_value)) 155 | else: 156 | diff.extend(compare(None, new_value)) 157 | diff.append(Diff(fragment=" }")) 158 | elif isinstance(old, str) or isinstance(new, str): 159 | assert(isinstance(default(old, ""), str)) 160 | # TODO: quoting 161 | if new is None: 162 | diff.append(Diff(fragment="None", different=True)) 163 | else: 164 | assert(isinstance(new, str)) 165 | diff.append(Diff(fragment="\"" + new + "\"", 166 | different=old != new)) 167 | elif isinstance(old, int) or isinstance(new, int): 168 | assert(isinstance(default(old, 0), int)) 169 | assert(isinstance(default(new, 0), int)) 170 | if new is None: 171 | diff.append(Diff(fragment="None", different=True)) 172 | else: 173 | diff.append(Diff(fragment=str(new), 174 | different=old != new)) 175 | elif isinstance(old, list) or isinstance(new, list): 176 | assert(isinstance(default(old, []), list)) 177 | assert(isinstance(default(new, []), list)) 178 | assert False, f"Unsupported data type list: {old} vs {new}" 179 | else: 180 | assert False, f"Unsupported data type: {old} vs {new}" 181 | 182 | return diff 183 | 184 | def json_to_tspans(data: Dict[str, JSONType], x: float) -> List[draw.TSpan]: 185 | lines = [] 186 | for key, value in data.items(): 187 | content = f"{key} = {json.dumps(value)}" 188 | for line in textwrap.wrap(content, width=30): 189 | lines.append(draw.TSpan(line, x=x, dy="1em")) 190 | return lines 191 | 192 | def diff_to_tspans(diffs: List[Diff], x: float) -> List[draw.TSpan]: 193 | spans = [] 194 | cur_line_len = 0 195 | max_len = 30 196 | for diff in diffs: 197 | do_wrap = len(diff.fragment) + cur_line_len > max_len 198 | font_style = "italic" if diff.different else "normal" 199 | if do_wrap: 200 | cur_line_len = 0 201 | spans.append(draw.TSpan(diff.fragment, x=x, dy="1.1em", font_style=font_style)) 202 | else: 203 | cur_line_len += len(diff.fragment) 204 | spans.append(draw.TSpan(diff.fragment, font_style=font_style)) 205 | return spans 206 | 207 | class TextTSpans(draw.Text): 208 | def __init__(self, lines: List[draw.TSpan], fontSize: float, x: float, y: float, **kwargs) -> None: 209 | super().__init__([], fontSize, x, y, **kwargs) 210 | for line in lines: 211 | self.append(line) 212 | 213 | def arrowSymbol(): 214 | return draw.Lines(-0.1, -0.5, -0.1, 0.5, 0.9, 0, fill='green', close=True) 215 | 216 | def crossSymbol(): 217 | """ 218 | 9 A K 219 | 8 ... ... 220 | 7B.... ....J 221 | 6 ..... ..... 222 | 5 ..... ..... 223 | 4 ..... ..... 224 | 3 ..... . ... 225 | 2 ....L.... 226 | 1 ....... 227 | 0 C.*.I 228 | 1 ....... 229 | 2 ....F.... 230 | 3 ..... ..... 231 | 4 ..... ..... 232 | 5 ..... ..... 233 | 6 ..... ..... 234 | 7D.... ....H 235 | 8 ... ... 236 | 9 E G 237 | 9876543210123456789 238 | """ 239 | return draw.Lines(-0.9, 0.7, 240 | -0.2, 0.0, 241 | -0.9, -0.7, 242 | -0.7, -0.9, 243 | 0.0, -0.2, 244 | 0.7, -0.9, 245 | 0.9, -0.7, 246 | 0.2, 0.0, 247 | 0.9, 0.7, 248 | 0.7, 0.9, 249 | 0.0, 0.2, 250 | -0.7, 0.9, 251 | fill='red', close=True) 252 | 253 | class Node: 254 | node_id : NodeId 255 | active_send : Dict[NodeId, StateMessage] 256 | active_receive : Dict[NodeId, StateMessage] 257 | state_id_range : Optional[Tuple[StateId, StateId]] 258 | action_names : Dict[StateId, str] 259 | env : Environment 260 | messages_sent : Dict[StateId, Dict[NodeId, MessageInfo]] 261 | states : Dict[StateId, State] 262 | 263 | # used to map from state ids to their predecessors 264 | prev_state_id_cache : Optional[Dict[StateId, StateId]] 265 | 266 | def __init__(self, env: Environment, node_id: NodeId) -> None: 267 | self.env = env 268 | self.node_id = node_id 269 | self.active_send = {} 270 | self.active_receive = {} 271 | self.state_id_range = None 272 | self.action_names = {} 273 | self.messages_sent = {} 274 | self.states = {} 275 | self.prev_state_id_cache = None 276 | 277 | def prev_state_id(self, state_id: StateId) -> Optional[StateId]: 278 | if not self.prev_state_id_cache: 279 | self.prev_state_id_cache = {} 280 | prev: Optional[StateId] = None 281 | for key in sorted(self.states.keys()): 282 | if prev: 283 | self.prev_state_id_cache[key] = prev 284 | prev = key 285 | return self.prev_state_id_cache.get(state_id) 286 | 287 | def prev_state(self, state_id: StateId) -> Optional[State]: 288 | prev_state_id = self.prev_state_id(state_id) 289 | if prev_state_id is not None: 290 | return self.states[prev_state_id] 291 | else: 292 | return None 293 | 294 | def update_state(self, state_id: StateId, state: State, action_name: str) -> None: 295 | self.prev_state_id_cache = None 296 | self.states[state_id] = state 297 | if self.states[state_id] != self.states.get(state_id - 1): 298 | self.action_names[state_id] = action_name 299 | 300 | def update_state_id_range(self, state_id: StateId) -> None: 301 | if self.state_id_range is None: 302 | self.state_id_range = (state_id, state_id) 303 | else: 304 | self.state_id_range = (self.state_id_range[0], state_id) 305 | 306 | def send_active(self, state_id: StateId, action_name: str, peer: NodeId, message: Message) -> None: 307 | self.update_state_id_range(state_id) 308 | if peer not in self.active_send: 309 | sent = StateMessage(state_id=state_id, message=message) 310 | print(f"st {state_id}\t{self.node_id}\tsends to\t{peer}\t@ st {sent.state_id}\t: {sent.message}") 311 | if not state_id in self.messages_sent: 312 | self.messages_sent[state_id] = {} 313 | self.action_names[state_id] = action_name 314 | self.messages_sent[state_id][peer] = MessageInfo(message=message, 315 | sent_at=state_id, 316 | received_at=None) 317 | self.active_send[peer] = sent 318 | def send_inactive(self, state_id: StateId, action_name: str, peer: NodeId) -> None: 319 | self.update_state_id_range(state_id) 320 | if peer in self.active_send: 321 | sent = self.active_send[peer] 322 | assert sent.state_id < state_id, f"Message received from {self.node_id} to {peer} in earlier state than it was sent. sent: {sent.state_id} now {state_id}. active sends: {self.active_send}" 323 | if sent.state_id in self.messages_sent and peer in self.messages_sent[sent.state_id]: 324 | self.messages_sent[sent.state_id][peer].received_at = state_id 325 | print(f"st {state_id}\t{peer}\trecvs from\t{self.node_id}\t@ st {sent.state_id}\t: {sent.message}") 326 | del self.active_send[peer] 327 | 328 | def recv_active(self, state_id: StateId, action_name: str, peer: NodeId, message: Message) -> None: 329 | self.update_state_id_range(state_id) 330 | if peer not in self.active_receive: 331 | received = StateMessage(state_id=state_id, message=message) 332 | self.active_receive[peer] = received 333 | #self.action_names[state_id] = action_name 334 | # TODO: maybe do something here? 335 | 336 | def recv_inactive(self, state_id: StateId, action_name: str, peer: NodeId) -> None: 337 | self.update_state_id_range(state_id) 338 | if peer in self.active_receive: 339 | received = self.active_receive[peer] 340 | del self.active_receive[peer] 341 | self.action_names[state_id] = action_name 342 | pass 343 | 344 | def draw_states(self, svg) -> None: 345 | self.draw_lane(svg) 346 | for state_id, _ in self.states.items(): 347 | if (state_id in self.action_names or 348 | self.states[state_id] != self.states.get(state_id - 1)): 349 | self.draw_state(svg, state_id) 350 | 351 | def draw_transitions(self, svg) -> None: 352 | for state_id, messages in self.messages_sent.items(): 353 | for peer, message_info in messages.items(): 354 | self.draw_message(svg, state_id, self.env.get_node(peer), message_info) 355 | 356 | def lane(self) -> int: 357 | return self.env.get_lane(self.node_id) 358 | 359 | def lane_base_x(self) -> float: 360 | return STATE_ID_WIDTH + STATE_WIDTH + self.lane() * (LANE_WIDTH + LANE_GAP) + (LANE_WIDTH - STATE_WIDTH) / 2 361 | 362 | def draw_state(self, svg, state_id: StateId) -> None: 363 | state_x = self.lane_base_x() 364 | state_y = - ((state_id - 1) * STATE_HEIGHT + STATE_HEIGHT) 365 | svg.append(draw.Rectangle(state_x, state_y, 366 | STATE_WIDTH, STATE_HEIGHT, 367 | stroke='black', stroke_width='1', 368 | fill='white')) 369 | 370 | delta_y = -8 371 | 372 | if state_id in self.action_names: 373 | svg.append(draw.Text(self.action_names[state_id], 10, 374 | state_x + STATE_WIDTH / 2.0, state_y + STATE_HEIGHT + delta_y, 375 | text_anchor='middle', dominant_baseline='hanging')) 376 | delta_y -= 12 377 | 378 | state = self.states.get(state_id) 379 | if state is not None: 380 | prev_state = self.prev_state(state_id) 381 | x = state_x + STATE_WIDTH / 2.0 382 | diff = compare(convert_tla_function_to_json(prev_state), 383 | convert_tla_function_to_json(state)) 384 | svg.append(TextTSpans(diff_to_tspans(diff, x=x), 8, 385 | x, state_y + STATE_HEIGHT + delta_y, 386 | text_anchor='middle', dominant_baseline='hanging')) 387 | 388 | 389 | def draw_message(self, svg, state_id: StateId, peer: "Node", message_info: MessageInfo) -> None: 390 | arrow_right = self.lane_base_x() < peer.lane_base_x() 391 | adjust_source_x = STATE_WIDTH / 2 + 5 392 | adjust_peer_x = -STATE_WIDTH / 2 - 15 393 | if not arrow_right: 394 | adjust_source_x = -adjust_source_x 395 | adjust_peer_x = -adjust_peer_x 396 | arrow = draw.Marker(-0.1, -0.5, 0.9, 0.5, scale=15, orient='auto') 397 | 398 | if message_info.received_at is not None: 399 | arrow.append(arrowSymbol()) 400 | else: 401 | arrow.append(crossSymbol()) 402 | 403 | a = (self.lane_base_x() + STATE_WIDTH / 2 + adjust_source_x, 404 | - (((message_info.sent_at - 1) + 0.9) * STATE_HEIGHT)) 405 | 406 | if message_info.received_at is not None: 407 | arrow_y = message_info.received_at - 1.0 408 | else: 409 | arrow_y = message_info.sent_at + 0.5 410 | 411 | b = (peer.lane_base_x() + STATE_WIDTH / 2 + adjust_peer_x, 412 | - ((arrow_y + 0.1) * STATE_HEIGHT)) 413 | 414 | path = draw.Lines(a[0], a[1], 415 | b[0], b[1], 416 | close=False, 417 | fill='#eeee00', 418 | stroke='black', 419 | marker_end=arrow) 420 | svg.append(path) 421 | 422 | x = (a[0] + b[0]) / 2 423 | svg.append(TextTSpans(json_to_tspans(message_info.message, x=x), 10, 424 | x, (a[1] + b[1]) / 2, 425 | text_anchor="middle")) 426 | 427 | def draw_lane(self, svg) -> None: 428 | assert self.state_id_range is not None 429 | state_id_min = self.state_id_range[0] 430 | state_id_max = self.state_id_range[1] 431 | height = (state_id_max - state_id_min + 1) * STATE_HEIGHT + 20 432 | base_x = STATE_ID_WIDTH + STATE_WIDTH + self.lane() * (LANE_WIDTH + LANE_GAP) 433 | svg.append(draw.Rectangle(base_x, 434 | - ((state_id_min - 1) * STATE_HEIGHT + height - 10), 435 | LANE_WIDTH, height, 436 | stroke='black', stroke_width='1', 437 | fill='none')) 438 | 439 | svg.append(draw.Text(f"{self.node_id}", 20, 440 | base_x, -0 + 1, 441 | text_anchor='middle')) 442 | 443 | if self.states.get(1): 444 | self.draw_state(svg, 1) 445 | 446 | def unquote(s: str) -> str: 447 | return quoted_dquote_re.sub("\"", s) 448 | 449 | def node_id_of(json: JSONType) -> NodeId: 450 | assert isinstance(json, list) 451 | assert len(json) == 2 452 | assert isinstance(json[0], str) 453 | assert isinstance(json[1], int) 454 | return (json[0], json[1]) 455 | 456 | def convert_tla_function_to_dict(data: Union[list, dict]) -> Dict[int, JSONType]: 457 | if isinstance(data, list): 458 | return {index + 1: data[index] for index in range(0, len(data))} 459 | elif isinstance(data, dict): 460 | return {int(key): data[key] for key in data.keys()} 461 | else: 462 | assert False, "Expected list or dict" 463 | 464 | def convert_tla_function_to_json(data: JSONType) -> JSONType: 465 | if data is None: 466 | return None 467 | elif isinstance(data, int): 468 | return data 469 | elif isinstance(data, float): 470 | return data 471 | elif isinstance(data, str): 472 | return data 473 | elif isinstance(data, list): 474 | return {str(index + 1): convert_tla_function_to_json(data[index]) for index in range(0, len(data))} 475 | elif isinstance(data, dict): 476 | return {key: convert_tla_function_to_json(data[key]) for key in data.keys()} 477 | else: 478 | assert False, f"Unexpected data: {data}" 479 | 480 | @dataclass 481 | class Data: 482 | env : Environment 483 | state_id : StateId 484 | error : List[str] 485 | 486 | def process_state(env: Environment, state_id: int, action_name: str, json: JSONType) -> None: 487 | assert isinstance(json, dict) 488 | for name, nodes in json.items(): 489 | assert isinstance(nodes, dict) or isinstance(nodes, list) 490 | for index, state in convert_tla_function_to_dict(nodes).items(): 491 | node_id = node_id_of([name, index]) 492 | assert isinstance(state, dict), f"Expected dictionary but got {state}" 493 | env.get_node(node_id).update_state(state_id, state, action_name) 494 | 495 | def process_messages(env: Environment, state_id: int, action_name: str, json: JSONType) -> None: 496 | messages: Dict[Tuple[NodeId, NodeId], Message] = {} 497 | assert isinstance(json, list) 498 | for message in json: 499 | # developer convenience to filter these empty messages here 500 | if not message or not message[0]: 501 | continue 502 | assert isinstance(message, list) and len(message) == 1 and isinstance(message[0], list) and len(message[0]) == 3, \ 503 | f"Expected message be of structure [[from, id], [to, id], message], got {message}" 504 | source = node_id_of(message[0][0]) 505 | target = node_id_of(message[0][1]) 506 | # print(f"chan: {chan} sending: {sending} index: {index} message[0]: {message[0]}") 507 | # print(f" {source}->{target} message[0]: {message[0]}") 508 | messages[(source, target)] = message[0][2] 509 | env.get_node(source) 510 | env.get_node(target) 511 | for source in env.nodes.keys(): 512 | for target in env.nodes.keys(): 513 | if (source, target) not in messages: 514 | # print(f"source={source}, target={target}") 515 | env.get_node(source).send_inactive(state_id, action_name, target) 516 | env.get_node(target).recv_inactive(state_id, action_name, source) 517 | 518 | # actually there is no causality in the TLA+ model, but IRL there is :) 519 | for source in env.nodes.keys(): 520 | for target in env.nodes.keys(): 521 | if (source, target) in messages: 522 | message = messages[(source, target)] 523 | env.get_node(source).send_active(state_id, action_name, target, message) 524 | env.get_node(target).recv_active(state_id, action_name, source, message) 525 | 526 | def process_lane_order(env: Environment, json: JSONType) -> None: 527 | def is_list(json: JSONType) -> list: 528 | assert isinstance(json, list) 529 | return json 530 | def is_str(json: JSONType) -> str: 531 | assert isinstance(json, str) 532 | return json 533 | env.lane_order = {is_str(json): index for index, json in convert_tla_function_to_dict(is_list(json)).items()} 534 | 535 | def read_variables(env: Environment, state_id: int, action_name: str, input: UnreadableInput): 536 | """Reads the variables of one state""" 537 | variables: Dict[str, JSONType] = {} 538 | for orig_line in input: 539 | line = orig_line.rstrip() 540 | variable_match = variable_re.match(line) 541 | 542 | if variable_match: 543 | variables[variable_match[2]] = json.loads(unquote(variable_match[3])) 544 | else: 545 | input.unread(orig_line) 546 | break 547 | 548 | if "lane_order_json" in variables: 549 | process_lane_order(env, variables["lane_order_json"]) 550 | if "state_json" in variables: 551 | process_state(env, state_id, action_name, variables["state_json"]) 552 | if "messages_json" in variables: 553 | process_messages(env, state_id, action_name, variables["messages_json"]) 554 | 555 | def process_data(input: UnreadableInput) -> Optional[Data]: 556 | env = Environment() 557 | 558 | state_id = None 559 | error: List[str] = [] 560 | error_handled = False 561 | for orig_line in input: 562 | line = orig_line.rstrip() 563 | state_id_match = state_id_re.match(line) 564 | if state_id_match: 565 | state_id = int(state_id_match[1]) 566 | action_name = state_id_match[2] 567 | 568 | read_variables(env, state_id, action_name, input) 569 | 570 | if state_id is None and not error_handled: 571 | if error == []: 572 | error_match = error_starts_re.match(line) 573 | if error_match: 574 | error.append(error_match[1]) 575 | else: 576 | error_match = error_starts_re.match(line) 577 | if error_match: 578 | error_handled = True 579 | else: 580 | error.append(line) 581 | 582 | if state_id is not None and \ 583 | error_starts_re.match(line) and \ 584 | not error_occurred_re.match(line): 585 | input.unread(orig_line) 586 | break 587 | 588 | if state_id is None: 589 | return None 590 | else: 591 | return Data(env=env, state_id=state_id, error=error) 592 | 593 | def draw_data(filename: str, data: Data) -> None: 594 | env = data.env 595 | height = STATE_HEIGHT * (data.state_id + 1) 596 | svg = draw.Drawing(STATE_ID_WIDTH + (LANE_WIDTH + LANE_GAP) * len(env.nodes), 597 | height + 20 + 100, 598 | origin=(0, -height), displayInline=False) 599 | svg.append(draw.Rectangle(0, -svg.height + (20 + 100), svg.width, svg.height, stroke='none', fill='white')) 600 | 601 | svg.append(draw.Text(data.error, 20, 602 | svg.width / 2.0, STATE_HEIGHT, 603 | text_anchor='middle', valign='middle')) 604 | 605 | for cur_state_id in range(1, data.state_id + 1): 606 | svg.append(draw.Rectangle(0, -(cur_state_id * STATE_HEIGHT), 607 | STATE_ID_WIDTH, STATE_HEIGHT, 608 | stroke='black', stroke_width='1', 609 | fill='none')) 610 | svg.append(draw.Text(f"State {cur_state_id}", 20, 611 | STATE_ID_WIDTH / 2, -((cur_state_id - 0.5) * STATE_HEIGHT), 612 | text_anchor='middle', valign='middle')) 613 | 614 | 615 | for source in env.nodes.values(): 616 | source.draw_states(svg) 617 | for source in env.nodes.values(): 618 | source.draw_transitions(svg) 619 | 620 | print(f"Saved {filename}") 621 | svg.saveSvg(filename) 622 | 623 | def main(): 624 | input = UnreadableInput(fileinput.input()) 625 | count = 0 626 | while True: 627 | results = process_data(input) 628 | more_data = input.has_unread() 629 | multiple = more_data or count 630 | if results is not None: 631 | filename = "sequence" + (f"-{(count+1):04}" if multiple else "") + ".svg" 632 | draw_data(filename, results) 633 | else: 634 | print("No applicable input?") 635 | if not more_data: 636 | break 637 | count += 1 638 | --------------------------------------------------------------------------------