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 networkx as nx  # type: ignore
 16
 17from typing import (
 18    Literal, Optional, Union, get_args, TypeAlias, List, Callable, Dict,
 19    Sequence
 20)
 21
 22from . import journal
 23from . import core
 24from . import base
 25from . import analysis
 26from . import parsing
 27
 28
 29#------------#
 30# File input #
 31#------------#
 32
 33SourceType: TypeAlias = Literal[
 34    "graph",
 35    "dot",
 36    "exploration",
 37    "journal",
 38]
 39"""
 40The file types we recognize.
 41"""
 42
 43
 44def determineFileType(filename: str) -> SourceType:
 45    if filename.endswith('.dcg'):
 46        return 'graph'
 47    elif filename.endswith('.dot'):
 48        return 'dot'
 49    elif filename.endswith('.exp'):
 50        return 'exploration'
 51    elif filename.endswith('.exj'):
 52        return 'journal'
 53    else:
 54        raise ValueError(
 55            f"Could not determine the file type of file '{filename}':"
 56            f" it does not end with '.dcg', '.dot', '.exp', or '.exj'."
 57        )
 58
 59
 60def loadDecisionGraph(path: pathlib.Path) -> core.DecisionGraph:
 61    """
 62    Loads a JSON-encoded decision graph from a file. The extension
 63    should normally be '.dcg'.
 64    """
 65    with path.open('r', encoding='utf-8-sig') as fInput:
 66        return parsing.loadCustom(fInput, core.DecisionGraph)
 67
 68
 69def saveDecisionGraph(
 70    path: pathlib.Path,
 71    graph: core.DecisionGraph
 72) -> None:
 73    """
 74    Saves a decision graph encoded as JSON in the specified file. The
 75    file should normally have a '.dcg' extension.
 76    """
 77    with path.open('w', encoding='utf-8') as fOutput:
 78        parsing.saveCustom(graph, fOutput)
 79
 80
 81def loadDotFile(path: pathlib.Path) -> core.DecisionGraph:
 82    """
 83    Loads a `core.DecisionGraph` form the file at the specified path
 84    (whose extension should normally be '.dot'). The file format is the
 85    GraphViz "dot" format.
 86    """
 87    with path.open('r', encoding='utf-8-sig') as fInput:
 88        dot = fInput.read()
 89        try:
 90            return parsing.parseDot(dot)
 91        except parsing.DotParseError:
 92            raise parsing.DotParseError(
 93                "Failed to parse Dot file contents:\n\n"
 94              + dot
 95              + "\n\n(See error above for specific parsing issue.)"
 96            )
 97
 98
 99def saveDotFile(path: pathlib.Path, graph: core.DecisionGraph) -> None:
100    """
101    Saves a `core.DecisionGraph` as a GraphViz "dot" file. The file
102    extension should normally be ".dot".
103    """
104    dotStr = parsing.toDot(graph, clusterLevels=[])
105    with path.open('w', encoding='utf-8') as fOutput:
106        fOutput.write(dotStr)
107
108
109def loadExploration(path: pathlib.Path) -> core.DiscreteExploration:
110    """
111    Loads a JSON-encoded `core.DiscreteExploration` object from the file
112    at the specified path. The extension should normally be '.exp'.
113    """
114    with path.open('r', encoding='utf-8-sig') as fInput:
115        return parsing.loadCustom(fInput, core.DiscreteExploration)
116
117
118def saveExploration(
119    path: pathlib.Path,
120    exploration: core.DiscreteExploration
121) -> None:
122    """
123    Saves a `core.DiscreteExploration` object as JSON in the specified
124    file. The file extension should normally be '.exp'.
125    """
126    with path.open('w', encoding='utf-8') as fOutput:
127        parsing.saveCustom(exploration, fOutput)
128
129
130def loadJournal(path: pathlib.Path) -> core.DiscreteExploration:
131    """
132    Loads a `core.DiscreteExploration` object from a journal file
133    (extension should normally be '.exj'). Uses the
134    `journal.convertJournal` function.
135    """
136    with path.open('r', encoding='utf-8-sig') as fInput:
137        return journal.convertJournal(fInput.read())
138
139
140def saveAsJournal(
141    path: pathlib.Path,
142    exploration: core.DiscreteExploration
143) -> None:
144    """
145    Saves a `core.DiscreteExploration` object as a text journal in the
146    specified file. The file extension should normally be '.exj'.
147
148    TODO: This?!
149    """
150    raise NotImplementedError(
151        "DiscreteExploration-to-journal conversion is not implemented"
152        " yet."
153    )
154
155
156def loadSource(
157    path: pathlib.Path,
158    formatOverride: Optional[SourceType] = None
159) -> Union[core.DecisionGraph, core.DiscreteExploration]:
160    """
161    Loads either a `core.DecisionGraph` or a `core.DiscreteExploration`
162    from the specified file, depending on its file extension (or the
163    specified format given as `formatOverride` if there is one).
164    """
165    if formatOverride is not None:
166        format = formatOverride
167    else:
168        format = determineFileType(str(path))
169
170    if format == "graph":
171        return loadDecisionGraph(path)
172    if format == "dot":
173        return loadDotFile(path)
174    elif format == "exploration":
175        return loadExploration(path)
176    elif format == "journal":
177        return loadJournal(path)
178    else:
179        raise ValueError(
180            f"Unrecognized file format '{format}' (recognized formats"
181            f" are 'graph', 'exploration', and 'journal')."
182        )
183
184
185#---------------------#
186# Analysis tool lists #
187#---------------------#
188
189AnalysisResult: TypeAlias = Union[None, bool, str, int, float, complex]
190"""
191A type alias for values we're willing to accept as analysis results.
192These are going to be written to a CSV file that we want to be
193human-readable.
194"""
195
196STEPWISE_DECISION_ANALYSIS_TOOLS: Dict[
197    str,
198    Callable[[base.Situation, base.DecisionID], AnalysisResult]
199] = {
200    "actionCount": analysis.analyzeGraph(analysis.countActionsAtDecision),
201    "branchCount": analysis.analyzeGraph(analysis.countBranches)
202}
203"""
204The analysis functions to apply to each decision in each step when
205analyzing an exploration, and the names for each.
206"""
207
208STEP_ANALYSIS_TOOLS: Dict[
209    str,
210    Callable[[base.Situation], AnalysisResult]
211] = {
212    "currentDecision": analysis.currentDecision,
213    "unexploredCount": analysis.countAllUnexploredBranches,
214    "traversableUnexploredCount": analysis.countTraversableUnexploredBranches,
215    "meanActions": analysis.meanOfResults(
216        analysis.perDecision(
217            analysis.analyzeGraph(analysis.countActionsAtDecision)
218        )
219    ),
220    "meanBranches": analysis.meanOfResults(
221        analysis.perDecision(
222            analysis.analyzeGraph(analysis.countBranches)
223        )
224    ),
225}
226"""
227The analysis functions to apply to each step when analyzing an
228exploration, and the names for each.
229"""
230
231DECISION_ANALYSIS_TOOLS: Dict[
232    str,
233    Callable[
234        [core.DiscreteExploration, base.DecisionID],
235        AnalysisResult
236    ]
237] = {
238    "identity": analysis.lastIdentity,
239    "isVisited": lambda e, d: d in e.allVisitedDecisions(),
240    "revisitCount": analysis.countRevisits,
241}
242"""
243The analysis functions to apply once to each decision in an exploration,
244and the names for each.
245"""
246
247WHOLE_ANALYSIS_TOOLS: Dict[
248    str,
249    Callable[[core.DiscreteExploration], AnalysisResult]
250] = {
251    "stepCount": lambda e: len(e),
252    "finalDecisionCount": lambda e: len(e.getSituation().graph),
253    "meanRevisits": analysis.meanOfResults(
254        analysis.perExplorationDecision(
255            analysis.countRevisits,
256            mode="visited"
257        )
258    )
259}
260"""
261The analysis functions to apply to entire explorations, and the names
262for each.
263"""
264
265
266#---------------#
267# API Functions #
268#---------------#
269
270def show(
271    source: pathlib.Path,
272    formatOverride: Optional[SourceType] = None,
273    step: int = -1
274) -> None:
275    """
276    Shows the graph or exploration stored in the `source` file. You will
277    need to have the `matplotlib` library installed. Consider using the
278    interactive interface provided by the `explorationViewer` module
279    instead. The file extension is used to determine how to load the data,
280    although the `--format` option may override this. '.dcg' files are
281    assumed to be decision graphs in JSON format, '.exp' files are assumed
282    to be exploration objects in JSON format, and '.exj' files are assumed
283    to be exploration journals in the default journal format. If the object
284    that gets loaded is an exploration, the final graph for that
285    exploration will be displayed, or a specific graph may be selected
286    using `--step`.
287    """
288    obj = loadSource(source, formatOverride)
289    if isinstance(obj, core.DiscreteExploration):
290        obj = obj.getSituation(step).graph
291
292    import matplotlib.pyplot # type: ignore
293
294    # This draws the graph in a new window that pops up. You have to close
295    # the window to end the program.
296    nx.draw(obj)
297    matplotlib.pyplot.show()
298
299
300def analyze(
301    source: pathlib.Path,
302    formatOverride: Optional[SourceType] = None,
303    destination: Optional[pathlib.Path] = None,
304    applyTools: Optional[List[str]] = None
305) -> None:
306    """
307    Analyzes the exploration stored in the `source` file. The file
308    extension is used to determine how to load the data, although this
309    may be overridden by the `--format` option. Normally, '.exp' files
310    are treated as JSON-encoded exploration objects, while '.exj' files
311    are treated as journals using the default journal format.
312
313    This applies a number of analysis functions to produce a CSV file
314    showing per-decision-per-step, per-decision, per-step, and
315    per-exploration metrics. A subset of the available metrics may be
316    selected by passing a list of strings for the `applyTools` argument.
317    See the `STEPWISE_DECISION_ANALYSIS_TOOLS`, `STEP_ANALYSIS_TOOLS`,
318    `DECISION_ANALYSIS_TOOLS`, and `WHOLE_ANALYSIS_TOOLS` dictionaries
319    for tool names.
320
321    If no output file is specified, the output will be printed out.
322    """
323    # Load our source exploration object:
324    obj = loadSource(source, formatOverride)
325    if isinstance(obj, core.DecisionGraph):
326        obj = core.DiscreteExploration.fromGraph(obj)
327
328    exploration: core.DiscreteExploration = obj
329
330    # Apply all of the analysis functions (or only just those that are
331    # selected using applyTools):
332
333    wholeRows: List[List[AnalysisResult]] = [['Whole exploration metrics:']]
334    # One row per tool
335    for tool in WHOLE_ANALYSIS_TOOLS:
336        if (applyTools is None) or (tool in applyTools):
337            wholeRows.append(
338                [tool, WHOLE_ANALYSIS_TOOLS[tool](exploration)]
339            )
340
341    decisionRows: List[Sequence[AnalysisResult]] = [
342        ['Per-decision metrics:']
343    ]
344    # One row per tool; one column per decision
345    decisionList = exploration.allDecisions()
346    columns = ['Metric ↓/Decision →'] + decisionList
347    decisionRows.append(columns)
348    for tool in DECISION_ANALYSIS_TOOLS:
349        if (applyTools is None) or (tool in applyTools):
350            row: List[AnalysisResult] = [tool]
351            decisionRows.append(row)
352            for decision in decisionList:
353                row.append(
354                    DECISION_ANALYSIS_TOOLS[tool](exploration, decision)
355                )
356
357    stepRows: List[Sequence[AnalysisResult]] = [
358        ['Per-step metrics:']
359    ]
360    # One row per exploration step; one column per tool
361    columns = ['Step ↓/Metric →']
362    stepRows.append(columns)
363    for i, situation in enumerate(exploration):
364        row = [i]
365        stepRows.append(row)
366        for tool in STEP_ANALYSIS_TOOLS:
367            if (applyTools is None) or (tool in applyTools):
368                if i == 0:
369                    columns.append(tool)
370                row.append(STEP_ANALYSIS_TOOLS[tool](situation))
371
372    stepwiseRows: List[Sequence[AnalysisResult]] = [
373        ['Per-decision-per-step, metrics (one table per metric):']
374    ]
375    # For each tool; one row per exploration step and one column per
376    # decision
377    decisionList = exploration.allDecisions()
378    columns = ['Step ↓/Decision →'] + decisionList
379    identities = ['Decision names:'] + [
380        analysis.lastIdentity(exploration, d)
381        for d in decisionList
382    ]
383    for tool in STEPWISE_DECISION_ANALYSIS_TOOLS:
384        if (applyTools is None) or (tool in applyTools):
385            stepwiseRows.append([tool])
386            stepwiseRows.append(columns)
387            stepwiseRows.append(identities)
388            for i, situation in enumerate(exploration):
389                row = [i]
390                stepwiseRows.append(row)
391                for decision in decisionList:
392                    row.append(
393                        STEPWISE_DECISION_ANALYSIS_TOOLS[tool](
394                            situation,
395                            decision
396                        )
397                    )
398
399    # Build a grid containing just the non-empty analysis categories, so
400    # that if you deselect some tools you get a smaller CSV file:
401    grid: List[Sequence[AnalysisResult]] = []
402    if len(wholeRows) > 1:
403        grid.extend(wholeRows)
404    for block in decisionRows, stepRows, stepwiseRows:
405        if len(block) > 1:
406            if grid:
407                grid.append([])  # spacer
408            grid.extend(block)
409
410    # Figure out our destination stream:
411    if destination is None:
412        outStream = sys.stdout
413        closeIt = False
414    else:
415        outStream = open(destination, 'w')
416        closeIt = True
417
418    # Create a CSV writer for our stream
419    writer = csv.writer(outStream)
420
421    # Write out our grid to the file
422    try:
423        writer.writerows(grid)
424    finally:
425        if closeIt:
426            outStream.close()
427
428
429def convert(
430    source: pathlib.Path,
431    destination: pathlib.Path,
432    inputFormatOverride: Optional[SourceType] = None,
433    outputFormatOverride: Optional[SourceType] = None,
434    step: int = -1
435) -> None:
436    """
437    Converts between exploration and graph formats. By default, formats
438    are determined by file extensions, but using the `--format` and
439    `--output-format` options can override this. The available formats
440    are:
441
442    - '.dcg' A `core.DecisionGraph` stored in JSON format.
443    - '.dot' A `core.DecisionGraph` stored as a GraphViz DOT file.
444    - '.exp' A `core.DiscreteExploration` stored in JSON format.
445    - '.exj' A `core.DiscreteExploration` stored as a journal (see
446        `journal.JournalObserver`; TODO: writing this format).
447
448    When converting a decision graph into an exploration format, the
449    resulting exploration will have a single starting step containing
450    the entire specified graph. When converting an exploration into a
451    decision graph format, only the current graph will be saved, unless
452    `--step` is used to specify a different step index to save.
453    """
454    # TODO journal writing
455    obj = loadSource(source, inputFormatOverride)
456
457    if outputFormatOverride is None:
458        outputFormat = determineFileType(str(destination))
459    else:
460        outputFormat = outputFormatOverride
461
462    if outputFormat in ("graph", "dot"):
463        if isinstance(obj, core.DiscreteExploration):
464            graph = obj.getSituation(step).graph
465        else:
466            graph = obj
467        if outputFormat == "graph":
468            saveDecisionGraph(destination, graph)
469        else:
470            saveDotFile(destination, graph)
471    else:
472        if isinstance(obj, core.DecisionGraph):
473            exploration = core.DiscreteExploration.fromGraph(obj)
474        else:
475            exploration = obj
476        if outputFormat == "exploration":
477            saveExploration(destination, exploration)
478        else:
479            saveAsJournal(destination, exploration)
480
481
482INSPECTOR_HELP = """
483Available commands:
484
485- 'help' or '?': List commands.
486- 'done', 'quit', 'q', or 'exit': Quit the inspector.
487- 'f' or 'follow': Follow the primary decision when changing steps. Also
488    changes to that decision immediately. Toggles off if on.
489- 'cd' or 'goto': Change focus decision to the named decision. Cancels
490    follow mode.
491- 'ls' or 'list' or 'destinations': Lists transitions at this decision
492    and their destinations, as well as any mechanisms at this decision.
493- 'lst' or 'steps': Lists each step of the exploration along with the
494    primary decision at each step.
495- 'st' or 'step': Switches to the specified step (an index)
496- 'n' or 'next': Switches to the next step.
497- 'p' or 'prev' or 'previous': Switches to the previous step.
498- 't' or 'take': Change focus decision to the decision which is the
499    destination of the specified transition at the current focused
500    decision.
501- 'prm' or 'primary': Displays the current primary decision.
502- 'a' or 'active': Lists all currently active decisions
503- 'u' or 'unexplored': Lists all unexplored transitions at the current
504    step.
505- 'x' or 'explorable': Lists all unexplored transitions at the current
506    step which are traversable based on the current state. (TODO:
507    make this more accurate).
508- 'r' or 'reachable': TODO
509- 'A' or 'all': Lists all decisions at the current step.
510- 'M' or 'mechanisms': Lists all mechanisms at the current step.
511"""
512
513
514def inspect(
515    source: pathlib.Path,
516    formatOverride: Optional[SourceType] = None
517) -> None:
518    """
519    Inspects the graph or exploration stored in the `source` file,
520    launching an interactive command line for inspecting properties of
521    decisions, transitions, and situations. The file extension is used
522    to determine how to load the data, although the `--format` option
523    may override this. '.dcg' files are assumed to be decision graphs in
524    JSON format, '.exp' files are assumed to be exploration objects in
525    JSON format, and '.exj' files are assumed to be exploration journals
526    in the default journal format. If the object that gets loaded is a
527    graph, a 1-step exploration containing just that graph will be
528    created to inspect. Inspector commands are listed in the
529    `INSPECTOR_HELP` variable.
530    """
531    print(f"Loading exploration from {source!r}...")
532    # Load our exploration
533    exploration = loadSource(source, formatOverride)
534    if isinstance(exploration, core.DecisionGraph):
535        exploration = core.DiscreteExploration.fromGraph(exploration)
536
537    print(
538        f"Inspecting exploration with {len(exploration)} step(s) and"
539        f" {len(exploration.allDecisions())} decision(s):"
540    )
541    print("('h' for help)")
542
543    # Set up tracking variables:
544    step = len(exploration) - 1
545    here: Optional[base.DecisionID] = exploration.primaryDecision(step)
546    graph = exploration.getSituation(step).graph
547    follow = True
548
549    pf = parsing.ParseFormat()
550
551    if here is None:
552        print("Note: There are no decisions in the final graph.")
553
554    while True:
555        # Re-establish the prompt
556        prompt = "> "
557        if here is not None and here in graph:
558            prompt = graph.identityOf(here) + "> "
559        elif here is not None:
560            prompt = f"{here} (?)> "
561
562        # Prompt for the next command
563        fullCommand = input(prompt).split()
564
565        # Track number of invalid commands so we can quit after 10 in a row
566        invalidCommands = 0
567
568        if len(fullCommand) == 0:
569            cmd = ''
570            args = ''
571        else:
572            cmd = fullCommand[0]
573            args = ' '.join(fullCommand[1:])
574
575        # Do what the command says
576        invalid = False
577        if cmd in ("help", '?'):
578            # Displays help message
579            if len(args.strip()) > 0:
580                print("(help does not accept any arguments)")
581            print(INSPECTOR_HELP)
582        elif cmd in ("done", "exit", "quit", "q"):
583            # Exits the inspector
584            if len(args.strip()) > 0:
585                print("(quit does not accept any arguments)")
586            print("Bye.")
587            break
588        elif cmd in ("f", "follow"):
589            if follow:
590                follow = False
591                print("Stopped following")
592            else:
593                follow = True
594                here = exploration.primaryDecision(step)
595                print(f"Now following at: {graph.identityOf(here)}")
596        elif cmd in ("cd", "goto"):
597            # Changes focus to a specific decision
598            try:
599                target = pf.parseDecisionSpecifier(args)
600                target = graph.resolveDecision(target)
601                here = target
602                follow = False
603                print(f"now at: {graph.identityOf(target)}")
604            except Exception:
605                print("(invalid decision specifier)")
606        elif cmd in ("ls", "list", "destinations"):
607            fromID: Optional[base.AnyDecisionSpecifier] = None
608            if args.strip():
609                fromID = pf.parseDecisionSpecifier(args)
610                fromID = graph.resolveDecision(fromID)
611            else:
612                fromID = here
613
614            if fromID is None:
615                print(
616                    "(no focus decision and no decision specified;"
617                    " nothing to list; use 'cd' to specify a decision,"
618                    " or 'all' to list all decisions)"
619                )
620            else:
621                outgoing = graph.destinationsFrom(fromID)
622                info = graph.identityOf(fromID)
623                if len(outgoing) > 0:
624                    print(f"Destinations from {info}:")
625                    print(graph.destinationsListing(outgoing))
626                else:
627                    print("No outgoing transitions from {info}.")
628        elif cmd in ("lst", "steps"):
629            total = len(exploration)
630            print(f"{total} step(s):")
631            for step in range(total):
632                pr = exploration.primaryDecision(step)
633                situ = exploration.getSituation(step)
634                stGraph = situ.graph
635                identity = stGraph.identityOf(pr)
636                print(f"  {step} at {identity}")
637            print(f"({total} total step(s))")
638        elif cmd in ("st", "step"):
639            stepTo = int(args.strip())
640            if stepTo < 0:
641                stepTo += len(exploration)
642            if stepTo < 0:
643                print(
644                    f"Invalid step {args!r} (too negative; min is"
645                    f" {-len(exploration)})"
646                )
647            if stepTo >= len(exploration):
648                print(
649                    f"Invalid step {args!r} (too large; max is"
650                    f" {len(exploration) - 1})"
651                )
652
653            step = stepTo
654            graph = exploration.getSituation(step).graph
655            if follow:
656                here = exploration.primaryDecision(step)
657                print(f"Followed to: {graph.identityOf(here)}")
658        elif cmd in ("n", "next"):
659            if step == -1 or step >= len(exploration) - 2:
660                print("Can't step beyond the last step.")
661            else:
662                step += 1
663                graph = exploration.getSituation(step).graph
664                if here not in graph:
665                    here = None
666            print(f"At step {step}")
667            if follow:
668                here = exploration.primaryDecision(step)
669                print(f"Followed to: {graph.identityOf(here)}")
670        elif cmd in ("p", "prev"):
671            if step == 0 or step <= -len(exploration) + 2:
672                print("Can't step before the first step.")
673            else:
674                step -= 1
675                graph = exploration.getSituation(step).graph
676                if here not in graph:
677                    here = None
678            print(f"At step {step}")
679            if follow:
680                here = exploration.primaryDecision(step)
681                print(f"Followed to: {graph.identityOf(here)}")
682        elif cmd in ("t", "take"):
683            if here is None:
684                print(
685                    "(no focus decision, so can't take transitions. Use"
686                    " 'cd' to specify a decision first.)"
687                )
688            else:
689                dest = graph.getDestination(here, args)
690                if dest is None:
691                    print(
692                        f"Invalid transition {args!r} (no destination for"
693                        f" that transition from {graph.identityOf(here)}"
694                    )
695                here = dest
696        elif cmd in ("prm", "primary"):
697            pr = exploration.primaryDecision(step)
698            if pr is None:
699                print("Step {step} has no primary decision")
700            else:
701                print(
702                    f"Primary decision for step {step} is:"
703                    f" {graph.identityOf(pr)}"
704                )
705        elif cmd in ("a", "active"):
706            active = exploration.getActiveDecisions(step)
707            print(f"Active decisions at step {step}:")
708            print(graph.namesListing(active))
709        elif cmd in ("u", "unexplored"):
710            unx = analysis.unexploredBranches(graph)
711            fin = ':' if len(unx) > 0 else '.'
712            print(f"{len(unx)} unexplored branch(es){fin}")
713            for frID, unTr in unx:
714                print(f"take {unTr} at {graph.identityOf(frID)}")
715        elif cmd in ("x", "explorable"):
716            ctx = base.genericContextForSituation(
717                exploration.getSituation(step)
718            )
719            unx = analysis.unexploredBranches(graph, ctx)
720            fin = ':' if len(unx) > 0 else '.'
721            print(f"{len(unx)} unexplored branch(es){fin}")
722            for frID, unTr in unx:
723                print(f"take {unTr} at {graph.identityOf(frID)}")
724        elif cmd in ("r", "reachable"):
725            print("TODO: Reachable does not work yet.")
726        elif cmd in ("A", "all"):
727            print(
728                f"There are {len(graph)} decision(s) at step {step}:"
729            )
730            for decision in graph.nodes():
731                print(f"  {graph.identityOf(decision)}")
732        elif cmd in ("M", "mechanisms"):
733            count = len(graph.mechanisms)
734            fin = ':' if count > 0 else '.'
735            print(
736                f"There are {count} mechanism(s) at step {step}{fin}"
737            )
738            for mID in graph.mechanisms:
739                where, name = graph.mechanisms[mID]
740                state = exploration.mechanismState(mID, step=step)
741                if where is None:
742                    print(f"  {name!r} (global) in state {state!r}")
743                else:
744                    info = graph.identityOf(where)
745                    print(f"  {name!r} at {info} in state {state!r}")
746        else:
747            invalid = True
748
749        if invalid:
750            if invalidCommands >= 10:
751                print("Too many invalid commands; exiting.")
752                break
753            else:
754                if invalidCommands >= 8:
755                    print("{invalidCommands} invalid commands so far,")
756                    print("inspector will stop after 10 invalid commands...")
757                print(f"Unknown command {cmd!r}...")
758                invalidCommands += 1
759                print(INSPECTOR_HELP)
760        else:
761            invalidCommands = 0
762
763
764#--------------#
765# Parser setup #
766#--------------#
767
768parser = argparse.ArgumentParser(
769    prog="python -m exploration",
770    description="""\
771Runs various commands for processing exploration graphs and journals,
772and for converting between them or displaying them in various formats.
773"""
774)
775subparsers = parser.add_subparsers(
776    title="commands",
777    description="The available commands are:",
778    help="use these with -h/--help for more details"
779)
780
781showParser = subparsers.add_parser(
782    'show',
783    help="show an exploration",
784    description=textwrap.dedent(str(show.__doc__)).strip()
785)
786showParser.set_defaults(run="show")
787showParser.add_argument(
788    "source",
789    type=pathlib.Path,
790    help="The file to load"
791)
792showParser.add_argument(
793    '-f',
794    "--format",
795    choices=get_args(SourceType),
796    help=(
797        "Which format the source file is in (normally that can be"
798        " determined from the file extension)."
799    )
800)
801showParser.add_argument(
802    '-s',
803    "--step",
804    type=int,
805    default=-1,
806    help="Which graph step to show (when loading an exploration)."
807)
808
809analyzeParser = subparsers.add_parser(
810    'analyze',
811    help="analyze an exploration",
812    description=textwrap.dedent(str(analyze.__doc__)).strip()
813)
814analyzeParser.set_defaults(run="analyze")
815analyzeParser.add_argument(
816    "source",
817    type=pathlib.Path,
818    help="The file holding the exploration to analyze"
819)
820analyzeParser.add_argument(
821    "destination",
822    default=None,
823    type=pathlib.Path,
824    help=(
825        "The file name where the output should be written (this file"
826        " will be overwritten without warning)."
827    )
828)
829analyzeParser.add_argument(
830    '-f',
831    "--format",
832    choices=get_args(SourceType),
833    help=(
834        "Which format the source file is in (normally that can be"
835        " determined from the file extension)."
836    )
837)
838
839convertParser = subparsers.add_parser(
840    'convert',
841    help="convert an exploration",
842    description=textwrap.dedent(str(convert.__doc__)).strip()
843)
844convertParser.set_defaults(run="convert")
845convertParser.add_argument(
846    "source",
847    type=pathlib.Path,
848    help="The file holding the graph or exploration to convert."
849)
850convertParser.add_argument(
851    "destination",
852    type=pathlib.Path,
853    help=(
854        "The file name where the output should be written (this file"
855        " will be overwritten without warning)."
856    )
857)
858convertParser.add_argument(
859    '-f',
860    "--format",
861    choices=get_args(SourceType),
862    help=(
863        "Which format the source file is in (normally that can be"
864        " determined from the file extension)."
865    )
866)
867convertParser.add_argument(
868    '-o',
869    "--output-format",
870    choices=get_args(SourceType),
871    help=(
872        "Which format the converted file should be saved as (normally"
873        " that is determined from the file extension)."
874    )
875)
876convertParser.add_argument(
877    '-s',
878    "--step",
879    type=int,
880    default=-1,
881    help=(
882        "Which graph step to save (when converting from an exploration"
883        " format to a graph format)."
884    )
885)
886
887if __name__ == "__main__":
888    options = parser.parse_args()
889    if options.run == "show":
890        show(
891            options.source,
892            formatOverride=options.format,
893            step=options.step
894        )
895    elif options.run == "analyze":
896        analyze(
897            options.source,
898            formatOverride=options.format,
899            destination=options.destination
900        )
901    elif options.run == "convert":
902        convert(
903            options.source,
904            options.destination,
905            inputFormatOverride=options.format,
906            outputFormatOverride=options.output_format,
907            step=options.step
908        )
909    else:
910        raise RuntimeError(
911            f"Invalid 'run' default value: '{options.run}'."
912        )
SourceType: TypeAlias = Literal['graph', 'dot', 'exploration', 'journal']

The file types we recognize.

def determineFileType(filename: str) -> Literal['graph', 'dot', 'exploration', 'journal']:
45def determineFileType(filename: str) -> SourceType:
46    if filename.endswith('.dcg'):
47        return 'graph'
48    elif filename.endswith('.dot'):
49        return 'dot'
50    elif filename.endswith('.exp'):
51        return 'exploration'
52    elif filename.endswith('.exj'):
53        return 'journal'
54    else:
55        raise ValueError(
56            f"Could not determine the file type of file '{filename}':"
57            f" it does not end with '.dcg', '.dot', '.exp', or '.exj'."
58        )
def loadDecisionGraph(path: pathlib.Path) -> exploration.core.DecisionGraph:
61def loadDecisionGraph(path: pathlib.Path) -> core.DecisionGraph:
62    """
63    Loads a JSON-encoded decision graph from a file. The extension
64    should normally be '.dcg'.
65    """
66    with path.open('r', encoding='utf-8-sig') as fInput:
67        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:
70def saveDecisionGraph(
71    path: pathlib.Path,
72    graph: core.DecisionGraph
73) -> None:
74    """
75    Saves a decision graph encoded as JSON in the specified file. The
76    file should normally have a '.dcg' extension.
77    """
78    with path.open('w', encoding='utf-8') as fOutput:
79        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:
82def loadDotFile(path: pathlib.Path) -> core.DecisionGraph:
83    """
84    Loads a `core.DecisionGraph` form the file at the specified path
85    (whose extension should normally be '.dot'). The file format is the
86    GraphViz "dot" format.
87    """
88    with path.open('r', encoding='utf-8-sig') as fInput:
89        dot = fInput.read()
90        try:
91            return parsing.parseDot(dot)
92        except parsing.DotParseError:
93            raise parsing.DotParseError(
94                "Failed to parse Dot file contents:\n\n"
95              + dot
96              + "\n\n(See error above for specific parsing issue.)"
97            )

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:
100def saveDotFile(path: pathlib.Path, graph: core.DecisionGraph) -> None:
101    """
102    Saves a `core.DecisionGraph` as a GraphViz "dot" file. The file
103    extension should normally be ".dot".
104    """
105    dotStr = parsing.toDot(graph, clusterLevels=[])
106    with path.open('w', encoding='utf-8') as fOutput:
107        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:
110def loadExploration(path: pathlib.Path) -> core.DiscreteExploration:
111    """
112    Loads a JSON-encoded `core.DiscreteExploration` object from the file
113    at the specified path. The extension should normally be '.exp'.
114    """
115    with path.open('r', encoding='utf-8-sig') as fInput:
116        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:
119def saveExploration(
120    path: pathlib.Path,
121    exploration: core.DiscreteExploration
122) -> None:
123    """
124    Saves a `core.DiscreteExploration` object as JSON in the specified
125    file. The file extension should normally be '.exp'.
126    """
127    with path.open('w', encoding='utf-8') as fOutput:
128        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) -> exploration.core.DiscreteExploration:
131def loadJournal(path: pathlib.Path) -> core.DiscreteExploration:
132    """
133    Loads a `core.DiscreteExploration` object from a journal file
134    (extension should normally be '.exj'). Uses the
135    `journal.convertJournal` function.
136    """
137    with path.open('r', encoding='utf-8-sig') as fInput:
138        return journal.convertJournal(fInput.read())

Loads a core.DiscreteExploration object from a journal file (extension should normally be '.exj'). Uses the journal.convertJournal function.

def saveAsJournal( path: pathlib.Path, exploration: exploration.core.DiscreteExploration) -> None:
141def saveAsJournal(
142    path: pathlib.Path,
143    exploration: core.DiscreteExploration
144) -> None:
145    """
146    Saves a `core.DiscreteExploration` object as a text journal in the
147    specified file. The file extension should normally be '.exj'.
148
149    TODO: This?!
150    """
151    raise NotImplementedError(
152        "DiscreteExploration-to-journal conversion is not implemented"
153        " yet."
154    )

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) -> Union[exploration.core.DecisionGraph, exploration.core.DiscreteExploration]:
157def loadSource(
158    path: pathlib.Path,
159    formatOverride: Optional[SourceType] = None
160) -> Union[core.DecisionGraph, core.DiscreteExploration]:
161    """
162    Loads either a `core.DecisionGraph` or a `core.DiscreteExploration`
163    from the specified file, depending on its file extension (or the
164    specified format given as `formatOverride` if there is one).
165    """
166    if formatOverride is not None:
167        format = formatOverride
168    else:
169        format = determineFileType(str(path))
170
171    if format == "graph":
172        return loadDecisionGraph(path)
173    if format == "dot":
174        return loadDotFile(path)
175    elif format == "exploration":
176        return loadExploration(path)
177    elif format == "journal":
178        return loadJournal(path)
179    else:
180        raise ValueError(
181            f"Unrecognized file format '{format}' (recognized formats"
182            f" are 'graph', 'exploration', and 'journal')."
183        )

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).

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

A type alias for values we're willing to accept as analysis results. These are going to be written to a CSV file that we want to be human-readable.

STEPWISE_DECISION_ANALYSIS_TOOLS: Dict[str, Callable[[exploration.base.Situation, int], Union[NoneType, bool, str, int, float, complex]]] = {'actionCount': <function analyzeGraph.<locals>.analyzesGraph>, 'branchCount': <function analyzeGraph.<locals>.analyzesGraph>}

The analysis functions to apply to each decision in each step when analyzing an exploration, and the names for each.

STEP_ANALYSIS_TOOLS: Dict[str, Callable[[exploration.base.Situation], Union[NoneType, bool, str, int, float, complex]]] = {'currentDecision': <function currentDecision>, 'unexploredCount': <function countAllUnexploredBranches>, 'traversableUnexploredCount': <function countTraversableUnexploredBranches>, 'meanActions': <function meanOfResults.<locals>.meanResult>, 'meanBranches': <function meanOfResults.<locals>.meanResult>}

The analysis functions to apply to each step when analyzing an exploration, and the names for each.

DECISION_ANALYSIS_TOOLS: Dict[str, Callable[[exploration.core.DiscreteExploration, int], Union[NoneType, bool, str, int, float, complex]]] = {'identity': <function lastIdentity>, 'isVisited': <function <lambda>>, 'revisitCount': <function countRevisits>}

The analysis functions to apply once to each decision in an exploration, and the names for each.

WHOLE_ANALYSIS_TOOLS: Dict[str, Callable[[exploration.core.DiscreteExploration], Union[NoneType, bool, str, int, float, complex]]] = {'stepCount': <function <lambda>>, 'finalDecisionCount': <function <lambda>>, 'meanRevisits': <function meanOfResults.<locals>.meanResult>}

The analysis functions to apply to entire explorations, and the names for each.

def show( source: pathlib.Path, formatOverride: Optional[Literal['graph', 'dot', 'exploration', 'journal']] = None, step: int = -1) -> None:
271def show(
272    source: pathlib.Path,
273    formatOverride: Optional[SourceType] = None,
274    step: int = -1
275) -> None:
276    """
277    Shows the graph or exploration stored in the `source` file. You will
278    need to have the `matplotlib` library installed. Consider using the
279    interactive interface provided by the `explorationViewer` module
280    instead. The file extension is used to determine how to load the data,
281    although the `--format` option may override this. '.dcg' files are
282    assumed to be decision graphs in JSON format, '.exp' files are assumed
283    to be exploration objects in JSON format, and '.exj' files are assumed
284    to be exploration journals in the default journal format. If the object
285    that gets loaded is an exploration, the final graph for that
286    exploration will be displayed, or a specific graph may be selected
287    using `--step`.
288    """
289    obj = loadSource(source, formatOverride)
290    if isinstance(obj, core.DiscreteExploration):
291        obj = obj.getSituation(step).graph
292
293    import matplotlib.pyplot # type: ignore
294
295    # This draws the graph in a new window that pops up. You have to close
296    # the window to end the program.
297    nx.draw(obj)
298    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 analyze( source: pathlib.Path, formatOverride: Optional[Literal['graph', 'dot', 'exploration', 'journal']] = None, destination: Optional[pathlib.Path] = None, applyTools: Optional[List[str]] = None) -> None:
301def analyze(
302    source: pathlib.Path,
303    formatOverride: Optional[SourceType] = None,
304    destination: Optional[pathlib.Path] = None,
305    applyTools: Optional[List[str]] = None
306) -> None:
307    """
308    Analyzes the exploration stored in the `source` file. The file
309    extension is used to determine how to load the data, although this
310    may be overridden by the `--format` option. Normally, '.exp' files
311    are treated as JSON-encoded exploration objects, while '.exj' files
312    are treated as journals using the default journal format.
313
314    This applies a number of analysis functions to produce a CSV file
315    showing per-decision-per-step, per-decision, per-step, and
316    per-exploration metrics. A subset of the available metrics may be
317    selected by passing a list of strings for the `applyTools` argument.
318    See the `STEPWISE_DECISION_ANALYSIS_TOOLS`, `STEP_ANALYSIS_TOOLS`,
319    `DECISION_ANALYSIS_TOOLS`, and `WHOLE_ANALYSIS_TOOLS` dictionaries
320    for tool names.
321
322    If no output file is specified, the output will be printed out.
323    """
324    # Load our source exploration object:
325    obj = loadSource(source, formatOverride)
326    if isinstance(obj, core.DecisionGraph):
327        obj = core.DiscreteExploration.fromGraph(obj)
328
329    exploration: core.DiscreteExploration = obj
330
331    # Apply all of the analysis functions (or only just those that are
332    # selected using applyTools):
333
334    wholeRows: List[List[AnalysisResult]] = [['Whole exploration metrics:']]
335    # One row per tool
336    for tool in WHOLE_ANALYSIS_TOOLS:
337        if (applyTools is None) or (tool in applyTools):
338            wholeRows.append(
339                [tool, WHOLE_ANALYSIS_TOOLS[tool](exploration)]
340            )
341
342    decisionRows: List[Sequence[AnalysisResult]] = [
343        ['Per-decision metrics:']
344    ]
345    # One row per tool; one column per decision
346    decisionList = exploration.allDecisions()
347    columns = ['Metric ↓/Decision →'] + decisionList
348    decisionRows.append(columns)
349    for tool in DECISION_ANALYSIS_TOOLS:
350        if (applyTools is None) or (tool in applyTools):
351            row: List[AnalysisResult] = [tool]
352            decisionRows.append(row)
353            for decision in decisionList:
354                row.append(
355                    DECISION_ANALYSIS_TOOLS[tool](exploration, decision)
356                )
357
358    stepRows: List[Sequence[AnalysisResult]] = [
359        ['Per-step metrics:']
360    ]
361    # One row per exploration step; one column per tool
362    columns = ['Step ↓/Metric →']
363    stepRows.append(columns)
364    for i, situation in enumerate(exploration):
365        row = [i]
366        stepRows.append(row)
367        for tool in STEP_ANALYSIS_TOOLS:
368            if (applyTools is None) or (tool in applyTools):
369                if i == 0:
370                    columns.append(tool)
371                row.append(STEP_ANALYSIS_TOOLS[tool](situation))
372
373    stepwiseRows: List[Sequence[AnalysisResult]] = [
374        ['Per-decision-per-step, metrics (one table per metric):']
375    ]
376    # For each tool; one row per exploration step and one column per
377    # decision
378    decisionList = exploration.allDecisions()
379    columns = ['Step ↓/Decision →'] + decisionList
380    identities = ['Decision names:'] + [
381        analysis.lastIdentity(exploration, d)
382        for d in decisionList
383    ]
384    for tool in STEPWISE_DECISION_ANALYSIS_TOOLS:
385        if (applyTools is None) or (tool in applyTools):
386            stepwiseRows.append([tool])
387            stepwiseRows.append(columns)
388            stepwiseRows.append(identities)
389            for i, situation in enumerate(exploration):
390                row = [i]
391                stepwiseRows.append(row)
392                for decision in decisionList:
393                    row.append(
394                        STEPWISE_DECISION_ANALYSIS_TOOLS[tool](
395                            situation,
396                            decision
397                        )
398                    )
399
400    # Build a grid containing just the non-empty analysis categories, so
401    # that if you deselect some tools you get a smaller CSV file:
402    grid: List[Sequence[AnalysisResult]] = []
403    if len(wholeRows) > 1:
404        grid.extend(wholeRows)
405    for block in decisionRows, stepRows, stepwiseRows:
406        if len(block) > 1:
407            if grid:
408                grid.append([])  # spacer
409            grid.extend(block)
410
411    # Figure out our destination stream:
412    if destination is None:
413        outStream = sys.stdout
414        closeIt = False
415    else:
416        outStream = open(destination, 'w')
417        closeIt = True
418
419    # Create a CSV writer for our stream
420    writer = csv.writer(outStream)
421
422    # Write out our grid to the file
423    try:
424        writer.writerows(grid)
425    finally:
426        if closeIt:
427            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. See the STEPWISE_DECISION_ANALYSIS_TOOLS, STEP_ANALYSIS_TOOLS, DECISION_ANALYSIS_TOOLS, and WHOLE_ANALYSIS_TOOLS dictionaries for tool names.

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:
430def convert(
431    source: pathlib.Path,
432    destination: pathlib.Path,
433    inputFormatOverride: Optional[SourceType] = None,
434    outputFormatOverride: Optional[SourceType] = None,
435    step: int = -1
436) -> None:
437    """
438    Converts between exploration and graph formats. By default, formats
439    are determined by file extensions, but using the `--format` and
440    `--output-format` options can override this. The available formats
441    are:
442
443    - '.dcg' A `core.DecisionGraph` stored in JSON format.
444    - '.dot' A `core.DecisionGraph` stored as a GraphViz DOT file.
445    - '.exp' A `core.DiscreteExploration` stored in JSON format.
446    - '.exj' A `core.DiscreteExploration` stored as a journal (see
447        `journal.JournalObserver`; TODO: writing this format).
448
449    When converting a decision graph into an exploration format, the
450    resulting exploration will have a single starting step containing
451    the entire specified graph. When converting an exploration into a
452    decision graph format, only the current graph will be saved, unless
453    `--step` is used to specify a different step index to save.
454    """
455    # TODO journal writing
456    obj = loadSource(source, inputFormatOverride)
457
458    if outputFormatOverride is None:
459        outputFormat = determineFileType(str(destination))
460    else:
461        outputFormat = outputFormatOverride
462
463    if outputFormat in ("graph", "dot"):
464        if isinstance(obj, core.DiscreteExploration):
465            graph = obj.getSituation(step).graph
466        else:
467            graph = obj
468        if outputFormat == "graph":
469            saveDecisionGraph(destination, graph)
470        else:
471            saveDotFile(destination, graph)
472    else:
473        if isinstance(obj, core.DecisionGraph):
474            exploration = core.DiscreteExploration.fromGraph(obj)
475        else:
476            exploration = obj
477        if outputFormat == "exploration":
478            saveExploration(destination, exploration)
479        else:
480            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- 'r' or 'reachable': TODO\n- 'A' or 'all': Lists all decisions at the current step.\n- 'M' or 'mechanisms': Lists all mechanisms at the current step.\n"
def inspect( source: pathlib.Path, formatOverride: Optional[Literal['graph', 'dot', 'exploration', 'journal']] = None) -> None:
515def inspect(
516    source: pathlib.Path,
517    formatOverride: Optional[SourceType] = None
518) -> None:
519    """
520    Inspects the graph or exploration stored in the `source` file,
521    launching an interactive command line for inspecting properties of
522    decisions, transitions, and situations. The file extension is used
523    to determine how to load the data, although the `--format` option
524    may override this. '.dcg' files are assumed to be decision graphs in
525    JSON format, '.exp' files are assumed to be exploration objects in
526    JSON format, and '.exj' files are assumed to be exploration journals
527    in the default journal format. If the object that gets loaded is a
528    graph, a 1-step exploration containing just that graph will be
529    created to inspect. Inspector commands are listed in the
530    `INSPECTOR_HELP` variable.
531    """
532    print(f"Loading exploration from {source!r}...")
533    # Load our exploration
534    exploration = loadSource(source, formatOverride)
535    if isinstance(exploration, core.DecisionGraph):
536        exploration = core.DiscreteExploration.fromGraph(exploration)
537
538    print(
539        f"Inspecting exploration with {len(exploration)} step(s) and"
540        f" {len(exploration.allDecisions())} decision(s):"
541    )
542    print("('h' for help)")
543
544    # Set up tracking variables:
545    step = len(exploration) - 1
546    here: Optional[base.DecisionID] = exploration.primaryDecision(step)
547    graph = exploration.getSituation(step).graph
548    follow = True
549
550    pf = parsing.ParseFormat()
551
552    if here is None:
553        print("Note: There are no decisions in the final graph.")
554
555    while True:
556        # Re-establish the prompt
557        prompt = "> "
558        if here is not None and here in graph:
559            prompt = graph.identityOf(here) + "> "
560        elif here is not None:
561            prompt = f"{here} (?)> "
562
563        # Prompt for the next command
564        fullCommand = input(prompt).split()
565
566        # Track number of invalid commands so we can quit after 10 in a row
567        invalidCommands = 0
568
569        if len(fullCommand) == 0:
570            cmd = ''
571            args = ''
572        else:
573            cmd = fullCommand[0]
574            args = ' '.join(fullCommand[1:])
575
576        # Do what the command says
577        invalid = False
578        if cmd in ("help", '?'):
579            # Displays help message
580            if len(args.strip()) > 0:
581                print("(help does not accept any arguments)")
582            print(INSPECTOR_HELP)
583        elif cmd in ("done", "exit", "quit", "q"):
584            # Exits the inspector
585            if len(args.strip()) > 0:
586                print("(quit does not accept any arguments)")
587            print("Bye.")
588            break
589        elif cmd in ("f", "follow"):
590            if follow:
591                follow = False
592                print("Stopped following")
593            else:
594                follow = True
595                here = exploration.primaryDecision(step)
596                print(f"Now following at: {graph.identityOf(here)}")
597        elif cmd in ("cd", "goto"):
598            # Changes focus to a specific decision
599            try:
600                target = pf.parseDecisionSpecifier(args)
601                target = graph.resolveDecision(target)
602                here = target
603                follow = False
604                print(f"now at: {graph.identityOf(target)}")
605            except Exception:
606                print("(invalid decision specifier)")
607        elif cmd in ("ls", "list", "destinations"):
608            fromID: Optional[base.AnyDecisionSpecifier] = None
609            if args.strip():
610                fromID = pf.parseDecisionSpecifier(args)
611                fromID = graph.resolveDecision(fromID)
612            else:
613                fromID = here
614
615            if fromID is None:
616                print(
617                    "(no focus decision and no decision specified;"
618                    " nothing to list; use 'cd' to specify a decision,"
619                    " or 'all' to list all decisions)"
620                )
621            else:
622                outgoing = graph.destinationsFrom(fromID)
623                info = graph.identityOf(fromID)
624                if len(outgoing) > 0:
625                    print(f"Destinations from {info}:")
626                    print(graph.destinationsListing(outgoing))
627                else:
628                    print("No outgoing transitions from {info}.")
629        elif cmd in ("lst", "steps"):
630            total = len(exploration)
631            print(f"{total} step(s):")
632            for step in range(total):
633                pr = exploration.primaryDecision(step)
634                situ = exploration.getSituation(step)
635                stGraph = situ.graph
636                identity = stGraph.identityOf(pr)
637                print(f"  {step} at {identity}")
638            print(f"({total} total step(s))")
639        elif cmd in ("st", "step"):
640            stepTo = int(args.strip())
641            if stepTo < 0:
642                stepTo += len(exploration)
643            if stepTo < 0:
644                print(
645                    f"Invalid step {args!r} (too negative; min is"
646                    f" {-len(exploration)})"
647                )
648            if stepTo >= len(exploration):
649                print(
650                    f"Invalid step {args!r} (too large; max is"
651                    f" {len(exploration) - 1})"
652                )
653
654            step = stepTo
655            graph = exploration.getSituation(step).graph
656            if follow:
657                here = exploration.primaryDecision(step)
658                print(f"Followed to: {graph.identityOf(here)}")
659        elif cmd in ("n", "next"):
660            if step == -1 or step >= len(exploration) - 2:
661                print("Can't step beyond the last step.")
662            else:
663                step += 1
664                graph = exploration.getSituation(step).graph
665                if here not in graph:
666                    here = None
667            print(f"At step {step}")
668            if follow:
669                here = exploration.primaryDecision(step)
670                print(f"Followed to: {graph.identityOf(here)}")
671        elif cmd in ("p", "prev"):
672            if step == 0 or step <= -len(exploration) + 2:
673                print("Can't step before the first step.")
674            else:
675                step -= 1
676                graph = exploration.getSituation(step).graph
677                if here not in graph:
678                    here = None
679            print(f"At step {step}")
680            if follow:
681                here = exploration.primaryDecision(step)
682                print(f"Followed to: {graph.identityOf(here)}")
683        elif cmd in ("t", "take"):
684            if here is None:
685                print(
686                    "(no focus decision, so can't take transitions. Use"
687                    " 'cd' to specify a decision first.)"
688                )
689            else:
690                dest = graph.getDestination(here, args)
691                if dest is None:
692                    print(
693                        f"Invalid transition {args!r} (no destination for"
694                        f" that transition from {graph.identityOf(here)}"
695                    )
696                here = dest
697        elif cmd in ("prm", "primary"):
698            pr = exploration.primaryDecision(step)
699            if pr is None:
700                print("Step {step} has no primary decision")
701            else:
702                print(
703                    f"Primary decision for step {step} is:"
704                    f" {graph.identityOf(pr)}"
705                )
706        elif cmd in ("a", "active"):
707            active = exploration.getActiveDecisions(step)
708            print(f"Active decisions at step {step}:")
709            print(graph.namesListing(active))
710        elif cmd in ("u", "unexplored"):
711            unx = analysis.unexploredBranches(graph)
712            fin = ':' if len(unx) > 0 else '.'
713            print(f"{len(unx)} unexplored branch(es){fin}")
714            for frID, unTr in unx:
715                print(f"take {unTr} at {graph.identityOf(frID)}")
716        elif cmd in ("x", "explorable"):
717            ctx = base.genericContextForSituation(
718                exploration.getSituation(step)
719            )
720            unx = analysis.unexploredBranches(graph, ctx)
721            fin = ':' if len(unx) > 0 else '.'
722            print(f"{len(unx)} unexplored branch(es){fin}")
723            for frID, unTr in unx:
724                print(f"take {unTr} at {graph.identityOf(frID)}")
725        elif cmd in ("r", "reachable"):
726            print("TODO: Reachable does not work yet.")
727        elif cmd in ("A", "all"):
728            print(
729                f"There are {len(graph)} decision(s) at step {step}:"
730            )
731            for decision in graph.nodes():
732                print(f"  {graph.identityOf(decision)}")
733        elif cmd in ("M", "mechanisms"):
734            count = len(graph.mechanisms)
735            fin = ':' if count > 0 else '.'
736            print(
737                f"There are {count} mechanism(s) at step {step}{fin}"
738            )
739            for mID in graph.mechanisms:
740                where, name = graph.mechanisms[mID]
741                state = exploration.mechanismState(mID, step=step)
742                if where is None:
743                    print(f"  {name!r} (global) in state {state!r}")
744                else:
745                    info = graph.identityOf(where)
746                    print(f"  {name!r} at {info} in state {state!r}")
747        else:
748            invalid = True
749
750        if invalid:
751            if invalidCommands >= 10:
752                print("Too many invalid commands; exiting.")
753                break
754            else:
755                if invalidCommands >= 8:
756                    print("{invalidCommands} invalid commands so far,")
757                    print("inspector will stop after 10 invalid commands...")
758                print(f"Unknown command {cmd!r}...")
759                invalidCommands += 1
760                print(INSPECTOR_HELP)
761        else:
762            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.

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={'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.\nSee the `STEPWISE_DECISION_ANALYSIS_TOOLS`, `STEP_ANALYSIS_TOOLS`,\n`DECISION_ANALYSIS_TOOLS`, and `WHOLE_ANALYSIS_TOOLS` dictionaries\nfor tool names.\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)}, required=False, help='use these with -h/--help for more details', metavar=None)
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.\nSee the `STEPWISE_DECISION_ANALYSIS_TOOLS`, `STEP_ANALYSIS_TOOLS`,\n`DECISION_ANALYSIS_TOOLS`, and `WHOLE_ANALYSIS_TOOLS` dictionaries\nfor tool names.\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)