exploration.main

  • Authors: Peter Mawhorter
  • Consulted:
  • Date: 2022-10-15
  • Purpose: Main API entry points to support the __main__.py script.
   1"""
   2- Authors: Peter Mawhorter
   3- Consulted:
   4- Date: 2022-10-15
   5- Purpose: Main API entry points to support the `__main__.py` script.
   6"""
   7
   8from __future__ import annotations
   9
  10import argparse
  11import pathlib
  12import textwrap
  13import sys
  14import csv
  15import json
  16import time
  17
  18# Resource module not available in Pyodide
  19try:
  20    import resource
  21except Exception:
  22    resource = None  # type: ignore
  23
  24import networkx as nx  # type: ignore
  25
  26from typing import (
  27    Literal, Optional, Union, get_args, TypeAlias, List, Callable, Dict,
  28    Sequence, Any, cast, Tuple, Set, Collection
  29)
  30
  31from . import journal
  32from . import core
  33from . import base
  34from . import analysis
  35from . import parsing
  36
  37
  38#------------#
  39# File input #
  40#------------#
  41
  42SourceType: TypeAlias = Literal[
  43    "graph",
  44    "dot",
  45    "exploration",
  46    "journal",
  47]
  48"""
  49The file types we recognize.
  50"""
  51
  52
  53def determineFileType(filename: str) -> SourceType:
  54    if filename.endswith('.dcg'):
  55        return 'graph'
  56    elif filename.endswith('.dot'):
  57        return 'dot'
  58    elif filename.endswith('.exp'):
  59        return 'exploration'
  60    elif filename.endswith('.exj'):
  61        return 'journal'
  62    else:
  63        raise ValueError(
  64            f"Could not determine the file type of file '{filename}':"
  65            f" it does not end with '.dcg', '.dot', '.exp', or '.exj'."
  66        )
  67
  68
  69def loadDecisionGraph(path: pathlib.Path) -> core.DecisionGraph:
  70    """
  71    Loads a JSON-encoded decision graph from a file. The extension
  72    should normally be '.dcg'.
  73    """
  74    with path.open('r', encoding='utf-8-sig') as fInput:
  75        return parsing.loadCustom(fInput, core.DecisionGraph)
  76
  77
  78def saveDecisionGraph(
  79    path: pathlib.Path,
  80    graph: core.DecisionGraph
  81) -> None:
  82    """
  83    Saves a decision graph encoded as JSON in the specified file. The
  84    file should normally have a '.dcg' extension.
  85    """
  86    with path.open('w', encoding='utf-8') as fOutput:
  87        parsing.saveCustom(graph, fOutput)
  88
  89
  90def loadDotFile(path: pathlib.Path) -> core.DecisionGraph:
  91    """
  92    Loads a `core.DecisionGraph` form the file at the specified path
  93    (whose extension should normally be '.dot'). The file format is the
  94    GraphViz "dot" format.
  95    """
  96    with path.open('r', encoding='utf-8-sig') as fInput:
  97        dot = fInput.read()
  98        try:
  99            return parsing.parseDot(dot)
 100        except parsing.DotParseError:
 101            raise parsing.DotParseError(
 102                "Failed to parse Dot file contents:\n\n"
 103              + dot
 104              + "\n\n(See error above for specific parsing issue.)"
 105            )
 106
 107
 108def saveDotFile(path: pathlib.Path, graph: core.DecisionGraph) -> None:
 109    """
 110    Saves a `core.DecisionGraph` as a GraphViz "dot" file. The file
 111    extension should normally be ".dot".
 112    """
 113    dotStr = parsing.toDot(graph, clusterLevels=[])
 114    with path.open('w', encoding='utf-8') as fOutput:
 115        fOutput.write(dotStr)
 116
 117
 118def loadExploration(path: pathlib.Path) -> core.DiscreteExploration:
 119    """
 120    Loads a JSON-encoded `core.DiscreteExploration` object from the file
 121    at the specified path. The extension should normally be '.exp'.
 122    """
 123    with path.open('r', encoding='utf-8-sig') as fInput:
 124        return parsing.loadCustom(fInput, core.DiscreteExploration)
 125
 126
 127def saveExploration(
 128    path: pathlib.Path,
 129    exploration: core.DiscreteExploration
 130) -> None:
 131    """
 132    Saves a `core.DiscreteExploration` object as JSON in the specified
 133    file. The file extension should normally be '.exp'.
 134    """
 135    with path.open('w', encoding='utf-8') as fOutput:
 136        parsing.saveCustom(exploration, fOutput)
 137
 138
 139def loadJournal(
 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()
SourceType: TypeAlias = Literal['graph', 'dot', 'exploration', 'journal']

The file types we recognize.

def determineFileType(filename: str) -> Literal['graph', 'dot', 'exploration', 'journal']:
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        )
def loadDecisionGraph(path: pathlib.Path) -> exploration.core.DecisionGraph:
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'.

def saveDecisionGraph(path: pathlib.Path, graph: exploration.core.DecisionGraph) -> None:
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.

def loadDotFile(path: pathlib.Path) -> exploration.core.DecisionGraph:
 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.

def saveDotFile(path: pathlib.Path, graph: exploration.core.DecisionGraph) -> None:
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".

def loadExploration(path: pathlib.Path) -> exploration.core.DiscreteExploration:
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'.

def saveExploration( path: pathlib.Path, exploration: exploration.core.DiscreteExploration) -> None:
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'.

def loadJournal( path: pathlib.Path, interactive: bool = False) -> exploration.core.DiscreteExploration:
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.

def saveAsJournal( path: pathlib.Path, exploration: exploration.core.DiscreteExploration) -> None:
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?!

def loadSource( path: pathlib.Path, formatOverride: Optional[Literal['graph', 'dot', 'exploration', 'journal']] = None, interactive: bool = False) -> Union[exploration.core.DecisionGraph, exploration.core.DiscreteExploration]:
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.

CSVEmbeddable: TypeAlias = Union[NoneType, bool, str, int, float, complex]

A type alias for values we're willing to store in a CSV file without coercing them to a string.

def coerceToCSVValue(result: Any) -> Union[NoneType, bool, str, int, float, complex]:
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.

def check( source: pathlib.Path, formatOverride: Optional[Literal['graph', 'dot', 'exploration', 'journal']] = None, interactive: bool = False) -> None:
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).

def show( source: pathlib.Path, formatOverride: Optional[Literal['graph', 'dot', 'exploration', 'journal']] = None, step: int = -1) -> None:
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.

def transitionStr( exploration: exploration.core.DiscreteExploration, src: int, transition: str, dst: int) -> str:
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.

def printPerf(analyzerName: str) -> None:
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.

def printMem() -> None:
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.

def analyze( source: pathlib.Path, destination: Optional[pathlib.Path] = None, formatOverride: Optional[Literal['graph', 'dot', 'exploration', 'journal']] = None, applyTools: Optional[Collection[str]] = None, finalOnly: Optional[Collection[str]] = None, includeAll: bool = False, profile: bool = False) -> None:
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.

def convert( source: pathlib.Path, destination: pathlib.Path, inputFormatOverride: Optional[Literal['graph', 'dot', 'exploration', 'journal']] = None, outputFormatOverride: Optional[Literal['graph', 'dot', 'exploration', 'journal']] = None, step: int = -1) -> None:
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.DecisionGraph stored in JSON format.
  • '.dot' A core.DecisionGraph stored as a GraphViz DOT file.
  • '.exp' A core.DiscreteExploration stored in JSON format.
  • '.exj' A core.DiscreteExploration stored as a journal (see journal.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.

INSPECTOR_HELP = "\nAvailable commands:\n\n- 'help' or '?': List commands.\n- 'done', 'quit', 'q', or 'exit': Quit the inspector.\n- 'f' or 'follow': Follow the primary decision when changing steps. Also\n changes to that decision immediately. Toggles off if on.\n- 'cd' or 'goto': Change focus decision to the named decision. Cancels\n follow mode.\n- 'ls' or 'list' or 'destinations': Lists transitions at this decision\n and their destinations, as well as any mechanisms at this decision.\n- 'lst' or 'steps': Lists each step of the exploration along with the\n primary decision at each step.\n- 'st' or 'step': Switches to the specified step (an index)\n- 'n' or 'next': Switches to the next step.\n- 'p' or 'prev' or 'previous': Switches to the previous step.\n- 't' or 'take': Change focus decision to the decision which is the\n destination of the specified transition at the current focused\n decision.\n- 'prm' or 'primary': Displays the current primary decision.\n- 'a' or 'active': Lists all currently active decisions\n- 'u' or 'unexplored': Lists all unexplored transitions at the current\n step.\n- 'x' or 'explorable': Lists all unexplored transitions at the current\n step which are traversable based on the current state. (TODO:\n make this more accurate).\n- 'A' or 'all': Lists all decisions at the current step.\n- 'M' or 'mechanisms': Lists all mechanisms at the current step.\n- 'P' or 'path': Displays the path to get from the current step's state\n to a state that activates the specified decision.\n- 'r' or 'reachable': TODO\n"
def inspect( source: pathlib.Path, formatOverride: Optional[Literal['graph', 'dot', 'exploration', 'journal']] = None) -> None:
 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.

def resolveAnalyzerStepArgument( arg: str, exploration: exploration.core.DiscreteExploration) -> Optional[int]:
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.

def resolveAnalyzerDecisionArgument( arg: str, exploration: exploration.core.DiscreteExploration, step: int, pf: exploration.parsing.ParseFormat) -> Optional[int]:
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.

def applyAnalysisFunction( name: str, args: Sequence[str], exploration: exploration.core.DiscreteExploration, defaultStep: int, defaultDecision: Optional[int], pf: exploration.parsing.ParseFormat) -> bool:
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!

parser = ArgumentParser(prog='python -m exploration', usage=None, description='Runs various commands for processing exploration graphs and journals,\nand for converting between them or displaying them in various formats.\n', formatter_class=<class 'argparse.HelpFormatter'>, conflict_handler='error', add_help=True)
subparsers = _SubParsersAction(option_strings=[], dest='==SUPPRESS==', nargs='A...', const=None, default=None, type=None, choices={'check': ArgumentParser(prog='python -m exploration check', usage=None, description="Shows the graph or exploration stored in the `source` file. You will\nneed to have the `matplotlib` library installed. Consider using the\ninteractive interface provided by the `explorationViewer` module\ninstead. The file extension is used to determine how to load the data,\nalthough the `--format` option may override this. '.dcg' files are\nassumed to be decision graphs in JSON format, '.exp' files are assumed\nto be exploration objects in JSON format, and '.exj' files are assumed\nto be exploration journals in the default journal format. If the object\nthat gets loaded is an exploration, the final graph for that\nexploration will be displayed, or a specific graph may be selected\nusing `--step`.", formatter_class=<class 'argparse.HelpFormatter'>, conflict_handler='error', add_help=True), 'show': ArgumentParser(prog='python -m exploration show', usage=None, description="Shows the graph or exploration stored in the `source` file. You will\nneed to have the `matplotlib` library installed. Consider using the\ninteractive interface provided by the `explorationViewer` module\ninstead. The file extension is used to determine how to load the data,\nalthough the `--format` option may override this. '.dcg' files are\nassumed to be decision graphs in JSON format, '.exp' files are assumed\nto be exploration objects in JSON format, and '.exj' files are assumed\nto be exploration journals in the default journal format. If the object\nthat gets loaded is an exploration, the final graph for that\nexploration will be displayed, or a specific graph may be selected\nusing `--step`.", formatter_class=<class 'argparse.HelpFormatter'>, conflict_handler='error', add_help=True), 'analyze': ArgumentParser(prog='python -m exploration analyze', usage=None, description="Analyzes the exploration stored in the `source` file. The file\nextension is used to determine how to load the data, although this\nmay be overridden by the `--format` option. Normally, '.exp' files\nare treated as JSON-encoded exploration objects, while '.exj' files\nare treated as journals using the default journal format.\n\nThis applies a number of analysis functions to produce a CSV file\nshowing per-decision-per-step, per-decision, per-step, and\nper-exploration metrics. A subset of the available metrics may be\nselected by passing a list of strings for the `applyTools` argument.\nThese strings should be the names of functions in `analysis.py` that\nare decorated with `analysis.analyze`. By default, only those not\nmarked with `analysis.elide` will be included. You can set\n`includeAll` to `True` to include all tools, although this is ignored\nwhen `applyTools` is not `None`. `finalOnly` specifies one or more\ntools to only run on the final step of the exploration rather than\nevery step. This only applies to tools whose unit of analysis is\n'step', 'stepDecision', or 'stepTransition'. By default those marked\nas `finalOnly` in `analysis.py` will be run this way. Tools excluded\nvia `applyTools` or by default when `includeAll` is false won't be\nrun even if specified in `finalOnly`. Set `finalOnly` to `False` to\nrun all selected tools on all steps without having to explicitly\nlist the tools that would otherwise be restricted by default.\n\nSet `profile` to `True` to gather and report analysis time spent\nresults (they'll be printed to stdout).\n\nIf no output file is specified, the output will be printed out.", formatter_class=<class 'argparse.HelpFormatter'>, conflict_handler='error', add_help=True), 'convert': ArgumentParser(prog='python -m exploration convert', usage=None, description="Converts between exploration and graph formats. By default, formats\nare determined by file extensions, but using the `--format` and\n`--output-format` options can override this. The available formats\nare:\n\n- '.dcg' A `core.DecisionGraph` stored in JSON format.\n- '.dot' A `core.DecisionGraph` stored as a GraphViz DOT file.\n- '.exp' A `core.DiscreteExploration` stored in JSON format.\n- '.exj' A `core.DiscreteExploration` stored as a journal (see\n `journal.JournalObserver`; TODO: writing this format).\n\nWhen converting a decision graph into an exploration format, the\nresulting exploration will have a single starting step containing\nthe entire specified graph. When converting an exploration into a\ndecision graph format, only the current graph will be saved, unless\n`--step` is used to specify a different step index to save.", formatter_class=<class 'argparse.HelpFormatter'>, conflict_handler='error', add_help=True), 'inspect': ArgumentParser(prog='python -m exploration inspect', usage=None, description="Inspects the graph or exploration stored in the `source` file,\nlaunching an interactive command line for inspecting properties of\ndecisions, transitions, and situations. The file extension is used\nto determine how to load the data, although the `--format` option\nmay override this. '.dcg' files are assumed to be decision graphs in\nJSON format, '.exp' files are assumed to be exploration objects in\nJSON format, and '.exj' files are assumed to be exploration journals\nin the default journal format. If the object that gets loaded is a\ngraph, a 1-step exploration containing just that graph will be\ncreated to inspect. Inspector commands are listed in the\n`INSPECTOR_HELP` variable.", formatter_class=<class 'argparse.HelpFormatter'>, conflict_handler='error', add_help=True)}, required=False, help='use these with -h/--help for more details', metavar=None)
checkParser = ArgumentParser(prog='python -m exploration check', usage=None, description="Shows the graph or exploration stored in the `source` file. You will\nneed to have the `matplotlib` library installed. Consider using the\ninteractive interface provided by the `explorationViewer` module\ninstead. The file extension is used to determine how to load the data,\nalthough the `--format` option may override this. '.dcg' files are\nassumed to be decision graphs in JSON format, '.exp' files are assumed\nto be exploration objects in JSON format, and '.exj' files are assumed\nto be exploration journals in the default journal format. If the object\nthat gets loaded is an exploration, the final graph for that\nexploration will be displayed, or a specific graph may be selected\nusing `--step`.", formatter_class=<class 'argparse.HelpFormatter'>, conflict_handler='error', add_help=True)
showParser = ArgumentParser(prog='python -m exploration show', usage=None, description="Shows the graph or exploration stored in the `source` file. You will\nneed to have the `matplotlib` library installed. Consider using the\ninteractive interface provided by the `explorationViewer` module\ninstead. The file extension is used to determine how to load the data,\nalthough the `--format` option may override this. '.dcg' files are\nassumed to be decision graphs in JSON format, '.exp' files are assumed\nto be exploration objects in JSON format, and '.exj' files are assumed\nto be exploration journals in the default journal format. If the object\nthat gets loaded is an exploration, the final graph for that\nexploration will be displayed, or a specific graph may be selected\nusing `--step`.", formatter_class=<class 'argparse.HelpFormatter'>, conflict_handler='error', add_help=True)
analyzeParser = ArgumentParser(prog='python -m exploration analyze', usage=None, description="Analyzes the exploration stored in the `source` file. The file\nextension is used to determine how to load the data, although this\nmay be overridden by the `--format` option. Normally, '.exp' files\nare treated as JSON-encoded exploration objects, while '.exj' files\nare treated as journals using the default journal format.\n\nThis applies a number of analysis functions to produce a CSV file\nshowing per-decision-per-step, per-decision, per-step, and\nper-exploration metrics. A subset of the available metrics may be\nselected by passing a list of strings for the `applyTools` argument.\nThese strings should be the names of functions in `analysis.py` that\nare decorated with `analysis.analyze`. By default, only those not\nmarked with `analysis.elide` will be included. You can set\n`includeAll` to `True` to include all tools, although this is ignored\nwhen `applyTools` is not `None`. `finalOnly` specifies one or more\ntools to only run on the final step of the exploration rather than\nevery step. This only applies to tools whose unit of analysis is\n'step', 'stepDecision', or 'stepTransition'. By default those marked\nas `finalOnly` in `analysis.py` will be run this way. Tools excluded\nvia `applyTools` or by default when `includeAll` is false won't be\nrun even if specified in `finalOnly`. Set `finalOnly` to `False` to\nrun all selected tools on all steps without having to explicitly\nlist the tools that would otherwise be restricted by default.\n\nSet `profile` to `True` to gather and report analysis time spent\nresults (they'll be printed to stdout).\n\nIf no output file is specified, the output will be printed out.", formatter_class=<class 'argparse.HelpFormatter'>, conflict_handler='error', add_help=True)
convertParser = ArgumentParser(prog='python -m exploration convert', usage=None, description="Converts between exploration and graph formats. By default, formats\nare determined by file extensions, but using the `--format` and\n`--output-format` options can override this. The available formats\nare:\n\n- '.dcg' A `core.DecisionGraph` stored in JSON format.\n- '.dot' A `core.DecisionGraph` stored as a GraphViz DOT file.\n- '.exp' A `core.DiscreteExploration` stored in JSON format.\n- '.exj' A `core.DiscreteExploration` stored as a journal (see\n `journal.JournalObserver`; TODO: writing this format).\n\nWhen converting a decision graph into an exploration format, the\nresulting exploration will have a single starting step containing\nthe entire specified graph. When converting an exploration into a\ndecision graph format, only the current graph will be saved, unless\n`--step` is used to specify a different step index to save.", formatter_class=<class 'argparse.HelpFormatter'>, conflict_handler='error', add_help=True)
inspectParser = ArgumentParser(prog='python -m exploration inspect', usage=None, description="Inspects the graph or exploration stored in the `source` file,\nlaunching an interactive command line for inspecting properties of\ndecisions, transitions, and situations. The file extension is used\nto determine how to load the data, although the `--format` option\nmay override this. '.dcg' files are assumed to be decision graphs in\nJSON format, '.exp' files are assumed to be exploration objects in\nJSON format, and '.exj' files are assumed to be exploration journals\nin the default journal format. If the object that gets loaded is a\ngraph, a 1-step exploration containing just that graph will be\ncreated to inspect. Inspector commands are listed in the\n`INSPECTOR_HELP` variable.", formatter_class=<class 'argparse.HelpFormatter'>, conflict_handler='error', add_help=True)
def main():
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.