exploration.main
- Authors: Peter Mawhorter
- Consulted:
- Date: 2022-10-15
- Purpose: Main API entry points to support the
__main__.py
script.
1""" 2- Authors: Peter Mawhorter 3- Consulted: 4- Date: 2022-10-15 5- Purpose: Main API entry points to support the `__main__.py` script. 6""" 7 8from __future__ import annotations 9 10import argparse 11import pathlib 12import textwrap 13import sys 14import csv 15import json 16import time 17 18# Resource module not available in Pyodide 19try: 20 import resource 21except Exception: 22 resource = None # type: ignore 23 24import networkx as nx # type: ignore 25 26from typing import ( 27 Literal, Optional, Union, get_args, TypeAlias, List, Callable, Dict, 28 Sequence, Any, cast, Tuple, Set, Collection 29) 30 31from . import journal 32from . import core 33from . import base 34from . import analysis 35from . import parsing 36 37 38#------------# 39# File input # 40#------------# 41 42SourceType: TypeAlias = Literal[ 43 "graph", 44 "dot", 45 "exploration", 46 "journal", 47] 48""" 49The file types we recognize. 50""" 51 52 53def determineFileType(filename: str) -> SourceType: 54 if filename.endswith('.dcg'): 55 return 'graph' 56 elif filename.endswith('.dot'): 57 return 'dot' 58 elif filename.endswith('.exp'): 59 return 'exploration' 60 elif filename.endswith('.exj'): 61 return 'journal' 62 else: 63 raise ValueError( 64 f"Could not determine the file type of file '{filename}':" 65 f" it does not end with '.dcg', '.dot', '.exp', or '.exj'." 66 ) 67 68 69def loadDecisionGraph(path: pathlib.Path) -> core.DecisionGraph: 70 """ 71 Loads a JSON-encoded decision graph from a file. The extension 72 should normally be '.dcg'. 73 """ 74 with path.open('r', encoding='utf-8-sig') as fInput: 75 return parsing.loadCustom(fInput, core.DecisionGraph) 76 77 78def saveDecisionGraph( 79 path: pathlib.Path, 80 graph: core.DecisionGraph 81) -> None: 82 """ 83 Saves a decision graph encoded as JSON in the specified file. The 84 file should normally have a '.dcg' extension. 85 """ 86 with path.open('w', encoding='utf-8') as fOutput: 87 parsing.saveCustom(graph, fOutput) 88 89 90def loadDotFile(path: pathlib.Path) -> core.DecisionGraph: 91 """ 92 Loads a `core.DecisionGraph` form the file at the specified path 93 (whose extension should normally be '.dot'). The file format is the 94 GraphViz "dot" format. 95 """ 96 with path.open('r', encoding='utf-8-sig') as fInput: 97 dot = fInput.read() 98 try: 99 return parsing.parseDot(dot) 100 except parsing.DotParseError: 101 raise parsing.DotParseError( 102 "Failed to parse Dot file contents:\n\n" 103 + dot 104 + "\n\n(See error above for specific parsing issue.)" 105 ) 106 107 108def saveDotFile(path: pathlib.Path, graph: core.DecisionGraph) -> None: 109 """ 110 Saves a `core.DecisionGraph` as a GraphViz "dot" file. The file 111 extension should normally be ".dot". 112 """ 113 dotStr = parsing.toDot(graph, clusterLevels=[]) 114 with path.open('w', encoding='utf-8') as fOutput: 115 fOutput.write(dotStr) 116 117 118def loadExploration(path: pathlib.Path) -> core.DiscreteExploration: 119 """ 120 Loads a JSON-encoded `core.DiscreteExploration` object from the file 121 at the specified path. The extension should normally be '.exp'. 122 """ 123 with path.open('r', encoding='utf-8-sig') as fInput: 124 return parsing.loadCustom(fInput, core.DiscreteExploration) 125 126 127def saveExploration( 128 path: pathlib.Path, 129 exploration: core.DiscreteExploration 130) -> None: 131 """ 132 Saves a `core.DiscreteExploration` object as JSON in the specified 133 file. The file extension should normally be '.exp'. 134 """ 135 with path.open('w', encoding='utf-8') as fOutput: 136 parsing.saveCustom(exploration, fOutput) 137 138 139def loadJournal(path: pathlib.Path) -> core.DiscreteExploration: 140 """ 141 Loads a `core.DiscreteExploration` object from a journal file 142 (extension should normally be '.exj'). Uses the 143 `journal.convertJournal` function. 144 """ 145 with path.open('r', encoding='utf-8-sig') as fInput: 146 return journal.convertJournal(fInput.read()) 147 148 149def saveAsJournal( 150 path: pathlib.Path, 151 exploration: core.DiscreteExploration 152) -> None: 153 """ 154 Saves a `core.DiscreteExploration` object as a text journal in the 155 specified file. The file extension should normally be '.exj'. 156 157 TODO: This?! 158 """ 159 raise NotImplementedError( 160 "DiscreteExploration-to-journal conversion is not implemented" 161 " yet." 162 ) 163 164 165def loadSource( 166 path: pathlib.Path, 167 formatOverride: Optional[SourceType] = None 168) -> Union[core.DecisionGraph, core.DiscreteExploration]: 169 """ 170 Loads either a `core.DecisionGraph` or a `core.DiscreteExploration` 171 from the specified file, depending on its file extension (or the 172 specified format given as `formatOverride` if there is one). 173 """ 174 if formatOverride is not None: 175 format = formatOverride 176 else: 177 format = determineFileType(str(path)) 178 179 if format == "graph": 180 return loadDecisionGraph(path) 181 if format == "dot": 182 return loadDotFile(path) 183 elif format == "exploration": 184 return loadExploration(path) 185 elif format == "journal": 186 return loadJournal(path) 187 else: 188 raise ValueError( 189 f"Unrecognized file format '{format}' (recognized formats" 190 f" are 'graph', 'exploration', and 'journal')." 191 ) 192 193 194#---------------------# 195# Analysis tool lists # 196#---------------------# 197 198CSVEmbeddable: TypeAlias = Union[None, bool, str, int, float, complex] 199""" 200A type alias for values we're willing to store in a CSV file without 201coercing them to a string. 202""" 203 204 205def coerceToCSVValue(result: Any) -> CSVEmbeddable: 206 """ 207 Coerces any value to one that's embeddable in a CSV file. The 208 `CSVEmbeddable` types are unchanged, but all other types are 209 converted to strings via `json.dumps` if possible or `repr` if not. 210 """ 211 if isinstance(result, get_args(CSVEmbeddable)): 212 return result 213 else: 214 try: 215 return json.dumps(result) 216 except Exception: 217 return repr(result) 218 219 220#---------------# 221# API Functions # 222#---------------# 223 224def show( 225 source: pathlib.Path, 226 formatOverride: Optional[SourceType] = None, 227 step: int = -1 228) -> None: 229 """ 230 Shows the graph or exploration stored in the `source` file. You will 231 need to have the `matplotlib` library installed. Consider using the 232 interactive interface provided by the `explorationViewer` module 233 instead. The file extension is used to determine how to load the data, 234 although the `--format` option may override this. '.dcg' files are 235 assumed to be decision graphs in JSON format, '.exp' files are assumed 236 to be exploration objects in JSON format, and '.exj' files are assumed 237 to be exploration journals in the default journal format. If the object 238 that gets loaded is an exploration, the final graph for that 239 exploration will be displayed, or a specific graph may be selected 240 using `--step`. 241 """ 242 obj = loadSource(source, formatOverride) 243 if isinstance(obj, core.DiscreteExploration): 244 obj = obj.getSituation(step).graph 245 246 import matplotlib.pyplot # type: ignore 247 248 # This draws the graph in a new window that pops up. You have to close 249 # the window to end the program. 250 nx.draw(obj) 251 matplotlib.pyplot.show() 252 253 254def transitionStr( 255 exploration: core.DiscreteExploration, 256 src: base.DecisionID, 257 transition: base.Transition, 258 dst: base.DecisionID 259) -> str: 260 """ 261 Given an exploration object, returns a string identifying a 262 transition, incorporating the final identity strings for the source 263 and destination. 264 """ 265 srcId = analysis.finalIdentity(exploration, src) 266 dstId = analysis.finalIdentity(exploration, dst) 267 return f"{srcId} → {transition} → {dstId}" 268 269 270def printPerf(analyzerName: str) -> None: 271 """ 272 Prints performance for the given analyzer to stderr. 273 """ 274 perf = analysis.ANALYSIS_TIME_SPENT.get(analyzerName) 275 if perf is None: 276 raise RuntimeError( 277 f"Missing analysis perf for {analyzerName!r}." 278 ) 279 unit = analysis.ALL_ANALYZERS[analyzerName]._unit 280 call, noC, tc, tw = perf.values() 281 print( 282 f"{analyzerName} ({unit}): {call} / {noC} / {tc:.6f} / {tw:.6f}", 283 file=sys.stderr 284 ) 285 286 287def printMem() -> None: 288 """ 289 Prints (to stderr) a message about how much memory Python is 290 currently using overall. 291 """ 292 if resource is not None: 293 used = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss 294 print(f"Using {used} memory (bytes or kilobytes, depending on OS)") 295 else: 296 print( 297 f"Can't get memory usage because the resource module is not" 298 f" available." 299 ) 300 # TODO: This is apparently kilobytes on linux but bytes on mac? 301 302 303def analyze( 304 source: pathlib.Path, 305 destination: Optional[pathlib.Path] = None, 306 formatOverride: Optional[SourceType] = None, 307 applyTools: Optional[Collection[str]] = None, 308 finalOnly: Optional[Collection[str]] = None, 309 includeAll: bool = False, 310 profile: bool = False 311) -> None: 312 """ 313 Analyzes the exploration stored in the `source` file. The file 314 extension is used to determine how to load the data, although this 315 may be overridden by the `--format` option. Normally, '.exp' files 316 are treated as JSON-encoded exploration objects, while '.exj' files 317 are treated as journals using the default journal format. 318 319 This applies a number of analysis functions to produce a CSV file 320 showing per-decision-per-step, per-decision, per-step, and 321 per-exploration metrics. A subset of the available metrics may be 322 selected by passing a list of strings for the `applyTools` argument. 323 These strings should be the names of functions in `analysis.py` that 324 are decorated with `analysis.analyze`. By default, only those not 325 marked with `analysis.elide` will be included. You can set 326 `includeAll` to `True` to include all tools, although this is ignored 327 when `applyTools` is not `None`. `finalOnly` specifies one or more 328 tools to only run on the final step of the exploration rather than 329 every step. This only applies to tools whose unit of analysis is 330 'step', 'stepDecision', or 'stepTransition'. By default those marked 331 as `finalOnly` in `analysis.py` will be run this way. Tools excluded 332 via `applyTools` or by default when `includeAll` is false won't be 333 run even if specified in `finalOnly`. Set `finalOnly` to `False` to 334 run all selected tools on all steps without having to explicitly 335 list the tools that would otherwise be restricted by default. 336 337 Set `profile` to `True` to gather and report analysis time spent 338 results (they'll be printed to stdout). 339 340 If no output file is specified, the output will be printed out. 341 """ 342 if profile: 343 print("Starting analysis with profiling...", file=sys.stderr) 344 parseStart = time.perf_counter() 345 printMem() 346 # Load our source exploration object: 347 obj = loadSource(source, formatOverride) 348 if isinstance(obj, core.DecisionGraph): 349 obj = core.DiscreteExploration.fromGraph(obj) 350 if profile: 351 elapsed = time.perf_counter() - parseStart 352 print(f"Parsed input in {elapsed:.6f}s...", file=sys.stderr) 353 printMem() 354 355 exploration: core.DiscreteExploration = obj 356 357 # Set up for profiling 358 if profile: 359 analysis.RECORD_PROFILE = True 360 else: 361 analysis.RECORD_PROFILE = False 362 363 # Figure out which to apply 364 if applyTools is not None: 365 toApply: Set[str] = set(applyTools) 366 else: 367 toApply = set(analysis.ALL_ANALYZERS.keys()) 368 if not includeAll: 369 print("ELIDING:", analysis.ELIDE, file=sys.stderr) 370 toApply -= analysis.ELIDE 371 372 if finalOnly is False: 373 finalOnly = set() 374 elif finalOnly is None: 375 finalOnly = analysis.FINAL_ONLY 376 377 # Group analyzers by unit 378 byUnit = analysis.analyzersByUnit(toApply) 379 380 # Apply all of the analysis functions (or only just those that are 381 # selected using applyTools): 382 383 wholeRows: List[List[CSVEmbeddable]] = [['Whole exploration metrics:']] 384 if profile: 385 print( 386 "name (unit): calls / non-cached / time (lookups) / time (work)", 387 file=sys.stderr 388 ) 389 # One row per analyzer 390 for ea in byUnit["exploration"]: 391 wholeRows.append([ea.__name__, coerceToCSVValue(ea(exploration))]) 392 if profile: 393 printPerf(ea.__name__) 394 395 # A few variables for holding pieces we'll assemble 396 row: List[CSVEmbeddable] 397 columns: List[CSVEmbeddable] 398 399 decisionRows: List[Sequence[CSVEmbeddable]] = [ 400 ['Per-decision metrics:'] 401 ] 402 # One row per tool; one column per decision 403 decisionList: List[base.DecisionID] = exploration.allDecisions() 404 columns = ( 405 cast(List[CSVEmbeddable], ['Metric ↓/Decision →']) 406 + cast(List[CSVEmbeddable], decisionList) 407 ) 408 409 decisionRows.append(columns) 410 for da in byUnit["decision"]: 411 row = [da.__name__] 412 decisionRows.append(row) 413 for decision in decisionList: 414 row.append(coerceToCSVValue(da(exploration, decision))) 415 if profile: 416 printPerf(da.__name__) 417 418 transitionRows: List[Sequence[CSVEmbeddable]] = [ 419 ['Per-transition metrics:'] 420 ] 421 # One row per tool; one column per decision 422 transitionList: List[ 423 Tuple[base.DecisionID, base.Transition, base.DecisionID] 424 ] = exploration.allTransitions() 425 transitionStrings: List[CSVEmbeddable] = [ 426 transitionStr(exploration, *trans) 427 for trans in transitionList 428 ] 429 columns = ( 430 cast(List[CSVEmbeddable], ['Metric ↓/Transition →']) 431 + transitionStrings 432 ) 433 transitionRows.append(columns) 434 for ta in byUnit["transition"]: 435 row = [ta.__name__] 436 transitionRows.append(row) 437 for transition in transitionList: 438 row.append( 439 coerceToCSVValue(ta(exploration, *transition)) 440 ) 441 if profile: 442 printPerf(ta.__name__) 443 444 stepRows: List[Sequence[CSVEmbeddable]] = [ 445 ['Per-step metrics:'] 446 ] 447 # One row per exploration step; one column per tool 448 columns = ['Step ↓/Metric →'] 449 stepRows.append(columns) 450 for step in range(len(exploration)): 451 row = [step] 452 stepRows.append(row) 453 for sa in byUnit["step"]: 454 if step == 0: 455 columns.append(sa.__name__) 456 if sa.__name__ in finalOnly and step != len(exploration) - 1: 457 row.append("") 458 else: 459 row.append(coerceToCSVValue(sa(exploration, step))) 460 461 # Print profile results just once after all steps have been analyzed 462 if profile: 463 for sa in byUnit["step"]: 464 printPerf(sa.__name__) 465 466 stepwiseRows: List[Sequence[CSVEmbeddable]] = [ 467 ['Per-decision-per-step metrics (one table per metric):'] 468 ] 469 470 # For each per-step decision tool; one row per exploration step and 471 # one column per decision 472 columns = ( 473 cast(List[CSVEmbeddable], ['Step ↓/Decision →']) 474 + cast(List[CSVEmbeddable], decisionList) 475 ) 476 identities = ['Decision names:'] + [ 477 analysis.finalIdentity(exploration, d) 478 for d in decisionList 479 ] 480 for sda in byUnit["stepDecision"]: 481 stepwiseRows.append([sda.__name__]) 482 stepwiseRows.append(columns) 483 stepwiseRows.append(identities) 484 if sda.__name__ in finalOnly: 485 step = len(exploration) - 1 486 row = [step] 487 stepwiseRows.append(row) 488 for decision in decisionList: 489 row.append(coerceToCSVValue(sda(exploration, step, decision))) 490 else: 491 for step in range(len(exploration)): 492 row = [step] 493 stepwiseRows.append(row) 494 for decision in decisionList: 495 row.append( 496 coerceToCSVValue(sda(exploration, step, decision)) 497 ) 498 if profile: 499 printPerf(sda.__name__) 500 501 stepwiseTransitionRows: List[Sequence[CSVEmbeddable]] = [ 502 ['Per-transition-per-step metrics (one table per metric):'] 503 ] 504 505 # For each per-step transition tool; one row per exploration step and 506 # one column per transition 507 columns = ( 508 cast(List[CSVEmbeddable], ['Step ↓/Transition →']) 509 + cast(List[CSVEmbeddable], transitionStrings) 510 ) 511 for sta in byUnit["stepTransition"]: 512 stepwiseTransitionRows.append([sta.__name__]) 513 stepwiseTransitionRows.append(columns) 514 if sta.__name__ in finalOnly: 515 step = len(exploration) - 1 516 row = [step] 517 stepwiseTransitionRows.append(row) 518 for (src, trans, dst) in transitionList: 519 row.append( 520 coerceToCSVValue(sta(exploration, step, src, trans, dst)) 521 ) 522 else: 523 for step in range(len(exploration)): 524 row = [step] 525 stepwiseTransitionRows.append(row) 526 for (src, trans, dst) in transitionList: 527 row.append( 528 coerceToCSVValue( 529 sta(exploration, step, src, trans, dst) 530 ) 531 ) 532 if profile: 533 printPerf(sta.__name__) 534 535 # Build a grid containing just the non-empty analysis categories, so 536 # that if you deselect some tools you get a smaller CSV file: 537 grid: List[Sequence[CSVEmbeddable]] = [] 538 if len(wholeRows) > 1: 539 grid.extend(wholeRows) 540 for block in ( 541 decisionRows, 542 transitionRows, 543 stepRows, 544 stepwiseRows, 545 stepwiseTransitionRows 546 ): 547 if len(block) > 1: 548 if grid: 549 grid.append([]) # spacer 550 grid.extend(block) 551 552 # Print all profile results at the end 553 if profile: 554 print("-"*80, file=sys.stderr) 555 print("Done with analysis. Time taken:", file=sys.stderr) 556 print("-"*80, file=sys.stderr) 557 for aname in analysis.ANALYSIS_TIME_SPENT: 558 printPerf(aname) 559 print("-"*80, file=sys.stderr) 560 printMem() 561 562 # Figure out our destination stream: 563 if destination is None: 564 outStream = sys.stdout 565 closeIt = False 566 else: 567 outStream = open(destination, 'w') 568 closeIt = True 569 570 # Create a CSV writer for our stream 571 writer = csv.writer(outStream) 572 573 # Write out our grid to the file 574 try: 575 writer.writerows(grid) 576 finally: 577 if closeIt: 578 outStream.close() 579 580 581def convert( 582 source: pathlib.Path, 583 destination: pathlib.Path, 584 inputFormatOverride: Optional[SourceType] = None, 585 outputFormatOverride: Optional[SourceType] = None, 586 step: int = -1 587) -> None: 588 """ 589 Converts between exploration and graph formats. By default, formats 590 are determined by file extensions, but using the `--format` and 591 `--output-format` options can override this. The available formats 592 are: 593 594 - '.dcg' A `core.DecisionGraph` stored in JSON format. 595 - '.dot' A `core.DecisionGraph` stored as a GraphViz DOT file. 596 - '.exp' A `core.DiscreteExploration` stored in JSON format. 597 - '.exj' A `core.DiscreteExploration` stored as a journal (see 598 `journal.JournalObserver`; TODO: writing this format). 599 600 When converting a decision graph into an exploration format, the 601 resulting exploration will have a single starting step containing 602 the entire specified graph. When converting an exploration into a 603 decision graph format, only the current graph will be saved, unless 604 `--step` is used to specify a different step index to save. 605 """ 606 # TODO journal writing 607 obj = loadSource(source, inputFormatOverride) 608 609 if outputFormatOverride is None: 610 outputFormat = determineFileType(str(destination)) 611 else: 612 outputFormat = outputFormatOverride 613 614 if outputFormat in ("graph", "dot"): 615 if isinstance(obj, core.DiscreteExploration): 616 graph = obj.getSituation(step).graph 617 else: 618 graph = obj 619 if outputFormat == "graph": 620 saveDecisionGraph(destination, graph) 621 else: 622 saveDotFile(destination, graph) 623 else: 624 if isinstance(obj, core.DecisionGraph): 625 exploration = core.DiscreteExploration.fromGraph(obj) 626 else: 627 exploration = obj 628 if outputFormat == "exploration": 629 saveExploration(destination, exploration) 630 else: 631 saveAsJournal(destination, exploration) 632 633 634INSPECTOR_HELP = """ 635Available commands: 636 637- 'help' or '?': List commands. 638- 'done', 'quit', 'q', or 'exit': Quit the inspector. 639- 'f' or 'follow': Follow the primary decision when changing steps. Also 640 changes to that decision immediately. Toggles off if on. 641- 'cd' or 'goto': Change focus decision to the named decision. Cancels 642 follow mode. 643- 'ls' or 'list' or 'destinations': Lists transitions at this decision 644 and their destinations, as well as any mechanisms at this decision. 645- 'lst' or 'steps': Lists each step of the exploration along with the 646 primary decision at each step. 647- 'st' or 'step': Switches to the specified step (an index) 648- 'n' or 'next': Switches to the next step. 649- 'p' or 'prev' or 'previous': Switches to the previous step. 650- 't' or 'take': Change focus decision to the decision which is the 651 destination of the specified transition at the current focused 652 decision. 653- 'prm' or 'primary': Displays the current primary decision. 654- 'a' or 'active': Lists all currently active decisions 655- 'u' or 'unexplored': Lists all unexplored transitions at the current 656 step. 657- 'x' or 'explorable': Lists all unexplored transitions at the current 658 step which are traversable based on the current state. (TODO: 659 make this more accurate). 660- 'r' or 'reachable': TODO 661- 'A' or 'all': Lists all decisions at the current step. 662- 'M' or 'mechanisms': Lists all mechanisms at the current step. 663""" 664 665 666def inspect( 667 source: pathlib.Path, 668 formatOverride: Optional[SourceType] = None 669) -> None: 670 """ 671 Inspects the graph or exploration stored in the `source` file, 672 launching an interactive command line for inspecting properties of 673 decisions, transitions, and situations. The file extension is used 674 to determine how to load the data, although the `--format` option 675 may override this. '.dcg' files are assumed to be decision graphs in 676 JSON format, '.exp' files are assumed to be exploration objects in 677 JSON format, and '.exj' files are assumed to be exploration journals 678 in the default journal format. If the object that gets loaded is a 679 graph, a 1-step exploration containing just that graph will be 680 created to inspect. Inspector commands are listed in the 681 `INSPECTOR_HELP` variable. 682 """ 683 print(f"Loading exploration from {source!r}...") 684 # Load our exploration 685 exploration = loadSource(source, formatOverride) 686 if isinstance(exploration, core.DecisionGraph): 687 exploration = core.DiscreteExploration.fromGraph(exploration) 688 689 print( 690 f"Inspecting exploration with {len(exploration)} step(s) and" 691 f" {len(exploration.allDecisions())} decision(s):" 692 ) 693 print("('h' for help)") 694 695 # Set up tracking variables: 696 step = len(exploration) - 1 697 here: Optional[base.DecisionID] = exploration.primaryDecision(step) 698 graph = exploration.getSituation(step).graph 699 follow = True 700 701 pf = parsing.ParseFormat() 702 703 if here is None: 704 print("Note: There are no decisions in the final graph.") 705 706 while True: 707 # Re-establish the prompt 708 prompt = "> " 709 if here is not None and here in graph: 710 prompt = graph.identityOf(here) + "> " 711 elif here is not None: 712 prompt = f"{here} (?)> " 713 714 # Prompt for the next command 715 fullCommand = input(prompt).split() 716 717 # Track number of invalid commands so we can quit after 10 in a row 718 invalidCommands = 0 719 720 if len(fullCommand) == 0: 721 cmd = '' 722 args = '' 723 else: 724 cmd = fullCommand[0] 725 args = ' '.join(fullCommand[1:]) 726 727 # Do what the command says 728 invalid = False 729 if cmd in ("help", '?'): 730 # Displays help message 731 if len(args.strip()) > 0: 732 print("(help does not accept any arguments)") 733 print(INSPECTOR_HELP) 734 elif cmd in ("done", "exit", "quit", "q"): 735 # Exits the inspector 736 if len(args.strip()) > 0: 737 print("(quit does not accept any arguments)") 738 print("Bye.") 739 break 740 elif cmd in ("f", "follow"): 741 if follow: 742 follow = False 743 print("Stopped following") 744 else: 745 follow = True 746 here = exploration.primaryDecision(step) 747 print(f"Now following at: {graph.identityOf(here)}") 748 elif cmd in ("cd", "goto"): 749 # Changes focus to a specific decision 750 try: 751 target = pf.parseDecisionSpecifier(args) 752 target = graph.resolveDecision(target) 753 here = target 754 follow = False 755 print(f"now at: {graph.identityOf(target)}") 756 except Exception: 757 print("(invalid decision specifier)") 758 elif cmd in ("ls", "list", "destinations"): 759 fromID: Optional[base.AnyDecisionSpecifier] = None 760 if args.strip(): 761 fromID = pf.parseDecisionSpecifier(args) 762 fromID = graph.resolveDecision(fromID) 763 else: 764 fromID = here 765 766 if fromID is None: 767 print( 768 "(no focus decision and no decision specified;" 769 " nothing to list; use 'cd' to specify a decision," 770 " or 'all' to list all decisions)" 771 ) 772 else: 773 outgoing = graph.destinationsFrom(fromID) 774 info = graph.identityOf(fromID) 775 if len(outgoing) > 0: 776 print(f"Destinations from {info}:") 777 print(graph.destinationsListing(outgoing)) 778 else: 779 print("No outgoing transitions from {info}.") 780 elif cmd in ("lst", "steps"): 781 total = len(exploration) 782 print(f"{total} step(s):") 783 for step in range(total): 784 pr = exploration.primaryDecision(step) 785 situ = exploration.getSituation(step) 786 stGraph = situ.graph 787 identity = stGraph.identityOf(pr) 788 print(f" {step} at {identity}") 789 print(f"({total} total step(s))") 790 elif cmd in ("st", "step"): 791 stepTo = int(args.strip()) 792 if stepTo < 0: 793 stepTo += len(exploration) 794 if stepTo < 0: 795 print( 796 f"Invalid step {args!r} (too negative; min is" 797 f" {-len(exploration)})" 798 ) 799 if stepTo >= len(exploration): 800 print( 801 f"Invalid step {args!r} (too large; max is" 802 f" {len(exploration) - 1})" 803 ) 804 805 step = stepTo 806 graph = exploration.getSituation(step).graph 807 if follow: 808 here = exploration.primaryDecision(step) 809 print(f"Followed to: {graph.identityOf(here)}") 810 elif cmd in ("n", "next"): 811 if step == -1 or step >= len(exploration) - 2: 812 print("Can't step beyond the last step.") 813 else: 814 step += 1 815 graph = exploration.getSituation(step).graph 816 if here not in graph: 817 here = None 818 print(f"At step {step}") 819 if follow: 820 here = exploration.primaryDecision(step) 821 print(f"Followed to: {graph.identityOf(here)}") 822 elif cmd in ("p", "prev"): 823 if step == 0 or step <= -len(exploration) + 2: 824 print("Can't step before the first step.") 825 else: 826 step -= 1 827 graph = exploration.getSituation(step).graph 828 if here not in graph: 829 here = None 830 print(f"At step {step}") 831 if follow: 832 here = exploration.primaryDecision(step) 833 print(f"Followed to: {graph.identityOf(here)}") 834 elif cmd in ("t", "take"): 835 if here is None: 836 print( 837 "(no focus decision, so can't take transitions. Use" 838 " 'cd' to specify a decision first.)" 839 ) 840 else: 841 dest = graph.getDestination(here, args) 842 if dest is None: 843 print( 844 f"Invalid transition {args!r} (no destination for" 845 f" that transition from {graph.identityOf(here)}" 846 ) 847 here = dest 848 elif cmd in ("prm", "primary"): 849 pr = exploration.primaryDecision(step) 850 if pr is None: 851 print(f"Step {step} has no primary decision") 852 else: 853 print( 854 f"Primary decision for step {step} is:" 855 f" {graph.identityOf(pr)}" 856 ) 857 elif cmd in ("a", "active"): 858 active = exploration.getActiveDecisions(step) 859 print(f"Active decisions at step {step}:") 860 print(graph.namesListing(active)) 861 elif cmd in ("u", "unexplored"): 862 unx = analysis.unexploredBranches(graph) 863 fin = ':' if len(unx) > 0 else '.' 864 print(f"{len(unx)} unexplored branch(es){fin}") 865 for frID, unTr in unx: 866 print(f"take {unTr} at {graph.identityOf(frID)}") 867 elif cmd in ("x", "explorable"): 868 ctx = base.genericContextForSituation( 869 exploration.getSituation(step) 870 ) 871 unx = analysis.unexploredBranches(graph, ctx) 872 fin = ':' if len(unx) > 0 else '.' 873 print(f"{len(unx)} unexplored branch(es){fin}") 874 for frID, unTr in unx: 875 print(f"take {unTr} at {graph.identityOf(frID)}") 876 elif cmd in ("r", "reachable"): 877 print("TODO: Reachable does not work yet.") 878 elif cmd in ("A", "all"): 879 print( 880 f"There are {len(graph)} decision(s) at step {step}:" 881 ) 882 for decision in graph.nodes(): 883 print(f" {graph.identityOf(decision)}") 884 elif cmd in ("M", "mechanisms"): 885 count = len(graph.mechanisms) 886 fin = ':' if count > 0 else '.' 887 print( 888 f"There are {count} mechanism(s) at step {step}{fin}" 889 ) 890 for mID in graph.mechanisms: 891 where, name = graph.mechanisms[mID] 892 state = exploration.mechanismState(mID, step=step) 893 if where is None: 894 print(f" {name!r} (global) in state {state!r}") 895 else: 896 info = graph.identityOf(where) 897 print(f" {name!r} at {info} in state {state!r}") 898 else: 899 invalid = True 900 901 if invalid: 902 if invalidCommands >= 10: 903 print("Too many invalid commands; exiting.") 904 break 905 else: 906 if invalidCommands >= 8: 907 print("{invalidCommands} invalid commands so far,") 908 print("inspector will stop after 10 invalid commands...") 909 print(f"Unknown command {cmd!r}...") 910 invalidCommands += 1 911 print(INSPECTOR_HELP) 912 else: 913 invalidCommands = 0 914 915 916#--------------# 917# Parser setup # 918#--------------# 919 920parser = argparse.ArgumentParser( 921 prog="python -m exploration", 922 description="""\ 923Runs various commands for processing exploration graphs and journals, 924and for converting between them or displaying them in various formats. 925""" 926) 927subparsers = parser.add_subparsers( 928 title="commands", 929 description="The available commands are:", 930 help="use these with -h/--help for more details" 931) 932 933showParser = subparsers.add_parser( 934 'show', 935 help="show an exploration", 936 description=textwrap.dedent(str(show.__doc__)).strip() 937) 938showParser.set_defaults(run="show") 939showParser.add_argument( 940 "source", 941 type=pathlib.Path, 942 help="The file to load" 943) 944showParser.add_argument( 945 '-f', 946 "--format", 947 choices=get_args(SourceType), 948 help=( 949 "Which format the source file is in (normally that can be" 950 " determined from the file extension)." 951 ) 952) 953showParser.add_argument( 954 '-s', 955 "--step", 956 type=int, 957 default=-1, 958 help="Which graph step to show (when loading an exploration)." 959) 960 961analyzeParser = subparsers.add_parser( 962 'analyze', 963 help="analyze an exploration", 964 description=textwrap.dedent(str(analyze.__doc__)).strip() 965) 966analyzeParser.set_defaults(run="analyze") 967analyzeParser.add_argument( 968 "source", 969 type=pathlib.Path, 970 help="The file holding the exploration to analyze" 971) 972analyzeParser.add_argument( 973 "destination", 974 default=None, 975 type=pathlib.Path, 976 help=( 977 "The file name where the output should be written (this file" 978 " will be overwritten without warning)." 979 ) 980) 981analyzeParser.add_argument( 982 '-f', 983 "--format", 984 choices=get_args(SourceType), 985 help=( 986 "Which format the source file is in (normally that can be" 987 " determined from the file extension)." 988 ) 989) 990analyzeParser.add_argument( 991 '-a', 992 "--all", 993 action='store_true', 994 help=( 995 "Whether to include all results or just the default ones. Some" 996 " of the extended results may cause issues with loading the CSV" 997 " file in common programs like Excel." 998 ) 999) 1000analyzeParser.add_argument( 1001 '-p', 1002 "--profile", 1003 action='store_true', 1004 help="Set this to profile time taken by analysis functions." 1005) 1006 1007convertParser = subparsers.add_parser( 1008 'convert', 1009 help="convert an exploration", 1010 description=textwrap.dedent(str(convert.__doc__)).strip() 1011) 1012convertParser.set_defaults(run="convert") 1013convertParser.add_argument( 1014 "source", 1015 type=pathlib.Path, 1016 help="The file holding the graph or exploration to convert." 1017) 1018convertParser.add_argument( 1019 "destination", 1020 type=pathlib.Path, 1021 help=( 1022 "The file name where the output should be written (this file" 1023 " will be overwritten without warning)." 1024 ) 1025) 1026convertParser.add_argument( 1027 '-f', 1028 "--format", 1029 choices=get_args(SourceType), 1030 help=( 1031 "Which format the source file is in (normally that can be" 1032 " determined from the file extension)." 1033 ) 1034) 1035convertParser.add_argument( 1036 '-o', 1037 "--output-format", 1038 choices=get_args(SourceType), 1039 help=( 1040 "Which format the converted file should be saved as (normally" 1041 " that is determined from the file extension)." 1042 ) 1043) 1044convertParser.add_argument( 1045 '-s', 1046 "--step", 1047 type=int, 1048 default=-1, 1049 help=( 1050 "Which graph step to save (when converting from an exploration" 1051 " format to a graph format)." 1052 ) 1053) 1054 1055inspectParser = subparsers.add_parser( 1056 'inspect', 1057 help="interactively inspect an exploration", 1058 description=textwrap.dedent(str(inspect.__doc__)).strip() 1059) 1060inspectParser.set_defaults(run="inspect") 1061inspectParser.add_argument( 1062 "source", 1063 type=pathlib.Path, 1064 help="The file holding the graph or exploration to inspect." 1065) 1066inspectParser.add_argument( 1067 '-f', 1068 "--format", 1069 choices=get_args(SourceType), 1070 help=( 1071 "Which format the source file is in (normally that can be" 1072 " determined from the file extension)." 1073 ) 1074) 1075 1076def main(): 1077 """ 1078 Parse options from command line & run appropriate tool. 1079 """ 1080 options = parser.parse_args() 1081 if not hasattr(options, "run"): 1082 print("No sub-command specified.") 1083 parser.print_help() 1084 exit(1) 1085 elif options.run == "show": 1086 show( 1087 options.source, 1088 formatOverride=options.format, 1089 step=options.step 1090 ) 1091 elif options.run == "analyze": 1092 analyze( 1093 options.source, 1094 destination=options.destination, 1095 formatOverride=options.format, 1096 includeAll=options.all, 1097 profile=options.profile 1098 ) 1099 elif options.run == "convert": 1100 convert( 1101 options.source, 1102 options.destination, 1103 inputFormatOverride=options.format, 1104 outputFormatOverride=options.output_format, 1105 step=options.step 1106 ) 1107 elif options.run == "inspect": 1108 inspect( 1109 options.source, 1110 formatOverride=options.format 1111 ) 1112 else: 1113 raise RuntimeError( 1114 f"Invalid 'run' default value: '{options.run}'." 1115 ) 1116 1117 1118if __name__ == "__main__": 1119 main()
The file types we recognize.
54def determineFileType(filename: str) -> SourceType: 55 if filename.endswith('.dcg'): 56 return 'graph' 57 elif filename.endswith('.dot'): 58 return 'dot' 59 elif filename.endswith('.exp'): 60 return 'exploration' 61 elif filename.endswith('.exj'): 62 return 'journal' 63 else: 64 raise ValueError( 65 f"Could not determine the file type of file '{filename}':" 66 f" it does not end with '.dcg', '.dot', '.exp', or '.exj'." 67 )
70def loadDecisionGraph(path: pathlib.Path) -> core.DecisionGraph: 71 """ 72 Loads a JSON-encoded decision graph from a file. The extension 73 should normally be '.dcg'. 74 """ 75 with path.open('r', encoding='utf-8-sig') as fInput: 76 return parsing.loadCustom(fInput, core.DecisionGraph)
Loads a JSON-encoded decision graph from a file. The extension should normally be '.dcg'.
79def saveDecisionGraph( 80 path: pathlib.Path, 81 graph: core.DecisionGraph 82) -> None: 83 """ 84 Saves a decision graph encoded as JSON in the specified file. The 85 file should normally have a '.dcg' extension. 86 """ 87 with path.open('w', encoding='utf-8') as fOutput: 88 parsing.saveCustom(graph, fOutput)
Saves a decision graph encoded as JSON in the specified file. The file should normally have a '.dcg' extension.
91def loadDotFile(path: pathlib.Path) -> core.DecisionGraph: 92 """ 93 Loads a `core.DecisionGraph` form the file at the specified path 94 (whose extension should normally be '.dot'). The file format is the 95 GraphViz "dot" format. 96 """ 97 with path.open('r', encoding='utf-8-sig') as fInput: 98 dot = fInput.read() 99 try: 100 return parsing.parseDot(dot) 101 except parsing.DotParseError: 102 raise parsing.DotParseError( 103 "Failed to parse Dot file contents:\n\n" 104 + dot 105 + "\n\n(See error above for specific parsing issue.)" 106 )
Loads a core.DecisionGraph
form the file at the specified path
(whose extension should normally be '.dot'). The file format is the
GraphViz "dot" format.
109def saveDotFile(path: pathlib.Path, graph: core.DecisionGraph) -> None: 110 """ 111 Saves a `core.DecisionGraph` as a GraphViz "dot" file. The file 112 extension should normally be ".dot". 113 """ 114 dotStr = parsing.toDot(graph, clusterLevels=[]) 115 with path.open('w', encoding='utf-8') as fOutput: 116 fOutput.write(dotStr)
Saves a core.DecisionGraph
as a GraphViz "dot" file. The file
extension should normally be ".dot".
119def loadExploration(path: pathlib.Path) -> core.DiscreteExploration: 120 """ 121 Loads a JSON-encoded `core.DiscreteExploration` object from the file 122 at the specified path. The extension should normally be '.exp'. 123 """ 124 with path.open('r', encoding='utf-8-sig') as fInput: 125 return parsing.loadCustom(fInput, core.DiscreteExploration)
Loads a JSON-encoded core.DiscreteExploration
object from the file
at the specified path. The extension should normally be '.exp'.
128def saveExploration( 129 path: pathlib.Path, 130 exploration: core.DiscreteExploration 131) -> None: 132 """ 133 Saves a `core.DiscreteExploration` object as JSON in the specified 134 file. The file extension should normally be '.exp'. 135 """ 136 with path.open('w', encoding='utf-8') as fOutput: 137 parsing.saveCustom(exploration, fOutput)
Saves a core.DiscreteExploration
object as JSON in the specified
file. The file extension should normally be '.exp'.
140def loadJournal(path: pathlib.Path) -> core.DiscreteExploration: 141 """ 142 Loads a `core.DiscreteExploration` object from a journal file 143 (extension should normally be '.exj'). Uses the 144 `journal.convertJournal` function. 145 """ 146 with path.open('r', encoding='utf-8-sig') as fInput: 147 return journal.convertJournal(fInput.read())
Loads a core.DiscreteExploration
object from a journal file
(extension should normally be '.exj'). Uses the
journal.convertJournal
function.
150def saveAsJournal( 151 path: pathlib.Path, 152 exploration: core.DiscreteExploration 153) -> None: 154 """ 155 Saves a `core.DiscreteExploration` object as a text journal in the 156 specified file. The file extension should normally be '.exj'. 157 158 TODO: This?! 159 """ 160 raise NotImplementedError( 161 "DiscreteExploration-to-journal conversion is not implemented" 162 " yet." 163 )
Saves a core.DiscreteExploration
object as a text journal in the
specified file. The file extension should normally be '.exj'.
TODO: This?!
166def loadSource( 167 path: pathlib.Path, 168 formatOverride: Optional[SourceType] = None 169) -> Union[core.DecisionGraph, core.DiscreteExploration]: 170 """ 171 Loads either a `core.DecisionGraph` or a `core.DiscreteExploration` 172 from the specified file, depending on its file extension (or the 173 specified format given as `formatOverride` if there is one). 174 """ 175 if formatOverride is not None: 176 format = formatOverride 177 else: 178 format = determineFileType(str(path)) 179 180 if format == "graph": 181 return loadDecisionGraph(path) 182 if format == "dot": 183 return loadDotFile(path) 184 elif format == "exploration": 185 return loadExploration(path) 186 elif format == "journal": 187 return loadJournal(path) 188 else: 189 raise ValueError( 190 f"Unrecognized file format '{format}' (recognized formats" 191 f" are 'graph', 'exploration', and 'journal')." 192 )
Loads either a core.DecisionGraph
or a core.DiscreteExploration
from the specified file, depending on its file extension (or the
specified format given as formatOverride
if there is one).
A type alias for values we're willing to store in a CSV file without coercing them to a string.
206def coerceToCSVValue(result: Any) -> CSVEmbeddable: 207 """ 208 Coerces any value to one that's embeddable in a CSV file. The 209 `CSVEmbeddable` types are unchanged, but all other types are 210 converted to strings via `json.dumps` if possible or `repr` if not. 211 """ 212 if isinstance(result, get_args(CSVEmbeddable)): 213 return result 214 else: 215 try: 216 return json.dumps(result) 217 except Exception: 218 return repr(result)
Coerces any value to one that's embeddable in a CSV file. The
CSVEmbeddable
types are unchanged, but all other types are
converted to strings via json.dumps
if possible or repr
if not.
225def show( 226 source: pathlib.Path, 227 formatOverride: Optional[SourceType] = None, 228 step: int = -1 229) -> None: 230 """ 231 Shows the graph or exploration stored in the `source` file. You will 232 need to have the `matplotlib` library installed. Consider using the 233 interactive interface provided by the `explorationViewer` module 234 instead. The file extension is used to determine how to load the data, 235 although the `--format` option may override this. '.dcg' files are 236 assumed to be decision graphs in JSON format, '.exp' files are assumed 237 to be exploration objects in JSON format, and '.exj' files are assumed 238 to be exploration journals in the default journal format. If the object 239 that gets loaded is an exploration, the final graph for that 240 exploration will be displayed, or a specific graph may be selected 241 using `--step`. 242 """ 243 obj = loadSource(source, formatOverride) 244 if isinstance(obj, core.DiscreteExploration): 245 obj = obj.getSituation(step).graph 246 247 import matplotlib.pyplot # type: ignore 248 249 # This draws the graph in a new window that pops up. You have to close 250 # the window to end the program. 251 nx.draw(obj) 252 matplotlib.pyplot.show()
Shows the graph or exploration stored in the source
file. You will
need to have the matplotlib
library installed. Consider using the
interactive interface provided by the explorationViewer
module
instead. The file extension is used to determine how to load the data,
although the --format
option may override this. '.dcg' files are
assumed to be decision graphs in JSON format, '.exp' files are assumed
to be exploration objects in JSON format, and '.exj' files are assumed
to be exploration journals in the default journal format. If the object
that gets loaded is an exploration, the final graph for that
exploration will be displayed, or a specific graph may be selected
using --step
.
255def transitionStr( 256 exploration: core.DiscreteExploration, 257 src: base.DecisionID, 258 transition: base.Transition, 259 dst: base.DecisionID 260) -> str: 261 """ 262 Given an exploration object, returns a string identifying a 263 transition, incorporating the final identity strings for the source 264 and destination. 265 """ 266 srcId = analysis.finalIdentity(exploration, src) 267 dstId = analysis.finalIdentity(exploration, dst) 268 return f"{srcId} → {transition} → {dstId}"
Given an exploration object, returns a string identifying a transition, incorporating the final identity strings for the source and destination.
271def printPerf(analyzerName: str) -> None: 272 """ 273 Prints performance for the given analyzer to stderr. 274 """ 275 perf = analysis.ANALYSIS_TIME_SPENT.get(analyzerName) 276 if perf is None: 277 raise RuntimeError( 278 f"Missing analysis perf for {analyzerName!r}." 279 ) 280 unit = analysis.ALL_ANALYZERS[analyzerName]._unit 281 call, noC, tc, tw = perf.values() 282 print( 283 f"{analyzerName} ({unit}): {call} / {noC} / {tc:.6f} / {tw:.6f}", 284 file=sys.stderr 285 )
Prints performance for the given analyzer to stderr.
288def printMem() -> None: 289 """ 290 Prints (to stderr) a message about how much memory Python is 291 currently using overall. 292 """ 293 if resource is not None: 294 used = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss 295 print(f"Using {used} memory (bytes or kilobytes, depending on OS)") 296 else: 297 print( 298 f"Can't get memory usage because the resource module is not" 299 f" available." 300 ) 301 # TODO: This is apparently kilobytes on linux but bytes on mac?
Prints (to stderr) a message about how much memory Python is currently using overall.
304def analyze( 305 source: pathlib.Path, 306 destination: Optional[pathlib.Path] = None, 307 formatOverride: Optional[SourceType] = None, 308 applyTools: Optional[Collection[str]] = None, 309 finalOnly: Optional[Collection[str]] = None, 310 includeAll: bool = False, 311 profile: bool = False 312) -> None: 313 """ 314 Analyzes the exploration stored in the `source` file. The file 315 extension is used to determine how to load the data, although this 316 may be overridden by the `--format` option. Normally, '.exp' files 317 are treated as JSON-encoded exploration objects, while '.exj' files 318 are treated as journals using the default journal format. 319 320 This applies a number of analysis functions to produce a CSV file 321 showing per-decision-per-step, per-decision, per-step, and 322 per-exploration metrics. A subset of the available metrics may be 323 selected by passing a list of strings for the `applyTools` argument. 324 These strings should be the names of functions in `analysis.py` that 325 are decorated with `analysis.analyze`. By default, only those not 326 marked with `analysis.elide` will be included. You can set 327 `includeAll` to `True` to include all tools, although this is ignored 328 when `applyTools` is not `None`. `finalOnly` specifies one or more 329 tools to only run on the final step of the exploration rather than 330 every step. This only applies to tools whose unit of analysis is 331 'step', 'stepDecision', or 'stepTransition'. By default those marked 332 as `finalOnly` in `analysis.py` will be run this way. Tools excluded 333 via `applyTools` or by default when `includeAll` is false won't be 334 run even if specified in `finalOnly`. Set `finalOnly` to `False` to 335 run all selected tools on all steps without having to explicitly 336 list the tools that would otherwise be restricted by default. 337 338 Set `profile` to `True` to gather and report analysis time spent 339 results (they'll be printed to stdout). 340 341 If no output file is specified, the output will be printed out. 342 """ 343 if profile: 344 print("Starting analysis with profiling...", file=sys.stderr) 345 parseStart = time.perf_counter() 346 printMem() 347 # Load our source exploration object: 348 obj = loadSource(source, formatOverride) 349 if isinstance(obj, core.DecisionGraph): 350 obj = core.DiscreteExploration.fromGraph(obj) 351 if profile: 352 elapsed = time.perf_counter() - parseStart 353 print(f"Parsed input in {elapsed:.6f}s...", file=sys.stderr) 354 printMem() 355 356 exploration: core.DiscreteExploration = obj 357 358 # Set up for profiling 359 if profile: 360 analysis.RECORD_PROFILE = True 361 else: 362 analysis.RECORD_PROFILE = False 363 364 # Figure out which to apply 365 if applyTools is not None: 366 toApply: Set[str] = set(applyTools) 367 else: 368 toApply = set(analysis.ALL_ANALYZERS.keys()) 369 if not includeAll: 370 print("ELIDING:", analysis.ELIDE, file=sys.stderr) 371 toApply -= analysis.ELIDE 372 373 if finalOnly is False: 374 finalOnly = set() 375 elif finalOnly is None: 376 finalOnly = analysis.FINAL_ONLY 377 378 # Group analyzers by unit 379 byUnit = analysis.analyzersByUnit(toApply) 380 381 # Apply all of the analysis functions (or only just those that are 382 # selected using applyTools): 383 384 wholeRows: List[List[CSVEmbeddable]] = [['Whole exploration metrics:']] 385 if profile: 386 print( 387 "name (unit): calls / non-cached / time (lookups) / time (work)", 388 file=sys.stderr 389 ) 390 # One row per analyzer 391 for ea in byUnit["exploration"]: 392 wholeRows.append([ea.__name__, coerceToCSVValue(ea(exploration))]) 393 if profile: 394 printPerf(ea.__name__) 395 396 # A few variables for holding pieces we'll assemble 397 row: List[CSVEmbeddable] 398 columns: List[CSVEmbeddable] 399 400 decisionRows: List[Sequence[CSVEmbeddable]] = [ 401 ['Per-decision metrics:'] 402 ] 403 # One row per tool; one column per decision 404 decisionList: List[base.DecisionID] = exploration.allDecisions() 405 columns = ( 406 cast(List[CSVEmbeddable], ['Metric ↓/Decision →']) 407 + cast(List[CSVEmbeddable], decisionList) 408 ) 409 410 decisionRows.append(columns) 411 for da in byUnit["decision"]: 412 row = [da.__name__] 413 decisionRows.append(row) 414 for decision in decisionList: 415 row.append(coerceToCSVValue(da(exploration, decision))) 416 if profile: 417 printPerf(da.__name__) 418 419 transitionRows: List[Sequence[CSVEmbeddable]] = [ 420 ['Per-transition metrics:'] 421 ] 422 # One row per tool; one column per decision 423 transitionList: List[ 424 Tuple[base.DecisionID, base.Transition, base.DecisionID] 425 ] = exploration.allTransitions() 426 transitionStrings: List[CSVEmbeddable] = [ 427 transitionStr(exploration, *trans) 428 for trans in transitionList 429 ] 430 columns = ( 431 cast(List[CSVEmbeddable], ['Metric ↓/Transition →']) 432 + transitionStrings 433 ) 434 transitionRows.append(columns) 435 for ta in byUnit["transition"]: 436 row = [ta.__name__] 437 transitionRows.append(row) 438 for transition in transitionList: 439 row.append( 440 coerceToCSVValue(ta(exploration, *transition)) 441 ) 442 if profile: 443 printPerf(ta.__name__) 444 445 stepRows: List[Sequence[CSVEmbeddable]] = [ 446 ['Per-step metrics:'] 447 ] 448 # One row per exploration step; one column per tool 449 columns = ['Step ↓/Metric →'] 450 stepRows.append(columns) 451 for step in range(len(exploration)): 452 row = [step] 453 stepRows.append(row) 454 for sa in byUnit["step"]: 455 if step == 0: 456 columns.append(sa.__name__) 457 if sa.__name__ in finalOnly and step != len(exploration) - 1: 458 row.append("") 459 else: 460 row.append(coerceToCSVValue(sa(exploration, step))) 461 462 # Print profile results just once after all steps have been analyzed 463 if profile: 464 for sa in byUnit["step"]: 465 printPerf(sa.__name__) 466 467 stepwiseRows: List[Sequence[CSVEmbeddable]] = [ 468 ['Per-decision-per-step metrics (one table per metric):'] 469 ] 470 471 # For each per-step decision tool; one row per exploration step and 472 # one column per decision 473 columns = ( 474 cast(List[CSVEmbeddable], ['Step ↓/Decision →']) 475 + cast(List[CSVEmbeddable], decisionList) 476 ) 477 identities = ['Decision names:'] + [ 478 analysis.finalIdentity(exploration, d) 479 for d in decisionList 480 ] 481 for sda in byUnit["stepDecision"]: 482 stepwiseRows.append([sda.__name__]) 483 stepwiseRows.append(columns) 484 stepwiseRows.append(identities) 485 if sda.__name__ in finalOnly: 486 step = len(exploration) - 1 487 row = [step] 488 stepwiseRows.append(row) 489 for decision in decisionList: 490 row.append(coerceToCSVValue(sda(exploration, step, decision))) 491 else: 492 for step in range(len(exploration)): 493 row = [step] 494 stepwiseRows.append(row) 495 for decision in decisionList: 496 row.append( 497 coerceToCSVValue(sda(exploration, step, decision)) 498 ) 499 if profile: 500 printPerf(sda.__name__) 501 502 stepwiseTransitionRows: List[Sequence[CSVEmbeddable]] = [ 503 ['Per-transition-per-step metrics (one table per metric):'] 504 ] 505 506 # For each per-step transition tool; one row per exploration step and 507 # one column per transition 508 columns = ( 509 cast(List[CSVEmbeddable], ['Step ↓/Transition →']) 510 + cast(List[CSVEmbeddable], transitionStrings) 511 ) 512 for sta in byUnit["stepTransition"]: 513 stepwiseTransitionRows.append([sta.__name__]) 514 stepwiseTransitionRows.append(columns) 515 if sta.__name__ in finalOnly: 516 step = len(exploration) - 1 517 row = [step] 518 stepwiseTransitionRows.append(row) 519 for (src, trans, dst) in transitionList: 520 row.append( 521 coerceToCSVValue(sta(exploration, step, src, trans, dst)) 522 ) 523 else: 524 for step in range(len(exploration)): 525 row = [step] 526 stepwiseTransitionRows.append(row) 527 for (src, trans, dst) in transitionList: 528 row.append( 529 coerceToCSVValue( 530 sta(exploration, step, src, trans, dst) 531 ) 532 ) 533 if profile: 534 printPerf(sta.__name__) 535 536 # Build a grid containing just the non-empty analysis categories, so 537 # that if you deselect some tools you get a smaller CSV file: 538 grid: List[Sequence[CSVEmbeddable]] = [] 539 if len(wholeRows) > 1: 540 grid.extend(wholeRows) 541 for block in ( 542 decisionRows, 543 transitionRows, 544 stepRows, 545 stepwiseRows, 546 stepwiseTransitionRows 547 ): 548 if len(block) > 1: 549 if grid: 550 grid.append([]) # spacer 551 grid.extend(block) 552 553 # Print all profile results at the end 554 if profile: 555 print("-"*80, file=sys.stderr) 556 print("Done with analysis. Time taken:", file=sys.stderr) 557 print("-"*80, file=sys.stderr) 558 for aname in analysis.ANALYSIS_TIME_SPENT: 559 printPerf(aname) 560 print("-"*80, file=sys.stderr) 561 printMem() 562 563 # Figure out our destination stream: 564 if destination is None: 565 outStream = sys.stdout 566 closeIt = False 567 else: 568 outStream = open(destination, 'w') 569 closeIt = True 570 571 # Create a CSV writer for our stream 572 writer = csv.writer(outStream) 573 574 # Write out our grid to the file 575 try: 576 writer.writerows(grid) 577 finally: 578 if closeIt: 579 outStream.close()
Analyzes the exploration stored in the source
file. The file
extension is used to determine how to load the data, although this
may be overridden by the --format
option. Normally, '.exp' files
are treated as JSON-encoded exploration objects, while '.exj' files
are treated as journals using the default journal format.
This applies a number of analysis functions to produce a CSV file
showing per-decision-per-step, per-decision, per-step, and
per-exploration metrics. A subset of the available metrics may be
selected by passing a list of strings for the applyTools
argument.
These strings should be the names of functions in analysis.py
that
are decorated with analysis.analyze
. By default, only those not
marked with analysis.elide
will be included. You can set
includeAll
to True
to include all tools, although this is ignored
when applyTools
is not None
. finalOnly
specifies one or more
tools to only run on the final step of the exploration rather than
every step. This only applies to tools whose unit of analysis is
'step', 'stepDecision', or 'stepTransition'. By default those marked
as finalOnly
in analysis.py
will be run this way. Tools excluded
via applyTools
or by default when includeAll
is false won't be
run even if specified in finalOnly
. Set finalOnly
to False
to
run all selected tools on all steps without having to explicitly
list the tools that would otherwise be restricted by default.
Set profile
to True
to gather and report analysis time spent
results (they'll be printed to stdout).
If no output file is specified, the output will be printed out.
582def convert( 583 source: pathlib.Path, 584 destination: pathlib.Path, 585 inputFormatOverride: Optional[SourceType] = None, 586 outputFormatOverride: Optional[SourceType] = None, 587 step: int = -1 588) -> None: 589 """ 590 Converts between exploration and graph formats. By default, formats 591 are determined by file extensions, but using the `--format` and 592 `--output-format` options can override this. The available formats 593 are: 594 595 - '.dcg' A `core.DecisionGraph` stored in JSON format. 596 - '.dot' A `core.DecisionGraph` stored as a GraphViz DOT file. 597 - '.exp' A `core.DiscreteExploration` stored in JSON format. 598 - '.exj' A `core.DiscreteExploration` stored as a journal (see 599 `journal.JournalObserver`; TODO: writing this format). 600 601 When converting a decision graph into an exploration format, the 602 resulting exploration will have a single starting step containing 603 the entire specified graph. When converting an exploration into a 604 decision graph format, only the current graph will be saved, unless 605 `--step` is used to specify a different step index to save. 606 """ 607 # TODO journal writing 608 obj = loadSource(source, inputFormatOverride) 609 610 if outputFormatOverride is None: 611 outputFormat = determineFileType(str(destination)) 612 else: 613 outputFormat = outputFormatOverride 614 615 if outputFormat in ("graph", "dot"): 616 if isinstance(obj, core.DiscreteExploration): 617 graph = obj.getSituation(step).graph 618 else: 619 graph = obj 620 if outputFormat == "graph": 621 saveDecisionGraph(destination, graph) 622 else: 623 saveDotFile(destination, graph) 624 else: 625 if isinstance(obj, core.DecisionGraph): 626 exploration = core.DiscreteExploration.fromGraph(obj) 627 else: 628 exploration = obj 629 if outputFormat == "exploration": 630 saveExploration(destination, exploration) 631 else: 632 saveAsJournal(destination, exploration)
Converts between exploration and graph formats. By default, formats
are determined by file extensions, but using the --format
and
--output-format
options can override this. The available formats
are:
- '.dcg' A
core.DecisionGraph
stored in JSON format. - '.dot' A
core.DecisionGraph
stored as a GraphViz DOT file. - '.exp' A
core.DiscreteExploration
stored in JSON format. - '.exj' A
core.DiscreteExploration
stored as a journal (seejournal.JournalObserver
; TODO: writing this format).
When converting a decision graph into an exploration format, the
resulting exploration will have a single starting step containing
the entire specified graph. When converting an exploration into a
decision graph format, only the current graph will be saved, unless
--step
is used to specify a different step index to save.
667def inspect( 668 source: pathlib.Path, 669 formatOverride: Optional[SourceType] = None 670) -> None: 671 """ 672 Inspects the graph or exploration stored in the `source` file, 673 launching an interactive command line for inspecting properties of 674 decisions, transitions, and situations. The file extension is used 675 to determine how to load the data, although the `--format` option 676 may override this. '.dcg' files are assumed to be decision graphs in 677 JSON format, '.exp' files are assumed to be exploration objects in 678 JSON format, and '.exj' files are assumed to be exploration journals 679 in the default journal format. If the object that gets loaded is a 680 graph, a 1-step exploration containing just that graph will be 681 created to inspect. Inspector commands are listed in the 682 `INSPECTOR_HELP` variable. 683 """ 684 print(f"Loading exploration from {source!r}...") 685 # Load our exploration 686 exploration = loadSource(source, formatOverride) 687 if isinstance(exploration, core.DecisionGraph): 688 exploration = core.DiscreteExploration.fromGraph(exploration) 689 690 print( 691 f"Inspecting exploration with {len(exploration)} step(s) and" 692 f" {len(exploration.allDecisions())} decision(s):" 693 ) 694 print("('h' for help)") 695 696 # Set up tracking variables: 697 step = len(exploration) - 1 698 here: Optional[base.DecisionID] = exploration.primaryDecision(step) 699 graph = exploration.getSituation(step).graph 700 follow = True 701 702 pf = parsing.ParseFormat() 703 704 if here is None: 705 print("Note: There are no decisions in the final graph.") 706 707 while True: 708 # Re-establish the prompt 709 prompt = "> " 710 if here is not None and here in graph: 711 prompt = graph.identityOf(here) + "> " 712 elif here is not None: 713 prompt = f"{here} (?)> " 714 715 # Prompt for the next command 716 fullCommand = input(prompt).split() 717 718 # Track number of invalid commands so we can quit after 10 in a row 719 invalidCommands = 0 720 721 if len(fullCommand) == 0: 722 cmd = '' 723 args = '' 724 else: 725 cmd = fullCommand[0] 726 args = ' '.join(fullCommand[1:]) 727 728 # Do what the command says 729 invalid = False 730 if cmd in ("help", '?'): 731 # Displays help message 732 if len(args.strip()) > 0: 733 print("(help does not accept any arguments)") 734 print(INSPECTOR_HELP) 735 elif cmd in ("done", "exit", "quit", "q"): 736 # Exits the inspector 737 if len(args.strip()) > 0: 738 print("(quit does not accept any arguments)") 739 print("Bye.") 740 break 741 elif cmd in ("f", "follow"): 742 if follow: 743 follow = False 744 print("Stopped following") 745 else: 746 follow = True 747 here = exploration.primaryDecision(step) 748 print(f"Now following at: {graph.identityOf(here)}") 749 elif cmd in ("cd", "goto"): 750 # Changes focus to a specific decision 751 try: 752 target = pf.parseDecisionSpecifier(args) 753 target = graph.resolveDecision(target) 754 here = target 755 follow = False 756 print(f"now at: {graph.identityOf(target)}") 757 except Exception: 758 print("(invalid decision specifier)") 759 elif cmd in ("ls", "list", "destinations"): 760 fromID: Optional[base.AnyDecisionSpecifier] = None 761 if args.strip(): 762 fromID = pf.parseDecisionSpecifier(args) 763 fromID = graph.resolveDecision(fromID) 764 else: 765 fromID = here 766 767 if fromID is None: 768 print( 769 "(no focus decision and no decision specified;" 770 " nothing to list; use 'cd' to specify a decision," 771 " or 'all' to list all decisions)" 772 ) 773 else: 774 outgoing = graph.destinationsFrom(fromID) 775 info = graph.identityOf(fromID) 776 if len(outgoing) > 0: 777 print(f"Destinations from {info}:") 778 print(graph.destinationsListing(outgoing)) 779 else: 780 print("No outgoing transitions from {info}.") 781 elif cmd in ("lst", "steps"): 782 total = len(exploration) 783 print(f"{total} step(s):") 784 for step in range(total): 785 pr = exploration.primaryDecision(step) 786 situ = exploration.getSituation(step) 787 stGraph = situ.graph 788 identity = stGraph.identityOf(pr) 789 print(f" {step} at {identity}") 790 print(f"({total} total step(s))") 791 elif cmd in ("st", "step"): 792 stepTo = int(args.strip()) 793 if stepTo < 0: 794 stepTo += len(exploration) 795 if stepTo < 0: 796 print( 797 f"Invalid step {args!r} (too negative; min is" 798 f" {-len(exploration)})" 799 ) 800 if stepTo >= len(exploration): 801 print( 802 f"Invalid step {args!r} (too large; max is" 803 f" {len(exploration) - 1})" 804 ) 805 806 step = stepTo 807 graph = exploration.getSituation(step).graph 808 if follow: 809 here = exploration.primaryDecision(step) 810 print(f"Followed to: {graph.identityOf(here)}") 811 elif cmd in ("n", "next"): 812 if step == -1 or step >= len(exploration) - 2: 813 print("Can't step beyond the last step.") 814 else: 815 step += 1 816 graph = exploration.getSituation(step).graph 817 if here not in graph: 818 here = None 819 print(f"At step {step}") 820 if follow: 821 here = exploration.primaryDecision(step) 822 print(f"Followed to: {graph.identityOf(here)}") 823 elif cmd in ("p", "prev"): 824 if step == 0 or step <= -len(exploration) + 2: 825 print("Can't step before the first step.") 826 else: 827 step -= 1 828 graph = exploration.getSituation(step).graph 829 if here not in graph: 830 here = None 831 print(f"At step {step}") 832 if follow: 833 here = exploration.primaryDecision(step) 834 print(f"Followed to: {graph.identityOf(here)}") 835 elif cmd in ("t", "take"): 836 if here is None: 837 print( 838 "(no focus decision, so can't take transitions. Use" 839 " 'cd' to specify a decision first.)" 840 ) 841 else: 842 dest = graph.getDestination(here, args) 843 if dest is None: 844 print( 845 f"Invalid transition {args!r} (no destination for" 846 f" that transition from {graph.identityOf(here)}" 847 ) 848 here = dest 849 elif cmd in ("prm", "primary"): 850 pr = exploration.primaryDecision(step) 851 if pr is None: 852 print(f"Step {step} has no primary decision") 853 else: 854 print( 855 f"Primary decision for step {step} is:" 856 f" {graph.identityOf(pr)}" 857 ) 858 elif cmd in ("a", "active"): 859 active = exploration.getActiveDecisions(step) 860 print(f"Active decisions at step {step}:") 861 print(graph.namesListing(active)) 862 elif cmd in ("u", "unexplored"): 863 unx = analysis.unexploredBranches(graph) 864 fin = ':' if len(unx) > 0 else '.' 865 print(f"{len(unx)} unexplored branch(es){fin}") 866 for frID, unTr in unx: 867 print(f"take {unTr} at {graph.identityOf(frID)}") 868 elif cmd in ("x", "explorable"): 869 ctx = base.genericContextForSituation( 870 exploration.getSituation(step) 871 ) 872 unx = analysis.unexploredBranches(graph, ctx) 873 fin = ':' if len(unx) > 0 else '.' 874 print(f"{len(unx)} unexplored branch(es){fin}") 875 for frID, unTr in unx: 876 print(f"take {unTr} at {graph.identityOf(frID)}") 877 elif cmd in ("r", "reachable"): 878 print("TODO: Reachable does not work yet.") 879 elif cmd in ("A", "all"): 880 print( 881 f"There are {len(graph)} decision(s) at step {step}:" 882 ) 883 for decision in graph.nodes(): 884 print(f" {graph.identityOf(decision)}") 885 elif cmd in ("M", "mechanisms"): 886 count = len(graph.mechanisms) 887 fin = ':' if count > 0 else '.' 888 print( 889 f"There are {count} mechanism(s) at step {step}{fin}" 890 ) 891 for mID in graph.mechanisms: 892 where, name = graph.mechanisms[mID] 893 state = exploration.mechanismState(mID, step=step) 894 if where is None: 895 print(f" {name!r} (global) in state {state!r}") 896 else: 897 info = graph.identityOf(where) 898 print(f" {name!r} at {info} in state {state!r}") 899 else: 900 invalid = True 901 902 if invalid: 903 if invalidCommands >= 10: 904 print("Too many invalid commands; exiting.") 905 break 906 else: 907 if invalidCommands >= 8: 908 print("{invalidCommands} invalid commands so far,") 909 print("inspector will stop after 10 invalid commands...") 910 print(f"Unknown command {cmd!r}...") 911 invalidCommands += 1 912 print(INSPECTOR_HELP) 913 else: 914 invalidCommands = 0
Inspects the graph or exploration stored in the source
file,
launching an interactive command line for inspecting properties of
decisions, transitions, and situations. The file extension is used
to determine how to load the data, although the --format
option
may override this. '.dcg' files are assumed to be decision graphs in
JSON format, '.exp' files are assumed to be exploration objects in
JSON format, and '.exj' files are assumed to be exploration journals
in the default journal format. If the object that gets loaded is a
graph, a 1-step exploration containing just that graph will be
created to inspect. Inspector commands are listed in the
INSPECTOR_HELP
variable.
1077def main(): 1078 """ 1079 Parse options from command line & run appropriate tool. 1080 """ 1081 options = parser.parse_args() 1082 if not hasattr(options, "run"): 1083 print("No sub-command specified.") 1084 parser.print_help() 1085 exit(1) 1086 elif options.run == "show": 1087 show( 1088 options.source, 1089 formatOverride=options.format, 1090 step=options.step 1091 ) 1092 elif options.run == "analyze": 1093 analyze( 1094 options.source, 1095 destination=options.destination, 1096 formatOverride=options.format, 1097 includeAll=options.all, 1098 profile=options.profile 1099 ) 1100 elif options.run == "convert": 1101 convert( 1102 options.source, 1103 options.destination, 1104 inputFormatOverride=options.format, 1105 outputFormatOverride=options.output_format, 1106 step=options.step 1107 ) 1108 elif options.run == "inspect": 1109 inspect( 1110 options.source, 1111 formatOverride=options.format 1112 ) 1113 else: 1114 raise RuntimeError( 1115 f"Invalid 'run' default value: '{options.run}'." 1116 )
Parse options from command line & run appropriate tool.