"""Read complete Token paths from RoboCrunch graph files. Requires Python 3.10+ and robocrunch.py in the same directory. Paths follow Token edges from begin to end. Each Token is visited at most once within a path, so cycles terminate. Distinct Token paths are retained. """ from __future__ import annotations import argparse from dataclasses import dataclass import sys from robocrunch import Graph, NodeAddress @dataclass(frozen=True) class TokenPath: """One immutable Token path, with its corresponding Label sequence.""" tokens: tuple[NodeAddress, ...] labels: tuple[str, ...] def to_text(self, separator: str = " -> ") -> str: """Return the Label sequence using the requested separator.""" return separator.join(self.labels) def get_full_paths(graph: Graph, max_paths: int = 10000) -> list[TokenPath]: """Return all simple begin-to-end Token paths in deterministic order. Label nodes belong to the empty class. Every Token must have exactly one outgoing Label edge. Exceeding max_paths raises ValueError rather than returning a partial list. The supplied graph is never modified. """ if isinstance(max_paths, bool) or not isinstance(max_paths, int) or max_paths < 1: raise ValueError("max_paths must be a positive integer.") tokens = sorted([node for node in graph.nodes if node.class_name == "token"], key=lambda node: node.id) labels: dict[NodeAddress, str] = {} successors: dict[NodeAddress, list[NodeAddress]] = {} for token in tokens: label_ids = [target.id for target in graph.nodes[token] if target.class_name == ""] if len(label_ids) != 1: raise ValueError(f"Token {token.to_text()} must point to exactly one Label node.") labels[token] = label_ids[0] successors[token] = sorted([target for target in graph.nodes[token] if target.class_name == "token"], key=lambda node: node.id) results: list[TokenPath] = [] for start in tokens: if labels[start] != "begin": continue path = [start] active = {start} frames = [iter(successors[start])] while frames: if labels[path[-1]] == "end": if len(results) >= max_paths: raise ValueError(f"More than {max_paths} full paths found; increase max_paths.") results.append(TokenPath(tokens=tuple(path), labels=tuple(labels[node] for node in path))) frames.pop() active.remove(path.pop()) continue following = next(frames[-1], None) if following is None: frames.pop() active.remove(path.pop()) elif following not in active: path.append(following) active.add(following) frames.append(iter(successors[following])) return results def main(argv: list[str] | None = None) -> int: """Display complete Token paths from an existing graph file.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("path", nargs="?", default="OutputGraph.txt", help="UTF-8 graph file.") parser.add_argument("--max-paths", type=int, default=10000, help="Maximum number of paths.") args = parser.parse_args(args=argv) try: graph = Graph.read_file(path=args.path) paths = get_full_paths(graph=graph, max_paths=args.max_paths) print(f"Graph: {graph.node_count} nodes, {graph.edge_count} edges.") print(f"Full Token paths: {len(paths)}") for number, token_path in enumerate(paths, start=1): print(f"{number}. {token_path.to_text()}") print(" Token IDs: " + ", ".join(node.id for node in token_path.tokens)) return 0 except (ValueError, OSError) as error: print(f"Error: {error}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())