exploration.main
- Authors: Peter Mawhorter
- Consulted:
- Date: 2022-10-15
- Purpose: Main API entry points to support the
__main__.pyscript.
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( 140 path: pathlib.Path, 141 interactive: bool = False 142) -> core.DiscreteExploration: 143 """ 144 Loads a `core.DiscreteExploration` object from a journal file 145 (extension should normally be '.exj'). Uses the 146 `journal.convertJournal` function. 147 148 Passes `interactive` through to that function. 149 """ 150 with path.open('r', encoding='utf-8-sig') as fInput: 151 return journal.convertJournal( 152 fInput.read(), 153 filename=str(path), 154 interactive=interactive 155 ) 156 157 158def saveAsJournal( 159 path: pathlib.Path, 160 exploration: core.DiscreteExploration 161) -> None: 162 """ 163 Saves a `core.DiscreteExploration` object as a text journal in the 164 specified file. The file extension should normally be '.exj'. 165 166 TODO: This?! 167 """ 168 raise NotImplementedError( 169 "DiscreteExploration-to-journal conversion is not implemented" 170 " yet." 171 ) 172 173 174def loadSource( 175 path: pathlib.Path, 176 formatOverride: Optional[SourceType] = None, 177 interactive: bool = False 178) -> Union[core.DecisionGraph, core.DiscreteExploration]: 179 """ 180 Loads either a `core.DecisionGraph` or a `core.DiscreteExploration` 181 from the specified file, depending on its file extension (or the 182 specified format given as `formatOverride` if there is one). 183 184 `interactive` only affects parsing of journal files, and is passed 185 through to `journal.convertJournal`. 186 """ 187 if formatOverride is not None: 188 format = formatOverride 189 else: 190 format = determineFileType(str(path)) 191 192 if format == "graph": 193 return loadDecisionGraph(path) 194 if format == "dot": 195 return loadDotFile(path) 196 elif format == "exploration": 197 return loadExploration(path) 198 elif format == "journal": 199 return loadJournal(path, interactive) 200 else: 201 raise ValueError( 202 f"Unrecognized file format '{format}' (recognized formats" 203 f" are 'graph', 'exploration', and 'journal')." 204 ) 205 206 207#---------------------# 208# Analysis tool lists # 209#---------------------# 210 211CSVEmbeddable: TypeAlias = Union[None, bool, str, int, float, complex] 212""" 213A type alias for values we're willing to store in a CSV file without 214coercing them to a string. 215""" 216 217 218def coerceToCSVValue(result: Any) -> CSVEmbeddable: 219 """ 220 Coerces any value to one that's embeddable in a CSV file. The 221 `CSVEmbeddable` types are unchanged, but all other types are 222 converted to strings via `json.dumps` if possible or `repr` if not. 223 """ 224 if isinstance(result, get_args(CSVEmbeddable)): 225 return result 226 else: 227 try: 228 return json.dumps(result) 229 except Exception: 230 return repr(result) 231 232 233#---------------# 234# API Functions # 235#---------------# 236 237def check( 238 source: pathlib.Path, 239 formatOverride: Optional[SourceType] = None, 240 interactive: bool = False 241) -> None: 242 """ 243 Parses a journal, exploration, or graph file and reports on any 244 warnings or errors, then exits. The file extension is used to 245 determine how to load the data, although the `--format` option may 246 override this. '.dcg' files are assumed to be decision graphs in 247 JSON format, '.exp' files are assumed to be exploration objects in 248 JSON format, and '.exj' files are assumed to be exploration journals 249 in the default journal format. 250 251 Interactive debugging can be enabled by setting `interactive` to 252 `True` (default is `False`). 253 """ 254 # Loading should display errors/warnings by default 255 loadSource(source, formatOverride, interactive=interactive) 256 257 # TODO: Check for each-step mechanism ambiguity 258 259 260def show( 261 source: pathlib.Path, 262 formatOverride: Optional[SourceType] = None, 263 step: int = -1 264) -> None: 265 """ 266 Shows the graph or exploration stored in the `source` file. You will 267 need to have the `matplotlib` library installed. Consider using the 268 interactive interface provided by the `explorationViewer` module 269 instead. The file extension is used to determine how to load the data, 270 although the `--format` option may override this. '.dcg' files are 271 assumed to be decision graphs in JSON format, '.exp' files are assumed 272 to be exploration objects in JSON format, and '.exj' files are assumed 273 to be exploration journals in the default journal format. If the object 274 that gets loaded is an exploration, the final graph for that 275 exploration will be displayed, or a specific graph may be selected 276 using `--step`. 277 """ 278 obj = loadSource(source, formatOverride) 279 if isinstance(obj, core.DiscreteExploration): 280 obj = obj.getSituation(step).graph 281 282 import matplotlib.pyplot # type: ignore 283 284 # This draws the graph in a new window that pops up. You have to close 285 # the window to end the program. 286 nx.draw(obj) 287 matplotlib.pyplot.show() 288 289 290def transitionStr( 291 exploration: core.DiscreteExploration, 292 src: base.DecisionID, 293 transition: base.Transition, 294 dst: base.DecisionID 295) -> str: 296 """ 297 Given an exploration object, returns a string identifying a 298 transition, incorporating the final identity strings for the source 299 and destination. 300 """ 301 srcId = analysis.finalIdentity(exploration, src) 302 dstId = analysis.finalIdentity(exploration, dst) 303 return f"{srcId} → {transition} → {dstId}" 304 305 306def printPerf(analyzerName: str) -> None: 307 """ 308 Prints performance for the given analyzer to stderr. 309 """ 310 perf = analysis.ANALYSIS_TIME_SPENT.get(analyzerName) 311 if perf is None: 312 raise RuntimeError( 313 f"Missing analysis perf for {analyzerName!r}." 314 ) 315 unit = analysis.ALL_ANALYZERS[analyzerName]._unit 316 call, noC, tc, tw = perf.values() 317 print( 318 f"{analyzerName} ({unit}): {call} / {noC} / {tc:.6f} / {tw:.6f}", 319 file=sys.stderr 320 ) 321 322 323def printMem() -> None: 324 """ 325 Prints (to stderr) a message about how much memory Python is 326 currently using overall. 327 """ 328 os = sys.platform 329 units = 1 330 if os.startswith('linux') or os.startswith('android'): 331 units = 1000 332 if resource is not None: 333 used = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss * units 334 suffix = "B" 335 if used > 1000000000: 336 usage = f"{used/1000000000:.2f} GB" 337 elif used > 1000000: 338 usage = f"{used/1000000:.2f} MB" 339 elif used > 1000: 340 usage = f"{used/1000:.2f} KB" 341 else: 342 usage = f"{used} B" 343 print(f"Using {usage} memory") 344 else: 345 print( 346 f"Can't get memory usage because the resource module is not" 347 f" available." 348 ) 349 350 351def analyze( 352 source: pathlib.Path, 353 destination: Optional[pathlib.Path] = None, 354 formatOverride: Optional[SourceType] = None, 355 applyTools: Optional[Collection[str]] = None, 356 finalOnly: Optional[Collection[str]] = None, 357 includeAll: bool = False, 358 profile: bool = False 359) -> None: 360 """ 361 Analyzes the exploration stored in the `source` file. The file 362 extension is used to determine how to load the data, although this 363 may be overridden by the `--format` option. Normally, '.exp' files 364 are treated as JSON-encoded exploration objects, while '.exj' files 365 are treated as journals using the default journal format. 366 367 This applies a number of analysis functions to produce a CSV file 368 showing per-decision-per-step, per-decision, per-step, and 369 per-exploration metrics. A subset of the available metrics may be 370 selected by passing a list of strings for the `applyTools` argument. 371 These strings should be the names of functions in `analysis.py` that 372 are decorated with `analysis.analyze`. By default, only those not 373 marked with `analysis.elide` will be included. You can set 374 `includeAll` to `True` to include all tools, although this is ignored 375 when `applyTools` is not `None`. `finalOnly` specifies one or more 376 tools to only run on the final step of the exploration rather than 377 every step. This only applies to tools whose unit of analysis is 378 'step', 'stepDecision', or 'stepTransition'. By default those marked 379 as `finalOnly` in `analysis.py` will be run this way. Tools excluded 380 via `applyTools` or by default when `includeAll` is false won't be 381 run even if specified in `finalOnly`. Set `finalOnly` to `False` to 382 run all selected tools on all steps without having to explicitly 383 list the tools that would otherwise be restricted by default. 384 385 Set `profile` to `True` to gather and report analysis time spent 386 results (they'll be printed to stdout). 387 388 If no output file is specified, the output will be printed out. 389 """ 390 if profile: 391 print("Starting analysis with profiling...", file=sys.stderr) 392 parseStart = time.perf_counter() 393 printMem() 394 # Load our source exploration object: 395 obj = loadSource(source, formatOverride) 396 if isinstance(obj, core.DecisionGraph): 397 obj = core.DiscreteExploration.fromGraph(obj) 398 if profile: 399 elapsed = time.perf_counter() - parseStart 400 print(f"Parsed input in {elapsed:.6f}s...", file=sys.stderr) 401 printMem() 402 403 exploration: core.DiscreteExploration = obj 404 405 # Set up for profiling 406 if profile: 407 analysis.RECORD_PROFILE = True 408 else: 409 analysis.RECORD_PROFILE = False 410 411 # Figure out which to apply 412 if applyTools is not None: 413 toApply: Set[str] = set(applyTools) 414 else: 415 toApply = set(analysis.ALL_ANALYZERS.keys()) 416 if not includeAll: 417 print("ELIDING:", analysis.ELIDE, file=sys.stderr) 418 toApply -= analysis.ELIDE 419 420 if finalOnly is False: 421 finalOnly = set() 422 elif finalOnly is None: 423 finalOnly = analysis.FINAL_ONLY 424 425 # Group analyzers by unit 426 byUnit = analysis.analyzersByUnit(toApply) 427 428 # Apply all of the analysis functions (or only just those that are 429 # selected using applyTools): 430 431 wholeRows: List[List[CSVEmbeddable]] = [['Whole exploration metrics:']] 432 if profile: 433 print( 434 "name (unit): calls / non-cached / time (lookups) / time (work)", 435 file=sys.stderr 436 ) 437 # One row per analyzer 438 for ea in byUnit["exploration"]: 439 wholeRows.append([ea.__name__, coerceToCSVValue(ea(exploration))]) 440 if profile: 441 printPerf(ea.__name__) 442 443 # A few variables for holding pieces we'll assemble 444 row: List[CSVEmbeddable] 445 columns: List[CSVEmbeddable] 446 447 decisionRows: List[Sequence[CSVEmbeddable]] = [ 448 ['Per-decision metrics:'] 449 ] 450 # One row per tool; one column per decision 451 decisionList: List[base.DecisionID] = exploration.allDecisions() 452 columns = ( 453 cast(List[CSVEmbeddable], ['Metric ↓/Decision →']) 454 + cast(List[CSVEmbeddable], decisionList) 455 ) 456 457 decisionRows.append(columns) 458 for da in byUnit["decision"]: 459 row = [da.__name__] 460 decisionRows.append(row) 461 for decision in decisionList: 462 row.append(coerceToCSVValue(da(exploration, decision))) 463 if profile: 464 printPerf(da.__name__) 465 466 transitionRows: List[Sequence[CSVEmbeddable]] = [ 467 ['Per-transition metrics:'] 468 ] 469 # One row per tool; one column per decision 470 transitionList: List[ 471 Tuple[base.DecisionID, base.Transition, base.DecisionID] 472 ] = exploration.allTransitions() 473 transitionStrings: List[CSVEmbeddable] = [ 474 transitionStr(exploration, *trans) 475 for trans in transitionList 476 ] 477 columns = ( 478 cast(List[CSVEmbeddable], ['Metric ↓/Transition →']) 479 + transitionStrings 480 ) 481 transitionRows.append(columns) 482 for ta in byUnit["transition"]: 483 row = [ta.__name__] 484 transitionRows.append(row) 485 for transition in transitionList: 486 row.append( 487 coerceToCSVValue(ta(exploration, *transition)) 488 ) 489 if profile: 490 printPerf(ta.__name__) 491 492 stepRows: List[Sequence[CSVEmbeddable]] = [ 493 ['Per-step metrics:'] 494 ] 495 # One row per exploration step; one column per tool 496 columns = ['Step ↓/Metric →'] 497 stepRows.append(columns) 498 for step in range(len(exploration)): 499 row = [step] 500 stepRows.append(row) 501 for sa in byUnit["step"]: 502 if step == 0: 503 columns.append(sa.__name__) 504 if sa.__name__ in finalOnly and step != len(exploration) - 1: 505 row.append("") 506 else: 507 row.append(coerceToCSVValue(sa(exploration, step))) 508 509 # Print profile results just once after all steps have been analyzed 510 if profile: 511 for sa in byUnit["step"]: 512 printPerf(sa.__name__) 513 514 stepwiseRows: List[Sequence[CSVEmbeddable]] = [ 515 ['Per-decision-per-step metrics (one table per metric):'] 516 ] 517 518 # For each per-step decision tool; one row per exploration step and 519 # one column per decision 520 columns = ( 521 cast(List[CSVEmbeddable], ['Step ↓/Decision →']) 522 + cast(List[CSVEmbeddable], decisionList) 523 ) 524 identities = ['Decision names:'] + [ 525 analysis.finalIdentity(exploration, d) 526 for d in decisionList 527 ] 528 for sda in byUnit["stepDecision"]: 529 stepwiseRows.append([sda.__name__]) 530 stepwiseRows.append(columns) 531 stepwiseRows.append(identities) 532 if sda.__name__ in finalOnly: 533 step = len(exploration) - 1 534 row = [step] 535 stepwiseRows.append(row) 536 for decision in decisionList: 537 row.append(coerceToCSVValue(sda(exploration, step, decision))) 538 else: 539 for step in range(len(exploration)): 540 row = [step] 541 stepwiseRows.append(row) 542 for decision in decisionList: 543 row.append( 544 coerceToCSVValue(sda(exploration, step, decision)) 545 ) 546 if profile: 547 printPerf(sda.__name__) 548 549 stepwiseTransitionRows: List[Sequence[CSVEmbeddable]] = [ 550 ['Per-transition-per-step metrics (one table per metric):'] 551 ] 552 553 # For each per-step transition tool; one row per exploration step and 554 # one column per transition 555 columns = ( 556 cast(List[CSVEmbeddable], ['Step ↓/Transition →']) 557 + cast(List[CSVEmbeddable], transitionStrings) 558 ) 559 for sta in byUnit["stepTransition"]: 560 stepwiseTransitionRows.append([sta.__name__]) 561 stepwiseTransitionRows.append(columns) 562 if sta.__name__ in finalOnly: 563 step = len(exploration) - 1 564 row = [step] 565 stepwiseTransitionRows.append(row) 566 for (src, trans, dst) in transitionList: 567 row.append( 568 coerceToCSVValue(sta(exploration, step, src, trans, dst)) 569 ) 570 else: 571 for step in range(len(exploration)): 572 row = [step] 573 stepwiseTransitionRows.append(row) 574 for (src, trans, dst) in transitionList: 575 row.append( 576 coerceToCSVValue( 577 sta(exploration, step, src, trans, dst) 578 ) 579 ) 580 if profile: 581 printPerf(sta.__name__) 582 583 # Build a grid containing just the non-empty analysis categories, so 584 # that if you deselect some tools you get a smaller CSV file: 585 grid: List[Sequence[CSVEmbeddable]] = [] 586 if len(wholeRows) > 1: 587 grid.extend(wholeRows) 588 for block in ( 589 decisionRows, 590 transitionRows, 591 stepRows, 592 stepwiseRows, 593 stepwiseTransitionRows 594 ): 595 if len(block) > 1: 596 if grid: 597 grid.append([]) # spacer 598 grid.extend(block) 599 600 # Print all profile results at the end 601 if profile: 602 print("-"*80, file=sys.stderr) 603 print("Done with analysis. Time taken:", file=sys.stderr) 604 print("-"*80, file=sys.stderr) 605 for aname in analysis.ANALYSIS_TIME_SPENT: 606 printPerf(aname) 607 print("-"*80, file=sys.stderr) 608 printMem() 609 610 # Figure out our destination stream: 611 if destination is None: 612 outStream = sys.stdout 613 closeIt = False 614 else: 615 outStream = open(destination, 'w') 616 closeIt = True 617 618 # Create a CSV writer for our stream 619 writer = csv.writer(outStream) 620 621 # Write out our grid to the file 622 try: 623 writer.writerows(grid) 624 finally: 625 if closeIt: 626 outStream.close() 627 628 629def convert( 630 source: pathlib.Path, 631 destination: pathlib.Path, 632 inputFormatOverride: Optional[SourceType] = None, 633 outputFormatOverride: Optional[SourceType] = None, 634 step: int = -1 635) -> None: 636 """ 637 Converts between exploration and graph formats. By default, formats 638 are determined by file extensions, but using the `--format` and 639 `--output-format` options can override this. The available formats 640 are: 641 642 - '.dcg' A `core.DecisionGraph` stored in JSON format. 643 - '.dot' A `core.DecisionGraph` stored as a GraphViz DOT file. 644 - '.exp' A `core.DiscreteExploration` stored in JSON format. 645 - '.exj' A `core.DiscreteExploration` stored as a journal (see 646 `journal.JournalObserver`; TODO: writing this format). 647 648 When converting a decision graph into an exploration format, the 649 resulting exploration will have a single starting step containing 650 the entire specified graph. When converting an exploration into a 651 decision graph format, only the current graph will be saved, unless 652 `--step` is used to specify a different step index to save. 653 """ 654 # TODO journal writing 655 obj = loadSource(source, inputFormatOverride) 656 657 if outputFormatOverride is None: 658 outputFormat = determineFileType(str(destination)) 659 else: 660 outputFormat = outputFormatOverride 661 662 if outputFormat in ("graph", "dot"): 663 if isinstance(obj, core.DiscreteExploration): 664 graph = obj.getSituation(step).graph 665 else: 666 graph = obj 667 if outputFormat == "graph": 668 saveDecisionGraph(destination, graph) 669 else: 670 saveDotFile(destination, graph) 671 else: 672 if isinstance(obj, core.DecisionGraph): 673 exploration = core.DiscreteExploration.fromGraph(obj) 674 else: 675 exploration = obj 676 if outputFormat == "exploration": 677 saveExploration(destination, exploration) 678 else: 679 saveAsJournal(destination, exploration) 680 681 682INSPECTOR_HELP = """ 683Available commands: 684 685- 'help' or '?': List commands. 686- 'done', 'quit', 'q', or 'exit': Quit the inspector. 687- 'f' or 'follow': Follow the primary decision when changing steps. Also 688 changes to that decision immediately. Toggles off if on. 689- 'cd' or 'goto': Change focus decision to the named decision. Cancels 690 follow mode. 691- 'ls' or 'list' or 'destinations': Lists transitions at this decision 692 and their destinations, as well as any mechanisms at this decision. 693- 'lst' or 'steps': Lists each step of the exploration along with the 694 primary decision at each step. 695- 'st' or 'step': Switches to the specified step (an index) 696- 'n' or 'next': Switches to the next step. 697- 'p' or 'prev' or 'previous': Switches to the previous step. 698- 't' or 'take': Change focus decision to the decision which is the 699 destination of the specified transition at the current focused 700 decision. 701- 'prm' or 'primary': Displays the current primary decision. 702- 'a' or 'active': Lists all currently active decisions 703- 'u' or 'unexplored': Lists all unexplored transitions at the current 704 step. 705- 'x' or 'explorable': Lists all unexplored transitions at the current 706 step which are traversable based on the current state. (TODO: 707 make this more accurate). 708- 'A' or 'all': Lists all decisions at the current step. 709- 'M' or 'mechanisms': Lists all mechanisms at the current step. 710- 'P' or 'path': Displays the path to get from the current step's state 711 to a state that activates the specified decision. 712- 'r' or 'reachable': TODO 713""" 714 715 716def inspect( 717 source: pathlib.Path, 718 formatOverride: Optional[SourceType] = None 719) -> None: 720 """ 721 Inspects the graph or exploration stored in the `source` file, 722 launching an interactive command line for inspecting properties of 723 decisions, transitions, and situations. The file extension is used 724 to determine how to load the data, although the `--format` option 725 may override this. '.dcg' files are assumed to be decision graphs in 726 JSON format, '.exp' files are assumed to be exploration objects in 727 JSON format, and '.exj' files are assumed to be exploration journals 728 in the default journal format. If the object that gets loaded is a 729 graph, a 1-step exploration containing just that graph will be 730 created to inspect. Inspector commands are listed in the 731 `INSPECTOR_HELP` variable. 732 """ 733 print(f"Loading exploration from {source!r}...") 734 # Load our exploration 735 exploration = loadSource(source, formatOverride) 736 if isinstance(exploration, core.DecisionGraph): 737 exploration = core.DiscreteExploration.fromGraph(exploration) 738 739 print( 740 f"Inspecting exploration with {len(exploration)} step(s) and" 741 f" {len(exploration.allDecisions())} decision(s):" 742 ) 743 print("('h' for help)") 744 745 # Set up tracking variables: 746 step = len(exploration) - 1 747 here: Optional[base.DecisionID] = exploration.primaryDecision(step) 748 graph = exploration.getSituation(step).graph 749 follow = True 750 751 pf = parsing.ParseFormat() 752 753 if here is None: 754 print("Note: There are no decisions in the final graph.") 755 756 while True: 757 # Re-establish the prompt 758 prompt = "> " 759 if here is not None and here in graph: 760 prompt = graph.identityOf(here) + "> " 761 elif here is not None: 762 prompt = f"{here} (?)> " 763 764 # Prompt for the next command 765 try: 766 fullCommand = input(prompt).split() 767 except EOFError: 768 fullCommand = ["quit"] 769 770 # Track number of invalid commands so we can quit after 10 in a row 771 invalidCommands = 0 772 773 if len(fullCommand) == 0: 774 cmd = '' 775 args = '' 776 else: 777 cmd = fullCommand[0] 778 args = ' '.join(fullCommand[1:]) 779 780 # Do what the command says 781 invalid = False 782 if cmd in ("help", '?'): 783 # Displays help message 784 if len(args.strip()) > 0: 785 print("(help does not accept any arguments)") 786 print(INSPECTOR_HELP) 787 elif cmd in ("done", "exit", "quit", "q"): 788 # Exits the inspector 789 if len(args.strip()) > 0: 790 print("(quit does not accept any arguments)") 791 print("Bye.") 792 break 793 elif cmd in ("f", "follow"): 794 if follow: 795 follow = False 796 print("Stopped following") 797 else: 798 follow = True 799 here = exploration.primaryDecision(step) 800 print(f"Now following at: {graph.identityOf(here)}") 801 elif cmd in ("cd", "goto"): 802 # Changes focus to a specific decision 803 try: 804 target = pf.parseDecisionSpecifier(args) 805 target = graph.resolveDecision(target) 806 here = target 807 follow = False 808 print(f"now at: {graph.identityOf(target)}") 809 except Exception: 810 print("(invalid decision specifier)") 811 elif cmd in ("ls", "list", "destinations"): 812 fromID: Optional[base.AnyDecisionSpecifier] = None 813 if args.strip(): 814 fromID = pf.parseDecisionSpecifier(args) 815 fromID = graph.resolveDecision(fromID) 816 else: 817 fromID = here 818 819 if fromID is None: 820 print( 821 "(no focus decision and no decision specified;" 822 " nothing to list; use 'cd' to specify a decision," 823 " or 'all' to list all decisions)" 824 ) 825 else: 826 outgoing = graph.destinationsFrom(fromID) 827 info = graph.identityOf(fromID) 828 if len(outgoing) > 0: 829 print(f"Destinations from {info}:") 830 print(graph.destinationsListing(outgoing)) 831 else: 832 print("No outgoing transitions from {info}.") 833 elif cmd in ("lst", "steps"): 834 total = len(exploration) 835 print(f"{total} step(s):") 836 for step in range(total): 837 pr = exploration.primaryDecision(step) 838 situ = exploration.getSituation(step) 839 stGraph = situ.graph 840 identity = stGraph.identityOf(pr) 841 print(f" {step} at {identity}") 842 print(f"({total} total step(s))") 843 elif cmd in ("st", "step"): 844 error = False 845 try: 846 stepTo = int(args.strip()) 847 except ValueError: 848 print("Invalid step argument (must be an integer).") 849 error = True 850 if not error: 851 if stepTo < 0: 852 stepTo += len(exploration) 853 if stepTo < 0: 854 print( 855 f"Invalid step {args!r} (too negative; min is" 856 f" {-len(exploration)})" 857 ) 858 if stepTo >= len(exploration): 859 print( 860 f"Invalid step {args!r} (too large; max is" 861 f" {len(exploration) - 1})" 862 ) 863 864 step = stepTo 865 graph = exploration.getSituation(step).graph 866 if follow: 867 here = exploration.primaryDecision(step) 868 print(f"Followed to: {graph.identityOf(here)}") 869 elif cmd in ("n", "next"): 870 if step == -1 or step >= len(exploration) - 2: 871 print("Can't step beyond the last step.") 872 else: 873 step += 1 874 graph = exploration.getSituation(step).graph 875 if here not in graph: 876 here = None 877 print(f"At step {step}") 878 if follow: 879 here = exploration.primaryDecision(step) 880 print(f"Followed to: {graph.identityOf(here)}") 881 elif cmd in ("p", "prev"): 882 if step == 0 or step <= -len(exploration) + 2: 883 print("Can't step before the first step.") 884 else: 885 step -= 1 886 graph = exploration.getSituation(step).graph 887 if here not in graph: 888 here = None 889 print(f"At step {step}") 890 if follow: 891 here = exploration.primaryDecision(step) 892 print(f"Followed to: {graph.identityOf(here)}") 893 elif cmd in ("t", "take"): 894 if here is None: 895 print( 896 "(no focus decision, so can't take transitions. Use" 897 " 'cd' to specify a decision first.)" 898 ) 899 else: 900 dest = graph.getDestination(here, args) 901 if dest is None: 902 print( 903 f"Invalid transition {args!r} (no destination for" 904 f" that transition from {graph.identityOf(here)}" 905 ) 906 here = dest 907 elif cmd in ("prm", "primary"): 908 pr = exploration.primaryDecision(step) 909 if pr is None: 910 print(f"Step {step} has no primary decision") 911 else: 912 print( 913 f"Primary decision for step {step} is:" 914 f" {graph.identityOf(pr)}" 915 ) 916 elif cmd in ("a", "active"): 917 active = exploration.getActiveDecisions(step) 918 print(f"Active decisions at step {step}:") 919 print(graph.namesListing(active)) 920 elif cmd in ("u", "unexplored"): 921 unx = analysis.unexploredBranches(graph) 922 fin = ':' if len(unx) > 0 else '.' 923 print(f"{len(unx)} unexplored branch(es){fin}") 924 options = [] 925 for frID, unTr in unx: 926 reqs = graph.getTransitionRequirement(frID, unTr) 927 reqStr = "" 928 satState = 0 929 if reqs != base.ReqNothing(): 930 if reqs == base.ReqImpossible(): 931 reqStr = f"\n (impossible to traverse)" 932 satState = 3 933 else: 934 situ = exploration.getSituation(step) 935 rctx = base.contextForTransition(situ, frID, unTr) 936 sat = reqs.satisfied(rctx) 937 satState = 1 if sat else 2 938 reqStr = ( 939 f"\n requires {reqs.unparse()} (" 940 f"{'satisfied' if sat else 'not satisfied'})" 941 ) 942 # Options will be sorted by 'satisfied-state' then source 943 # ID, then description 944 options.append( 945 ( 946 satState, 947 frID, 948 f"take {unTr} at {graph.identityOf(frID)}{reqStr}" 949 ) 950 ) 951 # Print no-reqs first, then satisfied-reqs, then 952 # unsatisfied-reqs, then obviously-unsatisfiable 953 for _, __, desc in sorted(options): 954 print(desc) 955 elif cmd in ("x", "explorable"): 956 ctx = base.genericContextForSituation( 957 exploration.getSituation(step) 958 ) 959 unx = analysis.unexploredBranches(graph, ctx) 960 fin = ':' if len(unx) > 0 else '.' 961 print(f"{len(unx)} unexplored branch(es){fin}") 962 for frID, unTr in unx: 963 print(f"take {unTr} at {graph.identityOf(frID)}") 964 elif cmd in ("A", "all"): 965 print( 966 f"There are {len(graph)} decision(s) at step {step}:" 967 ) 968 for decision in graph.nodes(): 969 print(f" {graph.identityOf(decision)}") 970 elif cmd in ("M", "mechanisms"): 971 count = len(graph.mechanisms) 972 fin = ':' if count > 0 else '.' 973 print( 974 f"There are {count} mechanism(s) at step {step}{fin}" 975 ) 976 for mID in graph.mechanisms: 977 where, name = graph.mechanisms[mID] 978 state = exploration.mechanismState(mID, step=step) 979 if where is None: 980 print(f" {name!r} (global) in state {state!r}") 981 else: 982 info = graph.identityOf(where) 983 print(f" {name!r} at {info} in state {state!r}") 984 elif cmd in ("P", "path"): 985 stateNow = exploration.getSituation(step).state 986 invalid = False 987 try: 988 whereTo = graph.resolveDecision(args) 989 except Exception: 990 print("(invalid decision specifier)") 991 invalid = True 992 if not invalid: 993 pathThere = analysis.shortestStatePathToActivate( 994 graph, 995 stateNow, 996 whereTo, 997 10000 998 ) 999 info = graph.identityOf(whereTo) 1000 if pathThere is None: 1001 print( 1002 f"No path to {info} from state at step {step}" 1003 f" (within 10,000 transitions)." 1004 ) 1005 else: 1006 print( 1007 f"Found a path to {info} with" 1008 f" {len(pathThere) - 1} steps:" 1009 ) 1010 print(base.statePathSummary(pathThere, graph)) 1011 elif cmd in ("r", "reachable"): 1012 print("TODO: Reachable does not work yet.") 1013 elif cmd in ("Z", "analyze"): 1014 argParts = args.split() 1015 if len(argParts) < 1: 1016 print( 1017 "'analyze' requires at least one argument: the analyzer" 1018 " to run" 1019 ) 1020 invalid = True 1021 else: 1022 if not applyAnalysisFunction( 1023 argParts[0].strip(), 1024 argParts[1:], 1025 exploration, 1026 step, 1027 here, 1028 pf 1029 ): 1030 invalid = True 1031 # No else needed 1032 else: 1033 invalid = True 1034 1035 if invalid: 1036 if invalidCommands >= 10: 1037 print("Too many invalid commands; exiting.") 1038 break 1039 else: 1040 if invalidCommands >= 8: 1041 print("{invalidCommands} invalid commands so far,") 1042 print("inspector will stop after 10 invalid commands...") 1043 print(f"Unknown command {cmd!r}...") 1044 invalidCommands += 1 1045 print(INSPECTOR_HELP) 1046 else: 1047 invalidCommands = 0 1048 1049def resolveAnalyzerStepArgument( 1050 arg: str, 1051 exploration: core.DiscreteExploration 1052) -> Optional[int]: 1053 """ 1054 Resolves an argument used to specify an exploration step, and 1055 returns the step number, or returns None if it can't. 1056 """ 1057 try: 1058 step = int(arg) 1059 except ValueError: 1060 return None 1061 try: 1062 situ = exploration.getSituation(step) 1063 except IndexError: 1064 return None 1065 return step 1066 1067 1068def resolveAnalyzerDecisionArgument( 1069 arg: str, 1070 exploration: core.DiscreteExploration, 1071 step: int, 1072 pf: parsing.ParseFormat 1073) -> Optional[base.DecisionID]: 1074 """ 1075 Resolves an argument used to specify a decision by parsing it as a 1076 `base.DecisionSpecifier` and then resolving that to an ID on the 1077 appropriate step of the given exploration. Returns `None` if it 1078 can't resolve the decision or the format isn't valid. 1079 """ 1080 graph = exploration.getSituation(step).graph 1081 try: 1082 return graph.resolveDecision(pf.parseDecisionSpecifier(arg)) 1083 except Exception: 1084 return None 1085 1086def applyAnalysisFunction( 1087 name: str, 1088 args: Sequence[str], 1089 exploration: core.DiscreteExploration, 1090 defaultStep: int, 1091 defaultDecision: Optional[base.DecisionID], 1092 pf: parsing.ParseFormat 1093) -> bool: 1094 """ 1095 Applies the analysis function with the given name using the given 1096 extra arguments to specify info it needs, with the given default 1097 step & decision values to fill in if they're needed and not 1098 specified. 1099 1100 A `parsing.ParseFormat` is needed to help parse arguments. 1101 1102 Prints the representation of the analysis result value. 1103 1104 Returns true if it succeeds and false if it encountered an error (in 1105 which case it will already have printed an error message). 1106 1107 TODO: Test this! 1108 """ 1109 analyzer = analysis.ALL_ANALYZERS.get(name) 1110 if analyzer is None: 1111 valid = '\n'.join( 1112 repr(k) for k in analysis.ALL_ANALYZERS.keys() 1113 ) 1114 print( 1115 f"Invalid analyzer name: {name!r}" 1116 f" Valid analyzers are:\n{valid}" 1117 ) 1118 return False 1119 else: 1120 step: Optional[int] 1121 decision: Optional[base.DecisionID] 1122 transition: Optional[str] = None 1123 1124 if analyzer._unit == "step": 1125 analyzer = cast(analysis.StepAnalyzer, analyzer) 1126 if len(args) == 0: 1127 result = analyzer(exploration, defaultStep) 1128 elif len(args) == 1: 1129 try: 1130 step = int(args[0]) 1131 except ValueError: 1132 print( 1133 f"Argument for step analyzer" 1134 f" {name!r} must be an" 1135 f" integer step number (got:" 1136 f" {args[0]!r})." 1137 ) 1138 return False 1139 result = analyzer(exploration, step) 1140 else: 1141 print(f"Step analyzer {name!r} must have 0 or 1 arguments.") 1142 return False 1143 elif analyzer._unit == "stepDecision": 1144 analyzer = cast(analysis.StepDecisionAnalyzer, analyzer) 1145 step = defaultStep 1146 decision = defaultDecision 1147 if len(args) == 1: 1148 specStep = resolveAnalyzerStepArgument(args[0], exploration) 1149 if specStep is not None: 1150 step = specStep 1151 else: 1152 decision = resolveAnalyzerDecisionArgument( 1153 args[0], 1154 exploration, 1155 step, 1156 pf 1157 ) 1158 if decision is None: 1159 print( 1160 f"Unable to resolve decision argument" 1161 f" {args[0]!r} on step {step}." 1162 ) 1163 return False 1164 elif len(args) == 2: 1165 step = resolveAnalyzerStepArgument(args[0], exploration) 1166 if step is None: 1167 print(f"Unable to parse step value {args[0]!r}.") 1168 return False 1169 decision = resolveAnalyzerDecisionArgument( 1170 args[1], 1171 exploration, 1172 step, 1173 pf 1174 ) 1175 if decision is None: 1176 print( 1177 f"Unable to resolve decision argument" 1178 f" {args[1]!r} on step {step}." 1179 ) 1180 return False 1181 elif len(args) != 0: 1182 print( 1183 f"Step-decision analyzer {name!r} must have 0-2" 1184 f" arguments." 1185 ) 1186 return False 1187 1188 if decision is None: 1189 print( 1190 f"No current decision and no decision specified for" 1191 f" step-decision analyzer {name!r}." 1192 ) 1193 return False 1194 result = analyzer(exploration, step, decision) 1195 1196 elif analyzer._unit == "stepTransition": 1197 analyzer = cast(analysis.StepTransitionAnalyzer, analyzer) 1198 step = defaultStep 1199 decision = defaultDecision 1200 if len(args) == 1: 1201 transition = args[0] 1202 elif len(args) == 2: 1203 transition = args[1] 1204 decision = resolveAnalyzerDecisionArgument( 1205 args[0], 1206 exploration, 1207 step, 1208 pf 1209 ) 1210 if decision is None: 1211 print( 1212 f"Unable to parse and resolve argument" 1213 f" {args[0]!r} as a decision for" 1214 f" step-transition analyzer {name!r}." 1215 ) 1216 return False 1217 elif len(args) == 3: 1218 transition = args[2] 1219 step = resolveAnalyzerStepArgument(args[0], exploration) 1220 if step is None: 1221 print( 1222 f"Unable to parse argument {args[0]!r} as a" 1223 f" step number for step-transition analyzer" 1224 f" {name!r}." 1225 ) 1226 return False 1227 decision = resolveAnalyzerDecisionArgument( 1228 args[1], 1229 exploration, 1230 step, 1231 pf 1232 ) 1233 if decision is None: 1234 print( 1235 f"Unable to parse and resolve argument" 1236 f" {args[1]!r} as a decision for" 1237 f" step-transition analyzer {name!r}." 1238 ) 1239 return False 1240 else: 1241 print( 1242 f"Step-transition analyzer {name!r} must have 1-3" 1243 f" arguments." 1244 ) 1245 return False 1246 1247 if decision is None: 1248 print( 1249 f"No current decision and no decision specified for" 1250 f" step-transition analyzer {name!r}." 1251 ) 1252 return False 1253 if transition is None: 1254 print( 1255 f"No transition specified for step-transition" 1256 f" analyzer {name!r}." 1257 ) 1258 return False 1259 1260 destination = exploration.getSituation(step).graph.destination( 1261 decision, 1262 transition 1263 ) 1264 result = analyzer( 1265 exploration, 1266 step, 1267 decision, 1268 transition, 1269 destination 1270 ) 1271 1272 elif analyzer._unit == "decision": 1273 analyzer = cast(analysis.DecisionAnalyzer, analyzer) 1274 decision = defaultDecision 1275 if len(args) == 1: 1276 decision = resolveAnalyzerDecisionArgument( 1277 args[0], 1278 exploration, 1279 defaultStep, 1280 pf 1281 ) 1282 if decision is None: 1283 print( 1284 f"Unable to parse and resolve argument" 1285 f" {args[0]!r} as a decision for decision" 1286 f" analyzer {name!r}." 1287 ) 1288 return False 1289 elif len(args) != 0: 1290 print(f"Decision analyzer {name!r} must have 1-3 arguments.") 1291 return False 1292 1293 if decision is None: 1294 print( 1295 f"No current decision and no decision specified for" 1296 f" decision analyzer {name!r}." 1297 ) 1298 return False 1299 1300 result = analyzer(exploration, decision) 1301 1302 elif analyzer._unit == "transition": 1303 analyzer = cast(analysis.TransitionAnalyzer, analyzer) 1304 decision = defaultDecision 1305 if len(args) == 1: 1306 transition = args[0] 1307 elif len(args) == 2: 1308 transition = args[1] 1309 decision = resolveAnalyzerDecisionArgument( 1310 args[0], 1311 exploration, 1312 defaultStep, 1313 pf 1314 ) 1315 if decision is None: 1316 print( 1317 f"Unable to parse and resolve argument" 1318 f" {args[0]!r} as a decision for" 1319 f" transition analyzer {name!r}." 1320 ) 1321 return False 1322 else: 1323 print( 1324 f"Transition analyzer {name!r} must have 1-2 arguments." 1325 ) 1326 return False 1327 1328 if decision is None: 1329 print( 1330 f"No current decision and no decision specified for" 1331 f" transition analyzer {name!r}." 1332 ) 1333 return False 1334 if transition is None: 1335 print( 1336 f"No transition specified for transition analyzer" 1337 f" {name!r}." 1338 ) 1339 return False 1340 1341 graph = exploration.getSituation(defaultStep).graph 1342 destination = graph.destination(decision, transition) 1343 result = analyzer( 1344 exploration, 1345 decision, 1346 transition, 1347 destination 1348 ) 1349 1350 elif analyzer._unit == "exploration": 1351 analyzer = cast(analysis.ExplorationAnalyzer, analyzer) 1352 if len(args) != 0: 1353 print( 1354 f"Exploration analyzer {name!r} must have 0 arguments." 1355 ) 1356 return False 1357 1358 result = analyzer(exploration) 1359 1360 else: 1361 raise ValueError( 1362 f"Unrecognized analysis unit {analyzer._unit!r}." 1363 ) 1364 1365 # Finally print our result! 1366 print(repr(result)) 1367 return True 1368 1369 1370#--------------# 1371# Parser setup # 1372#--------------# 1373 1374parser = argparse.ArgumentParser( 1375 prog="python -m exploration", 1376 description="""\ 1377Runs various commands for processing exploration graphs and journals, 1378and for converting between them or displaying them in various formats. 1379""" 1380) 1381subparsers = parser.add_subparsers( 1382 title="commands", 1383 description="The available commands are:", 1384 help="use these with -h/--help for more details" 1385) 1386 1387checkParser = subparsers.add_parser( 1388 'check', 1389 help="check a journal or graph file", 1390 description=textwrap.dedent(str(show.__doc__)).strip() 1391) 1392checkParser.set_defaults(run="check") 1393checkParser.add_argument( 1394 "source", 1395 type=pathlib.Path, 1396 help="The file to check" 1397) 1398checkParser.add_argument( 1399 '-f', 1400 "--format", 1401 choices=get_args(SourceType), 1402 help=( 1403 "Which format the source file is in (normally that can be" 1404 " determined from the file extension)." 1405 ) 1406) 1407checkParser.add_argument( 1408 '-i', 1409 "--interactive", 1410 action='store_true', 1411 help=( 1412 "Whether to enter interactive fix mode when an error occurs." 1413 ) 1414) 1415 1416showParser = subparsers.add_parser( 1417 'show', 1418 help="show an exploration", 1419 description=textwrap.dedent(str(show.__doc__)).strip() 1420) 1421showParser.set_defaults(run="show") 1422showParser.add_argument( 1423 "source", 1424 type=pathlib.Path, 1425 help="The file to load" 1426) 1427showParser.add_argument( 1428 '-f', 1429 "--format", 1430 choices=get_args(SourceType), 1431 help=( 1432 "Which format the source file is in (normally that can be" 1433 " determined from the file extension)." 1434 ) 1435) 1436showParser.add_argument( 1437 '-s', 1438 "--step", 1439 type=int, 1440 default=-1, 1441 help="Which graph step to show (when loading an exploration)." 1442) 1443 1444analyzeParser = subparsers.add_parser( 1445 'analyze', 1446 help="analyze an exploration", 1447 description=textwrap.dedent(str(analyze.__doc__)).strip() 1448) 1449analyzeParser.set_defaults(run="analyze") 1450analyzeParser.add_argument( 1451 "source", 1452 type=pathlib.Path, 1453 help="The file holding the exploration to analyze" 1454) 1455analyzeParser.add_argument( 1456 "destination", 1457 default=None, 1458 type=pathlib.Path, 1459 help=( 1460 "The file name where the output should be written (this file" 1461 " will be overwritten without warning)." 1462 ) 1463) 1464analyzeParser.add_argument( 1465 '-f', 1466 "--format", 1467 choices=get_args(SourceType), 1468 help=( 1469 "Which format the source file is in (normally that can be" 1470 " determined from the file extension)." 1471 ) 1472) 1473analyzeParser.add_argument( 1474 '-a', 1475 "--all", 1476 action='store_true', 1477 help=( 1478 "Whether to include all results or just the default ones. Some" 1479 " of the extended results may cause issues with loading the CSV" 1480 " file in common programs like Excel." 1481 ) 1482) 1483analyzeParser.add_argument( 1484 '-p', 1485 "--profile", 1486 action='store_true', 1487 help="Set this to profile time taken by analysis functions." 1488) 1489 1490convertParser = subparsers.add_parser( 1491 'convert', 1492 help="convert an exploration", 1493 description=textwrap.dedent(str(convert.__doc__)).strip() 1494) 1495convertParser.set_defaults(run="convert") 1496convertParser.add_argument( 1497 "source", 1498 type=pathlib.Path, 1499 help="The file holding the graph or exploration to convert." 1500) 1501convertParser.add_argument( 1502 "destination", 1503 type=pathlib.Path, 1504 help=( 1505 "The file name where the output should be written (this file" 1506 " will be overwritten without warning)." 1507 ) 1508) 1509convertParser.add_argument( 1510 '-f', 1511 "--format", 1512 choices=get_args(SourceType), 1513 help=( 1514 "Which format the source file is in (normally that can be" 1515 " determined from the file extension)." 1516 ) 1517) 1518convertParser.add_argument( 1519 '-o', 1520 "--output-format", 1521 choices=get_args(SourceType), 1522 help=( 1523 "Which format the converted file should be saved as (normally" 1524 " that is determined from the file extension)." 1525 ) 1526) 1527convertParser.add_argument( 1528 '-s', 1529 "--step", 1530 type=int, 1531 default=-1, 1532 help=( 1533 "Which graph step to save (when converting from an exploration" 1534 " format to a graph format)." 1535 ) 1536) 1537 1538inspectParser = subparsers.add_parser( 1539 'inspect', 1540 help="interactively inspect an exploration", 1541 description=textwrap.dedent(str(inspect.__doc__)).strip() 1542) 1543inspectParser.set_defaults(run="inspect") 1544inspectParser.add_argument( 1545 "source", 1546 type=pathlib.Path, 1547 help="The file holding the graph or exploration to inspect." 1548) 1549inspectParser.add_argument( 1550 '-f', 1551 "--format", 1552 choices=get_args(SourceType), 1553 help=( 1554 "Which format the source file is in (normally that can be" 1555 " determined from the file extension)." 1556 ) 1557) 1558 1559def main(): 1560 """ 1561 Parse options from command line & run appropriate tool. 1562 """ 1563 options = parser.parse_args() 1564 if not hasattr(options, "run"): 1565 print("No sub-command specified.") 1566 parser.print_help() 1567 exit(1) 1568 elif options.run == "check": 1569 check( 1570 options.source, 1571 formatOverride=options.format, 1572 interactive=options.interactive 1573 ) 1574 elif options.run == "show": 1575 show( 1576 options.source, 1577 formatOverride=options.format, 1578 step=options.step 1579 ) 1580 elif options.run == "analyze": 1581 analyze( 1582 options.source, 1583 destination=options.destination, 1584 formatOverride=options.format, 1585 includeAll=options.all, 1586 profile=options.profile 1587 ) 1588 elif options.run == "convert": 1589 convert( 1590 options.source, 1591 options.destination, 1592 inputFormatOverride=options.format, 1593 outputFormatOverride=options.output_format, 1594 step=options.step 1595 ) 1596 elif options.run == "inspect": 1597 inspect( 1598 options.source, 1599 formatOverride=options.format 1600 ) 1601 else: 1602 raise RuntimeError( 1603 f"Invalid 'run' default value: '{options.run}'." 1604 ) 1605 1606 1607if __name__ == "__main__": 1608 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( 141 path: pathlib.Path, 142 interactive: bool = False 143) -> core.DiscreteExploration: 144 """ 145 Loads a `core.DiscreteExploration` object from a journal file 146 (extension should normally be '.exj'). Uses the 147 `journal.convertJournal` function. 148 149 Passes `interactive` through to that function. 150 """ 151 with path.open('r', encoding='utf-8-sig') as fInput: 152 return journal.convertJournal( 153 fInput.read(), 154 filename=str(path), 155 interactive=interactive 156 )
Loads a core.DiscreteExploration object from a journal file
(extension should normally be '.exj'). Uses the
journal.convertJournal function.
Passes interactive through to that function.
159def saveAsJournal( 160 path: pathlib.Path, 161 exploration: core.DiscreteExploration 162) -> None: 163 """ 164 Saves a `core.DiscreteExploration` object as a text journal in the 165 specified file. The file extension should normally be '.exj'. 166 167 TODO: This?! 168 """ 169 raise NotImplementedError( 170 "DiscreteExploration-to-journal conversion is not implemented" 171 " yet." 172 )
Saves a core.DiscreteExploration object as a text journal in the
specified file. The file extension should normally be '.exj'.
TODO: This?!
175def loadSource( 176 path: pathlib.Path, 177 formatOverride: Optional[SourceType] = None, 178 interactive: bool = False 179) -> Union[core.DecisionGraph, core.DiscreteExploration]: 180 """ 181 Loads either a `core.DecisionGraph` or a `core.DiscreteExploration` 182 from the specified file, depending on its file extension (or the 183 specified format given as `formatOverride` if there is one). 184 185 `interactive` only affects parsing of journal files, and is passed 186 through to `journal.convertJournal`. 187 """ 188 if formatOverride is not None: 189 format = formatOverride 190 else: 191 format = determineFileType(str(path)) 192 193 if format == "graph": 194 return loadDecisionGraph(path) 195 if format == "dot": 196 return loadDotFile(path) 197 elif format == "exploration": 198 return loadExploration(path) 199 elif format == "journal": 200 return loadJournal(path, interactive) 201 else: 202 raise ValueError( 203 f"Unrecognized file format '{format}' (recognized formats" 204 f" are 'graph', 'exploration', and 'journal')." 205 )
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).
interactive only affects parsing of journal files, and is passed
through to journal.convertJournal.
A type alias for values we're willing to store in a CSV file without coercing them to a string.
219def coerceToCSVValue(result: Any) -> CSVEmbeddable: 220 """ 221 Coerces any value to one that's embeddable in a CSV file. The 222 `CSVEmbeddable` types are unchanged, but all other types are 223 converted to strings via `json.dumps` if possible or `repr` if not. 224 """ 225 if isinstance(result, get_args(CSVEmbeddable)): 226 return result 227 else: 228 try: 229 return json.dumps(result) 230 except Exception: 231 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.
238def check( 239 source: pathlib.Path, 240 formatOverride: Optional[SourceType] = None, 241 interactive: bool = False 242) -> None: 243 """ 244 Parses a journal, exploration, or graph file and reports on any 245 warnings or errors, then exits. The file extension is used to 246 determine how to load the data, although the `--format` option may 247 override this. '.dcg' files are assumed to be decision graphs in 248 JSON format, '.exp' files are assumed to be exploration objects in 249 JSON format, and '.exj' files are assumed to be exploration journals 250 in the default journal format. 251 252 Interactive debugging can be enabled by setting `interactive` to 253 `True` (default is `False`). 254 """ 255 # Loading should display errors/warnings by default 256 loadSource(source, formatOverride, interactive=interactive) 257 258 # TODO: Check for each-step mechanism ambiguity
Parses a journal, exploration, or graph file and reports on any
warnings or errors, then exits. 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.
Interactive debugging can be enabled by setting interactive to
True (default is False).
261def show( 262 source: pathlib.Path, 263 formatOverride: Optional[SourceType] = None, 264 step: int = -1 265) -> None: 266 """ 267 Shows the graph or exploration stored in the `source` file. You will 268 need to have the `matplotlib` library installed. Consider using the 269 interactive interface provided by the `explorationViewer` module 270 instead. The file extension is used to determine how to load the data, 271 although the `--format` option may override this. '.dcg' files are 272 assumed to be decision graphs in JSON format, '.exp' files are assumed 273 to be exploration objects in JSON format, and '.exj' files are assumed 274 to be exploration journals in the default journal format. If the object 275 that gets loaded is an exploration, the final graph for that 276 exploration will be displayed, or a specific graph may be selected 277 using `--step`. 278 """ 279 obj = loadSource(source, formatOverride) 280 if isinstance(obj, core.DiscreteExploration): 281 obj = obj.getSituation(step).graph 282 283 import matplotlib.pyplot # type: ignore 284 285 # This draws the graph in a new window that pops up. You have to close 286 # the window to end the program. 287 nx.draw(obj) 288 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.
291def transitionStr( 292 exploration: core.DiscreteExploration, 293 src: base.DecisionID, 294 transition: base.Transition, 295 dst: base.DecisionID 296) -> str: 297 """ 298 Given an exploration object, returns a string identifying a 299 transition, incorporating the final identity strings for the source 300 and destination. 301 """ 302 srcId = analysis.finalIdentity(exploration, src) 303 dstId = analysis.finalIdentity(exploration, dst) 304 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.
307def printPerf(analyzerName: str) -> None: 308 """ 309 Prints performance for the given analyzer to stderr. 310 """ 311 perf = analysis.ANALYSIS_TIME_SPENT.get(analyzerName) 312 if perf is None: 313 raise RuntimeError( 314 f"Missing analysis perf for {analyzerName!r}." 315 ) 316 unit = analysis.ALL_ANALYZERS[analyzerName]._unit 317 call, noC, tc, tw = perf.values() 318 print( 319 f"{analyzerName} ({unit}): {call} / {noC} / {tc:.6f} / {tw:.6f}", 320 file=sys.stderr 321 )
Prints performance for the given analyzer to stderr.
324def printMem() -> None: 325 """ 326 Prints (to stderr) a message about how much memory Python is 327 currently using overall. 328 """ 329 os = sys.platform 330 units = 1 331 if os.startswith('linux') or os.startswith('android'): 332 units = 1000 333 if resource is not None: 334 used = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss * units 335 suffix = "B" 336 if used > 1000000000: 337 usage = f"{used/1000000000:.2f} GB" 338 elif used > 1000000: 339 usage = f"{used/1000000:.2f} MB" 340 elif used > 1000: 341 usage = f"{used/1000:.2f} KB" 342 else: 343 usage = f"{used} B" 344 print(f"Using {usage} memory") 345 else: 346 print( 347 f"Can't get memory usage because the resource module is not" 348 f" available." 349 )
Prints (to stderr) a message about how much memory Python is currently using overall.
352def analyze( 353 source: pathlib.Path, 354 destination: Optional[pathlib.Path] = None, 355 formatOverride: Optional[SourceType] = None, 356 applyTools: Optional[Collection[str]] = None, 357 finalOnly: Optional[Collection[str]] = None, 358 includeAll: bool = False, 359 profile: bool = False 360) -> None: 361 """ 362 Analyzes the exploration stored in the `source` file. The file 363 extension is used to determine how to load the data, although this 364 may be overridden by the `--format` option. Normally, '.exp' files 365 are treated as JSON-encoded exploration objects, while '.exj' files 366 are treated as journals using the default journal format. 367 368 This applies a number of analysis functions to produce a CSV file 369 showing per-decision-per-step, per-decision, per-step, and 370 per-exploration metrics. A subset of the available metrics may be 371 selected by passing a list of strings for the `applyTools` argument. 372 These strings should be the names of functions in `analysis.py` that 373 are decorated with `analysis.analyze`. By default, only those not 374 marked with `analysis.elide` will be included. You can set 375 `includeAll` to `True` to include all tools, although this is ignored 376 when `applyTools` is not `None`. `finalOnly` specifies one or more 377 tools to only run on the final step of the exploration rather than 378 every step. This only applies to tools whose unit of analysis is 379 'step', 'stepDecision', or 'stepTransition'. By default those marked 380 as `finalOnly` in `analysis.py` will be run this way. Tools excluded 381 via `applyTools` or by default when `includeAll` is false won't be 382 run even if specified in `finalOnly`. Set `finalOnly` to `False` to 383 run all selected tools on all steps without having to explicitly 384 list the tools that would otherwise be restricted by default. 385 386 Set `profile` to `True` to gather and report analysis time spent 387 results (they'll be printed to stdout). 388 389 If no output file is specified, the output will be printed out. 390 """ 391 if profile: 392 print("Starting analysis with profiling...", file=sys.stderr) 393 parseStart = time.perf_counter() 394 printMem() 395 # Load our source exploration object: 396 obj = loadSource(source, formatOverride) 397 if isinstance(obj, core.DecisionGraph): 398 obj = core.DiscreteExploration.fromGraph(obj) 399 if profile: 400 elapsed = time.perf_counter() - parseStart 401 print(f"Parsed input in {elapsed:.6f}s...", file=sys.stderr) 402 printMem() 403 404 exploration: core.DiscreteExploration = obj 405 406 # Set up for profiling 407 if profile: 408 analysis.RECORD_PROFILE = True 409 else: 410 analysis.RECORD_PROFILE = False 411 412 # Figure out which to apply 413 if applyTools is not None: 414 toApply: Set[str] = set(applyTools) 415 else: 416 toApply = set(analysis.ALL_ANALYZERS.keys()) 417 if not includeAll: 418 print("ELIDING:", analysis.ELIDE, file=sys.stderr) 419 toApply -= analysis.ELIDE 420 421 if finalOnly is False: 422 finalOnly = set() 423 elif finalOnly is None: 424 finalOnly = analysis.FINAL_ONLY 425 426 # Group analyzers by unit 427 byUnit = analysis.analyzersByUnit(toApply) 428 429 # Apply all of the analysis functions (or only just those that are 430 # selected using applyTools): 431 432 wholeRows: List[List[CSVEmbeddable]] = [['Whole exploration metrics:']] 433 if profile: 434 print( 435 "name (unit): calls / non-cached / time (lookups) / time (work)", 436 file=sys.stderr 437 ) 438 # One row per analyzer 439 for ea in byUnit["exploration"]: 440 wholeRows.append([ea.__name__, coerceToCSVValue(ea(exploration))]) 441 if profile: 442 printPerf(ea.__name__) 443 444 # A few variables for holding pieces we'll assemble 445 row: List[CSVEmbeddable] 446 columns: List[CSVEmbeddable] 447 448 decisionRows: List[Sequence[CSVEmbeddable]] = [ 449 ['Per-decision metrics:'] 450 ] 451 # One row per tool; one column per decision 452 decisionList: List[base.DecisionID] = exploration.allDecisions() 453 columns = ( 454 cast(List[CSVEmbeddable], ['Metric ↓/Decision →']) 455 + cast(List[CSVEmbeddable], decisionList) 456 ) 457 458 decisionRows.append(columns) 459 for da in byUnit["decision"]: 460 row = [da.__name__] 461 decisionRows.append(row) 462 for decision in decisionList: 463 row.append(coerceToCSVValue(da(exploration, decision))) 464 if profile: 465 printPerf(da.__name__) 466 467 transitionRows: List[Sequence[CSVEmbeddable]] = [ 468 ['Per-transition metrics:'] 469 ] 470 # One row per tool; one column per decision 471 transitionList: List[ 472 Tuple[base.DecisionID, base.Transition, base.DecisionID] 473 ] = exploration.allTransitions() 474 transitionStrings: List[CSVEmbeddable] = [ 475 transitionStr(exploration, *trans) 476 for trans in transitionList 477 ] 478 columns = ( 479 cast(List[CSVEmbeddable], ['Metric ↓/Transition →']) 480 + transitionStrings 481 ) 482 transitionRows.append(columns) 483 for ta in byUnit["transition"]: 484 row = [ta.__name__] 485 transitionRows.append(row) 486 for transition in transitionList: 487 row.append( 488 coerceToCSVValue(ta(exploration, *transition)) 489 ) 490 if profile: 491 printPerf(ta.__name__) 492 493 stepRows: List[Sequence[CSVEmbeddable]] = [ 494 ['Per-step metrics:'] 495 ] 496 # One row per exploration step; one column per tool 497 columns = ['Step ↓/Metric →'] 498 stepRows.append(columns) 499 for step in range(len(exploration)): 500 row = [step] 501 stepRows.append(row) 502 for sa in byUnit["step"]: 503 if step == 0: 504 columns.append(sa.__name__) 505 if sa.__name__ in finalOnly and step != len(exploration) - 1: 506 row.append("") 507 else: 508 row.append(coerceToCSVValue(sa(exploration, step))) 509 510 # Print profile results just once after all steps have been analyzed 511 if profile: 512 for sa in byUnit["step"]: 513 printPerf(sa.__name__) 514 515 stepwiseRows: List[Sequence[CSVEmbeddable]] = [ 516 ['Per-decision-per-step metrics (one table per metric):'] 517 ] 518 519 # For each per-step decision tool; one row per exploration step and 520 # one column per decision 521 columns = ( 522 cast(List[CSVEmbeddable], ['Step ↓/Decision →']) 523 + cast(List[CSVEmbeddable], decisionList) 524 ) 525 identities = ['Decision names:'] + [ 526 analysis.finalIdentity(exploration, d) 527 for d in decisionList 528 ] 529 for sda in byUnit["stepDecision"]: 530 stepwiseRows.append([sda.__name__]) 531 stepwiseRows.append(columns) 532 stepwiseRows.append(identities) 533 if sda.__name__ in finalOnly: 534 step = len(exploration) - 1 535 row = [step] 536 stepwiseRows.append(row) 537 for decision in decisionList: 538 row.append(coerceToCSVValue(sda(exploration, step, decision))) 539 else: 540 for step in range(len(exploration)): 541 row = [step] 542 stepwiseRows.append(row) 543 for decision in decisionList: 544 row.append( 545 coerceToCSVValue(sda(exploration, step, decision)) 546 ) 547 if profile: 548 printPerf(sda.__name__) 549 550 stepwiseTransitionRows: List[Sequence[CSVEmbeddable]] = [ 551 ['Per-transition-per-step metrics (one table per metric):'] 552 ] 553 554 # For each per-step transition tool; one row per exploration step and 555 # one column per transition 556 columns = ( 557 cast(List[CSVEmbeddable], ['Step ↓/Transition →']) 558 + cast(List[CSVEmbeddable], transitionStrings) 559 ) 560 for sta in byUnit["stepTransition"]: 561 stepwiseTransitionRows.append([sta.__name__]) 562 stepwiseTransitionRows.append(columns) 563 if sta.__name__ in finalOnly: 564 step = len(exploration) - 1 565 row = [step] 566 stepwiseTransitionRows.append(row) 567 for (src, trans, dst) in transitionList: 568 row.append( 569 coerceToCSVValue(sta(exploration, step, src, trans, dst)) 570 ) 571 else: 572 for step in range(len(exploration)): 573 row = [step] 574 stepwiseTransitionRows.append(row) 575 for (src, trans, dst) in transitionList: 576 row.append( 577 coerceToCSVValue( 578 sta(exploration, step, src, trans, dst) 579 ) 580 ) 581 if profile: 582 printPerf(sta.__name__) 583 584 # Build a grid containing just the non-empty analysis categories, so 585 # that if you deselect some tools you get a smaller CSV file: 586 grid: List[Sequence[CSVEmbeddable]] = [] 587 if len(wholeRows) > 1: 588 grid.extend(wholeRows) 589 for block in ( 590 decisionRows, 591 transitionRows, 592 stepRows, 593 stepwiseRows, 594 stepwiseTransitionRows 595 ): 596 if len(block) > 1: 597 if grid: 598 grid.append([]) # spacer 599 grid.extend(block) 600 601 # Print all profile results at the end 602 if profile: 603 print("-"*80, file=sys.stderr) 604 print("Done with analysis. Time taken:", file=sys.stderr) 605 print("-"*80, file=sys.stderr) 606 for aname in analysis.ANALYSIS_TIME_SPENT: 607 printPerf(aname) 608 print("-"*80, file=sys.stderr) 609 printMem() 610 611 # Figure out our destination stream: 612 if destination is None: 613 outStream = sys.stdout 614 closeIt = False 615 else: 616 outStream = open(destination, 'w') 617 closeIt = True 618 619 # Create a CSV writer for our stream 620 writer = csv.writer(outStream) 621 622 # Write out our grid to the file 623 try: 624 writer.writerows(grid) 625 finally: 626 if closeIt: 627 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.
630def convert( 631 source: pathlib.Path, 632 destination: pathlib.Path, 633 inputFormatOverride: Optional[SourceType] = None, 634 outputFormatOverride: Optional[SourceType] = None, 635 step: int = -1 636) -> None: 637 """ 638 Converts between exploration and graph formats. By default, formats 639 are determined by file extensions, but using the `--format` and 640 `--output-format` options can override this. The available formats 641 are: 642 643 - '.dcg' A `core.DecisionGraph` stored in JSON format. 644 - '.dot' A `core.DecisionGraph` stored as a GraphViz DOT file. 645 - '.exp' A `core.DiscreteExploration` stored in JSON format. 646 - '.exj' A `core.DiscreteExploration` stored as a journal (see 647 `journal.JournalObserver`; TODO: writing this format). 648 649 When converting a decision graph into an exploration format, the 650 resulting exploration will have a single starting step containing 651 the entire specified graph. When converting an exploration into a 652 decision graph format, only the current graph will be saved, unless 653 `--step` is used to specify a different step index to save. 654 """ 655 # TODO journal writing 656 obj = loadSource(source, inputFormatOverride) 657 658 if outputFormatOverride is None: 659 outputFormat = determineFileType(str(destination)) 660 else: 661 outputFormat = outputFormatOverride 662 663 if outputFormat in ("graph", "dot"): 664 if isinstance(obj, core.DiscreteExploration): 665 graph = obj.getSituation(step).graph 666 else: 667 graph = obj 668 if outputFormat == "graph": 669 saveDecisionGraph(destination, graph) 670 else: 671 saveDotFile(destination, graph) 672 else: 673 if isinstance(obj, core.DecisionGraph): 674 exploration = core.DiscreteExploration.fromGraph(obj) 675 else: 676 exploration = obj 677 if outputFormat == "exploration": 678 saveExploration(destination, exploration) 679 else: 680 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.DecisionGraphstored in JSON format. - '.dot' A
core.DecisionGraphstored as a GraphViz DOT file. - '.exp' A
core.DiscreteExplorationstored in JSON format. - '.exj' A
core.DiscreteExplorationstored 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.
717def inspect( 718 source: pathlib.Path, 719 formatOverride: Optional[SourceType] = None 720) -> None: 721 """ 722 Inspects the graph or exploration stored in the `source` file, 723 launching an interactive command line for inspecting properties of 724 decisions, transitions, and situations. The file extension is used 725 to determine how to load the data, although the `--format` option 726 may override this. '.dcg' files are assumed to be decision graphs in 727 JSON format, '.exp' files are assumed to be exploration objects in 728 JSON format, and '.exj' files are assumed to be exploration journals 729 in the default journal format. If the object that gets loaded is a 730 graph, a 1-step exploration containing just that graph will be 731 created to inspect. Inspector commands are listed in the 732 `INSPECTOR_HELP` variable. 733 """ 734 print(f"Loading exploration from {source!r}...") 735 # Load our exploration 736 exploration = loadSource(source, formatOverride) 737 if isinstance(exploration, core.DecisionGraph): 738 exploration = core.DiscreteExploration.fromGraph(exploration) 739 740 print( 741 f"Inspecting exploration with {len(exploration)} step(s) and" 742 f" {len(exploration.allDecisions())} decision(s):" 743 ) 744 print("('h' for help)") 745 746 # Set up tracking variables: 747 step = len(exploration) - 1 748 here: Optional[base.DecisionID] = exploration.primaryDecision(step) 749 graph = exploration.getSituation(step).graph 750 follow = True 751 752 pf = parsing.ParseFormat() 753 754 if here is None: 755 print("Note: There are no decisions in the final graph.") 756 757 while True: 758 # Re-establish the prompt 759 prompt = "> " 760 if here is not None and here in graph: 761 prompt = graph.identityOf(here) + "> " 762 elif here is not None: 763 prompt = f"{here} (?)> " 764 765 # Prompt for the next command 766 try: 767 fullCommand = input(prompt).split() 768 except EOFError: 769 fullCommand = ["quit"] 770 771 # Track number of invalid commands so we can quit after 10 in a row 772 invalidCommands = 0 773 774 if len(fullCommand) == 0: 775 cmd = '' 776 args = '' 777 else: 778 cmd = fullCommand[0] 779 args = ' '.join(fullCommand[1:]) 780 781 # Do what the command says 782 invalid = False 783 if cmd in ("help", '?'): 784 # Displays help message 785 if len(args.strip()) > 0: 786 print("(help does not accept any arguments)") 787 print(INSPECTOR_HELP) 788 elif cmd in ("done", "exit", "quit", "q"): 789 # Exits the inspector 790 if len(args.strip()) > 0: 791 print("(quit does not accept any arguments)") 792 print("Bye.") 793 break 794 elif cmd in ("f", "follow"): 795 if follow: 796 follow = False 797 print("Stopped following") 798 else: 799 follow = True 800 here = exploration.primaryDecision(step) 801 print(f"Now following at: {graph.identityOf(here)}") 802 elif cmd in ("cd", "goto"): 803 # Changes focus to a specific decision 804 try: 805 target = pf.parseDecisionSpecifier(args) 806 target = graph.resolveDecision(target) 807 here = target 808 follow = False 809 print(f"now at: {graph.identityOf(target)}") 810 except Exception: 811 print("(invalid decision specifier)") 812 elif cmd in ("ls", "list", "destinations"): 813 fromID: Optional[base.AnyDecisionSpecifier] = None 814 if args.strip(): 815 fromID = pf.parseDecisionSpecifier(args) 816 fromID = graph.resolveDecision(fromID) 817 else: 818 fromID = here 819 820 if fromID is None: 821 print( 822 "(no focus decision and no decision specified;" 823 " nothing to list; use 'cd' to specify a decision," 824 " or 'all' to list all decisions)" 825 ) 826 else: 827 outgoing = graph.destinationsFrom(fromID) 828 info = graph.identityOf(fromID) 829 if len(outgoing) > 0: 830 print(f"Destinations from {info}:") 831 print(graph.destinationsListing(outgoing)) 832 else: 833 print("No outgoing transitions from {info}.") 834 elif cmd in ("lst", "steps"): 835 total = len(exploration) 836 print(f"{total} step(s):") 837 for step in range(total): 838 pr = exploration.primaryDecision(step) 839 situ = exploration.getSituation(step) 840 stGraph = situ.graph 841 identity = stGraph.identityOf(pr) 842 print(f" {step} at {identity}") 843 print(f"({total} total step(s))") 844 elif cmd in ("st", "step"): 845 error = False 846 try: 847 stepTo = int(args.strip()) 848 except ValueError: 849 print("Invalid step argument (must be an integer).") 850 error = True 851 if not error: 852 if stepTo < 0: 853 stepTo += len(exploration) 854 if stepTo < 0: 855 print( 856 f"Invalid step {args!r} (too negative; min is" 857 f" {-len(exploration)})" 858 ) 859 if stepTo >= len(exploration): 860 print( 861 f"Invalid step {args!r} (too large; max is" 862 f" {len(exploration) - 1})" 863 ) 864 865 step = stepTo 866 graph = exploration.getSituation(step).graph 867 if follow: 868 here = exploration.primaryDecision(step) 869 print(f"Followed to: {graph.identityOf(here)}") 870 elif cmd in ("n", "next"): 871 if step == -1 or step >= len(exploration) - 2: 872 print("Can't step beyond the last step.") 873 else: 874 step += 1 875 graph = exploration.getSituation(step).graph 876 if here not in graph: 877 here = None 878 print(f"At step {step}") 879 if follow: 880 here = exploration.primaryDecision(step) 881 print(f"Followed to: {graph.identityOf(here)}") 882 elif cmd in ("p", "prev"): 883 if step == 0 or step <= -len(exploration) + 2: 884 print("Can't step before the first step.") 885 else: 886 step -= 1 887 graph = exploration.getSituation(step).graph 888 if here not in graph: 889 here = None 890 print(f"At step {step}") 891 if follow: 892 here = exploration.primaryDecision(step) 893 print(f"Followed to: {graph.identityOf(here)}") 894 elif cmd in ("t", "take"): 895 if here is None: 896 print( 897 "(no focus decision, so can't take transitions. Use" 898 " 'cd' to specify a decision first.)" 899 ) 900 else: 901 dest = graph.getDestination(here, args) 902 if dest is None: 903 print( 904 f"Invalid transition {args!r} (no destination for" 905 f" that transition from {graph.identityOf(here)}" 906 ) 907 here = dest 908 elif cmd in ("prm", "primary"): 909 pr = exploration.primaryDecision(step) 910 if pr is None: 911 print(f"Step {step} has no primary decision") 912 else: 913 print( 914 f"Primary decision for step {step} is:" 915 f" {graph.identityOf(pr)}" 916 ) 917 elif cmd in ("a", "active"): 918 active = exploration.getActiveDecisions(step) 919 print(f"Active decisions at step {step}:") 920 print(graph.namesListing(active)) 921 elif cmd in ("u", "unexplored"): 922 unx = analysis.unexploredBranches(graph) 923 fin = ':' if len(unx) > 0 else '.' 924 print(f"{len(unx)} unexplored branch(es){fin}") 925 options = [] 926 for frID, unTr in unx: 927 reqs = graph.getTransitionRequirement(frID, unTr) 928 reqStr = "" 929 satState = 0 930 if reqs != base.ReqNothing(): 931 if reqs == base.ReqImpossible(): 932 reqStr = f"\n (impossible to traverse)" 933 satState = 3 934 else: 935 situ = exploration.getSituation(step) 936 rctx = base.contextForTransition(situ, frID, unTr) 937 sat = reqs.satisfied(rctx) 938 satState = 1 if sat else 2 939 reqStr = ( 940 f"\n requires {reqs.unparse()} (" 941 f"{'satisfied' if sat else 'not satisfied'})" 942 ) 943 # Options will be sorted by 'satisfied-state' then source 944 # ID, then description 945 options.append( 946 ( 947 satState, 948 frID, 949 f"take {unTr} at {graph.identityOf(frID)}{reqStr}" 950 ) 951 ) 952 # Print no-reqs first, then satisfied-reqs, then 953 # unsatisfied-reqs, then obviously-unsatisfiable 954 for _, __, desc in sorted(options): 955 print(desc) 956 elif cmd in ("x", "explorable"): 957 ctx = base.genericContextForSituation( 958 exploration.getSituation(step) 959 ) 960 unx = analysis.unexploredBranches(graph, ctx) 961 fin = ':' if len(unx) > 0 else '.' 962 print(f"{len(unx)} unexplored branch(es){fin}") 963 for frID, unTr in unx: 964 print(f"take {unTr} at {graph.identityOf(frID)}") 965 elif cmd in ("A", "all"): 966 print( 967 f"There are {len(graph)} decision(s) at step {step}:" 968 ) 969 for decision in graph.nodes(): 970 print(f" {graph.identityOf(decision)}") 971 elif cmd in ("M", "mechanisms"): 972 count = len(graph.mechanisms) 973 fin = ':' if count > 0 else '.' 974 print( 975 f"There are {count} mechanism(s) at step {step}{fin}" 976 ) 977 for mID in graph.mechanisms: 978 where, name = graph.mechanisms[mID] 979 state = exploration.mechanismState(mID, step=step) 980 if where is None: 981 print(f" {name!r} (global) in state {state!r}") 982 else: 983 info = graph.identityOf(where) 984 print(f" {name!r} at {info} in state {state!r}") 985 elif cmd in ("P", "path"): 986 stateNow = exploration.getSituation(step).state 987 invalid = False 988 try: 989 whereTo = graph.resolveDecision(args) 990 except Exception: 991 print("(invalid decision specifier)") 992 invalid = True 993 if not invalid: 994 pathThere = analysis.shortestStatePathToActivate( 995 graph, 996 stateNow, 997 whereTo, 998 10000 999 ) 1000 info = graph.identityOf(whereTo) 1001 if pathThere is None: 1002 print( 1003 f"No path to {info} from state at step {step}" 1004 f" (within 10,000 transitions)." 1005 ) 1006 else: 1007 print( 1008 f"Found a path to {info} with" 1009 f" {len(pathThere) - 1} steps:" 1010 ) 1011 print(base.statePathSummary(pathThere, graph)) 1012 elif cmd in ("r", "reachable"): 1013 print("TODO: Reachable does not work yet.") 1014 elif cmd in ("Z", "analyze"): 1015 argParts = args.split() 1016 if len(argParts) < 1: 1017 print( 1018 "'analyze' requires at least one argument: the analyzer" 1019 " to run" 1020 ) 1021 invalid = True 1022 else: 1023 if not applyAnalysisFunction( 1024 argParts[0].strip(), 1025 argParts[1:], 1026 exploration, 1027 step, 1028 here, 1029 pf 1030 ): 1031 invalid = True 1032 # No else needed 1033 else: 1034 invalid = True 1035 1036 if invalid: 1037 if invalidCommands >= 10: 1038 print("Too many invalid commands; exiting.") 1039 break 1040 else: 1041 if invalidCommands >= 8: 1042 print("{invalidCommands} invalid commands so far,") 1043 print("inspector will stop after 10 invalid commands...") 1044 print(f"Unknown command {cmd!r}...") 1045 invalidCommands += 1 1046 print(INSPECTOR_HELP) 1047 else: 1048 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.
1050def resolveAnalyzerStepArgument( 1051 arg: str, 1052 exploration: core.DiscreteExploration 1053) -> Optional[int]: 1054 """ 1055 Resolves an argument used to specify an exploration step, and 1056 returns the step number, or returns None if it can't. 1057 """ 1058 try: 1059 step = int(arg) 1060 except ValueError: 1061 return None 1062 try: 1063 situ = exploration.getSituation(step) 1064 except IndexError: 1065 return None 1066 return step
Resolves an argument used to specify an exploration step, and returns the step number, or returns None if it can't.
1069def resolveAnalyzerDecisionArgument( 1070 arg: str, 1071 exploration: core.DiscreteExploration, 1072 step: int, 1073 pf: parsing.ParseFormat 1074) -> Optional[base.DecisionID]: 1075 """ 1076 Resolves an argument used to specify a decision by parsing it as a 1077 `base.DecisionSpecifier` and then resolving that to an ID on the 1078 appropriate step of the given exploration. Returns `None` if it 1079 can't resolve the decision or the format isn't valid. 1080 """ 1081 graph = exploration.getSituation(step).graph 1082 try: 1083 return graph.resolveDecision(pf.parseDecisionSpecifier(arg)) 1084 except Exception: 1085 return None
Resolves an argument used to specify a decision by parsing it as a
base.DecisionSpecifier and then resolving that to an ID on the
appropriate step of the given exploration. Returns None if it
can't resolve the decision or the format isn't valid.
1087def applyAnalysisFunction( 1088 name: str, 1089 args: Sequence[str], 1090 exploration: core.DiscreteExploration, 1091 defaultStep: int, 1092 defaultDecision: Optional[base.DecisionID], 1093 pf: parsing.ParseFormat 1094) -> bool: 1095 """ 1096 Applies the analysis function with the given name using the given 1097 extra arguments to specify info it needs, with the given default 1098 step & decision values to fill in if they're needed and not 1099 specified. 1100 1101 A `parsing.ParseFormat` is needed to help parse arguments. 1102 1103 Prints the representation of the analysis result value. 1104 1105 Returns true if it succeeds and false if it encountered an error (in 1106 which case it will already have printed an error message). 1107 1108 TODO: Test this! 1109 """ 1110 analyzer = analysis.ALL_ANALYZERS.get(name) 1111 if analyzer is None: 1112 valid = '\n'.join( 1113 repr(k) for k in analysis.ALL_ANALYZERS.keys() 1114 ) 1115 print( 1116 f"Invalid analyzer name: {name!r}" 1117 f" Valid analyzers are:\n{valid}" 1118 ) 1119 return False 1120 else: 1121 step: Optional[int] 1122 decision: Optional[base.DecisionID] 1123 transition: Optional[str] = None 1124 1125 if analyzer._unit == "step": 1126 analyzer = cast(analysis.StepAnalyzer, analyzer) 1127 if len(args) == 0: 1128 result = analyzer(exploration, defaultStep) 1129 elif len(args) == 1: 1130 try: 1131 step = int(args[0]) 1132 except ValueError: 1133 print( 1134 f"Argument for step analyzer" 1135 f" {name!r} must be an" 1136 f" integer step number (got:" 1137 f" {args[0]!r})." 1138 ) 1139 return False 1140 result = analyzer(exploration, step) 1141 else: 1142 print(f"Step analyzer {name!r} must have 0 or 1 arguments.") 1143 return False 1144 elif analyzer._unit == "stepDecision": 1145 analyzer = cast(analysis.StepDecisionAnalyzer, analyzer) 1146 step = defaultStep 1147 decision = defaultDecision 1148 if len(args) == 1: 1149 specStep = resolveAnalyzerStepArgument(args[0], exploration) 1150 if specStep is not None: 1151 step = specStep 1152 else: 1153 decision = resolveAnalyzerDecisionArgument( 1154 args[0], 1155 exploration, 1156 step, 1157 pf 1158 ) 1159 if decision is None: 1160 print( 1161 f"Unable to resolve decision argument" 1162 f" {args[0]!r} on step {step}." 1163 ) 1164 return False 1165 elif len(args) == 2: 1166 step = resolveAnalyzerStepArgument(args[0], exploration) 1167 if step is None: 1168 print(f"Unable to parse step value {args[0]!r}.") 1169 return False 1170 decision = resolveAnalyzerDecisionArgument( 1171 args[1], 1172 exploration, 1173 step, 1174 pf 1175 ) 1176 if decision is None: 1177 print( 1178 f"Unable to resolve decision argument" 1179 f" {args[1]!r} on step {step}." 1180 ) 1181 return False 1182 elif len(args) != 0: 1183 print( 1184 f"Step-decision analyzer {name!r} must have 0-2" 1185 f" arguments." 1186 ) 1187 return False 1188 1189 if decision is None: 1190 print( 1191 f"No current decision and no decision specified for" 1192 f" step-decision analyzer {name!r}." 1193 ) 1194 return False 1195 result = analyzer(exploration, step, decision) 1196 1197 elif analyzer._unit == "stepTransition": 1198 analyzer = cast(analysis.StepTransitionAnalyzer, analyzer) 1199 step = defaultStep 1200 decision = defaultDecision 1201 if len(args) == 1: 1202 transition = args[0] 1203 elif len(args) == 2: 1204 transition = args[1] 1205 decision = resolveAnalyzerDecisionArgument( 1206 args[0], 1207 exploration, 1208 step, 1209 pf 1210 ) 1211 if decision is None: 1212 print( 1213 f"Unable to parse and resolve argument" 1214 f" {args[0]!r} as a decision for" 1215 f" step-transition analyzer {name!r}." 1216 ) 1217 return False 1218 elif len(args) == 3: 1219 transition = args[2] 1220 step = resolveAnalyzerStepArgument(args[0], exploration) 1221 if step is None: 1222 print( 1223 f"Unable to parse argument {args[0]!r} as a" 1224 f" step number for step-transition analyzer" 1225 f" {name!r}." 1226 ) 1227 return False 1228 decision = resolveAnalyzerDecisionArgument( 1229 args[1], 1230 exploration, 1231 step, 1232 pf 1233 ) 1234 if decision is None: 1235 print( 1236 f"Unable to parse and resolve argument" 1237 f" {args[1]!r} as a decision for" 1238 f" step-transition analyzer {name!r}." 1239 ) 1240 return False 1241 else: 1242 print( 1243 f"Step-transition analyzer {name!r} must have 1-3" 1244 f" arguments." 1245 ) 1246 return False 1247 1248 if decision is None: 1249 print( 1250 f"No current decision and no decision specified for" 1251 f" step-transition analyzer {name!r}." 1252 ) 1253 return False 1254 if transition is None: 1255 print( 1256 f"No transition specified for step-transition" 1257 f" analyzer {name!r}." 1258 ) 1259 return False 1260 1261 destination = exploration.getSituation(step).graph.destination( 1262 decision, 1263 transition 1264 ) 1265 result = analyzer( 1266 exploration, 1267 step, 1268 decision, 1269 transition, 1270 destination 1271 ) 1272 1273 elif analyzer._unit == "decision": 1274 analyzer = cast(analysis.DecisionAnalyzer, analyzer) 1275 decision = defaultDecision 1276 if len(args) == 1: 1277 decision = resolveAnalyzerDecisionArgument( 1278 args[0], 1279 exploration, 1280 defaultStep, 1281 pf 1282 ) 1283 if decision is None: 1284 print( 1285 f"Unable to parse and resolve argument" 1286 f" {args[0]!r} as a decision for decision" 1287 f" analyzer {name!r}." 1288 ) 1289 return False 1290 elif len(args) != 0: 1291 print(f"Decision analyzer {name!r} must have 1-3 arguments.") 1292 return False 1293 1294 if decision is None: 1295 print( 1296 f"No current decision and no decision specified for" 1297 f" decision analyzer {name!r}." 1298 ) 1299 return False 1300 1301 result = analyzer(exploration, decision) 1302 1303 elif analyzer._unit == "transition": 1304 analyzer = cast(analysis.TransitionAnalyzer, analyzer) 1305 decision = defaultDecision 1306 if len(args) == 1: 1307 transition = args[0] 1308 elif len(args) == 2: 1309 transition = args[1] 1310 decision = resolveAnalyzerDecisionArgument( 1311 args[0], 1312 exploration, 1313 defaultStep, 1314 pf 1315 ) 1316 if decision is None: 1317 print( 1318 f"Unable to parse and resolve argument" 1319 f" {args[0]!r} as a decision for" 1320 f" transition analyzer {name!r}." 1321 ) 1322 return False 1323 else: 1324 print( 1325 f"Transition analyzer {name!r} must have 1-2 arguments." 1326 ) 1327 return False 1328 1329 if decision is None: 1330 print( 1331 f"No current decision and no decision specified for" 1332 f" transition analyzer {name!r}." 1333 ) 1334 return False 1335 if transition is None: 1336 print( 1337 f"No transition specified for transition analyzer" 1338 f" {name!r}." 1339 ) 1340 return False 1341 1342 graph = exploration.getSituation(defaultStep).graph 1343 destination = graph.destination(decision, transition) 1344 result = analyzer( 1345 exploration, 1346 decision, 1347 transition, 1348 destination 1349 ) 1350 1351 elif analyzer._unit == "exploration": 1352 analyzer = cast(analysis.ExplorationAnalyzer, analyzer) 1353 if len(args) != 0: 1354 print( 1355 f"Exploration analyzer {name!r} must have 0 arguments." 1356 ) 1357 return False 1358 1359 result = analyzer(exploration) 1360 1361 else: 1362 raise ValueError( 1363 f"Unrecognized analysis unit {analyzer._unit!r}." 1364 ) 1365 1366 # Finally print our result! 1367 print(repr(result)) 1368 return True
Applies the analysis function with the given name using the given extra arguments to specify info it needs, with the given default step & decision values to fill in if they're needed and not specified.
A parsing.ParseFormat is needed to help parse arguments.
Prints the representation of the analysis result value.
Returns true if it succeeds and false if it encountered an error (in which case it will already have printed an error message).
TODO: Test this!
1560def main(): 1561 """ 1562 Parse options from command line & run appropriate tool. 1563 """ 1564 options = parser.parse_args() 1565 if not hasattr(options, "run"): 1566 print("No sub-command specified.") 1567 parser.print_help() 1568 exit(1) 1569 elif options.run == "check": 1570 check( 1571 options.source, 1572 formatOverride=options.format, 1573 interactive=options.interactive 1574 ) 1575 elif options.run == "show": 1576 show( 1577 options.source, 1578 formatOverride=options.format, 1579 step=options.step 1580 ) 1581 elif options.run == "analyze": 1582 analyze( 1583 options.source, 1584 destination=options.destination, 1585 formatOverride=options.format, 1586 includeAll=options.all, 1587 profile=options.profile 1588 ) 1589 elif options.run == "convert": 1590 convert( 1591 options.source, 1592 options.destination, 1593 inputFormatOverride=options.format, 1594 outputFormatOverride=options.output_format, 1595 step=options.step 1596 ) 1597 elif options.run == "inspect": 1598 inspect( 1599 options.source, 1600 formatOverride=options.format 1601 ) 1602 else: 1603 raise RuntimeError( 1604 f"Invalid 'run' default value: '{options.run}'." 1605 )
Parse options from command line & run appropriate tool.