exploration.core

  • Authors: Peter Mawhorter
  • Consulted:
  • Date: 2022-3-3
  • Purpose: Core types and tools for dealing with them.

This file defines the main types used for processing and storing DiscreteExploration objects. Note that the types in open.py like OpenExploration represent more generic and broadly capable types.

Key types defined here are:

  • DecisionGraph: Represents a graph of decisions, including observed connections to unknown destinations. This works well for games that focus on rooms with discrete exits where there are few open spaces to explore, and where pretending that each decision has a discrete set of options is not too much of a distortion.
  • DiscreteExploration: A list of DecisionGraphs with position and transition information representing exploration over time.
    1"""
    2- Authors: Peter Mawhorter
    3- Consulted:
    4- Date: 2022-3-3
    5- Purpose: Core types and tools for dealing with them.
    6
    7This file defines the main types used for processing and storing
    8`DiscreteExploration` objects. Note that the types in `open.py` like
    9`OpenExploration` represent more generic and broadly capable types.
   10
   11Key types defined here are:
   12
   13- `DecisionGraph`: Represents a graph of decisions, including observed
   14    connections to unknown destinations. This works well for games that
   15    focus on rooms with discrete exits where there are few open spaces to
   16    explore, and where pretending that each decision has a discrete set
   17    of options is not too much of a distortion.
   18- `DiscreteExploration`: A list of `DecisionGraph`s with position and
   19    transition information representing exploration over time.
   20"""
   21
   22# TODO: Some way to specify the visibility conditions of a transition,
   23# separately from its traversal conditions? Or at least a way to specify
   24# that a transition is only visible when traversable (like successive
   25# upgrades or warp zone connections).
   26
   27from typing import (
   28    Any, Optional, List, Set, Union, cast, Tuple, Dict, TypedDict,
   29    Sequence, Collection, Literal, get_args, Callable, TypeVar,
   30    Iterator, Generator
   31)
   32
   33import copy
   34import warnings
   35import inspect
   36
   37from . import graphs
   38from . import base
   39from . import utils
   40from . import commands
   41
   42
   43#---------#
   44# Globals #
   45#---------#
   46
   47ENDINGS_DOMAIN = 'endings'
   48"""
   49Domain value for endings.
   50"""
   51
   52TRIGGERS_DOMAIN = 'triggers'
   53"""
   54Domain value for triggers.
   55"""
   56
   57
   58#------------------#
   59# Supporting types #
   60#------------------#
   61
   62
   63LookupResult = TypeVar('LookupResult')
   64"""
   65A type variable for lookup results from the generic
   66`DecisionGraph.localLookup` function.
   67"""
   68
   69LookupLayersList = List[Union[None, int, str]]
   70"""
   71A list of layers to look things up in, consisting of `None` for the
   72starting provided decision set, integers for zone heights, and some
   73custom strings like "fallback" and "all" for fallback sets.
   74"""
   75
   76
   77class DecisionInfo(TypedDict):
   78    """
   79    The information stored per-decision in a `DecisionGraph` includes
   80    the decision name (since the key is a decision ID), the domain, a
   81    tags dictionary, and an annotations list.
   82    """
   83    name: base.DecisionName
   84    domain: base.Domain
   85    tags: Dict[base.Tag, base.TagValue]
   86    annotations: List[base.Annotation]
   87
   88
   89#-----------------------#
   90# Transition properties #
   91#-----------------------#
   92
   93class TransitionProperties(TypedDict, total=False):
   94    """
   95    Represents bundled properties of a transition, including a
   96    requirement, effects, tags, and/or annotations. Does not include the
   97    reciprocal. Has the following slots:
   98
   99    - `'requirement'`: The requirement for the transition. This is
  100        always a `Requirement`, although it might be `ReqNothing` if
  101        nothing special is required.
  102    - `'consequence'`: The `Consequence` of the transition.
  103    - `'tags'`: Any tags applied to the transition (as a dictionary).
  104    - `'annotations'`: A list of annotations applied to the transition.
  105    """
  106    requirement: base.Requirement
  107    consequence: base.Consequence
  108    tags: Dict[base.Tag, base.TagValue]
  109    annotations: List[base.Annotation]
  110
  111
  112def mergeProperties(
  113    a: Optional[TransitionProperties],
  114    b: Optional[TransitionProperties]
  115) -> TransitionProperties:
  116    """
  117    Merges two sets of transition properties, following these rules:
  118
  119    1. Tags and annotations are combined. Annotations from the
  120        second property set are ordered after those from the first.
  121    2. If one of the transitions has a `ReqNothing` instance as its
  122        requirement, we use the other requirement. If both have
  123        complex requirements, we create a new `ReqAll` which
  124        combines them as the requirement.
  125    3. The consequences are merged by placing all of the consequences of
  126        the first transition before those of the second one. This may in
  127        some cases change the net outcome of those consequences,
  128        because not all transition properties are compatible. (Imagine
  129        merging two transitions one of which causes a capability to be
  130        gained and the other of which causes a capability to be lost.
  131        What should happen?).
  132    4. The result will not list a reciprocal.
  133
  134    If either transition is `None`, then a deep copy of the other is
  135    returned. If both are `None`, then an empty transition properties
  136    dictionary is returned, with `ReqNothing` as the requirement, no
  137    effects, no tags, and no annotations.
  138
  139    Deep copies of consequences are always made, so that any `Effects`
  140    applications which edit effects won't end up with entangled effects.
  141    """
  142    if a is None:
  143        if b is None:
  144            return {
  145                "requirement": base.ReqNothing(),
  146                "consequence": [],
  147                "tags": {},
  148                "annotations": []
  149            }
  150        else:
  151            return copy.deepcopy(b)
  152    elif b is None:
  153        return copy.deepcopy(a)
  154    # implicitly neither a or b is None below
  155
  156    result: TransitionProperties = {
  157        "requirement": base.ReqNothing(),
  158        "consequence": copy.deepcopy(a["consequence"] + b["consequence"]),
  159        "tags": a["tags"] | b["tags"],
  160        "annotations": a["annotations"] + b["annotations"]
  161    }
  162
  163    if a["requirement"] == base.ReqNothing():
  164        result["requirement"] = b["requirement"]
  165    elif b["requirement"] == base.ReqNothing():
  166        result["requirement"] = a["requirement"]
  167    else:
  168        result["requirement"] = base.ReqAll(
  169            [a["requirement"], b["requirement"]]
  170        )
  171
  172    return result
  173
  174
  175#---------------------#
  176# Errors and warnings #
  177#---------------------#
  178
  179class TransitionBlockedWarning(Warning):
  180    """
  181    An warning type for indicating that a transition which has been
  182    requested does not have its requirements satisfied by the current
  183    game state.
  184    """
  185    pass
  186
  187
  188class BadStart(ValueError):
  189    """
  190    An error raised when the start method is used improperly.
  191    """
  192    pass
  193
  194
  195class MissingDecisionError(KeyError):
  196    """
  197    An error raised when attempting to use a decision that does not
  198    exist.
  199    """
  200    pass
  201
  202
  203class AmbiguousDecisionSpecifierError(KeyError):
  204    """
  205    An error raised when an ambiguous decision specifier is provided.
  206    Note that if a decision specifier simply doesn't match anything, you
  207    will get a `MissingDecisionError` instead.
  208    """
  209    pass
  210
  211
  212class AmbiguousTransitionError(KeyError):
  213    """
  214    An error raised when an ambiguous transition is specified.
  215    If a transition specifier simply doesn't match anything, you
  216    will get a `MissingTransitionError` instead.
  217    """
  218    pass
  219
  220
  221class MissingTransitionError(KeyError):
  222    """
  223    An error raised when attempting to use a transition that does not
  224    exist.
  225    """
  226    pass
  227
  228
  229class MissingTransitionWarning(Warning):
  230    """
  231    Softer form of a `MissingTransitionError`.
  232    """
  233    pass
  234
  235
  236class MissingMechanismWarning(Warning):
  237    """
  238    A warning to use when attempting look up a mechanism name but no
  239    mechanism is found. Use `MissingMechanismError` instead if the issue
  240    is an error.
  241    """
  242    pass
  243
  244
  245class MissingMechanismError(KeyError):
  246    """
  247    An error raised when attempting to use a mechanism that does not
  248    exist.
  249    """
  250    pass
  251
  252
  253class MissingZoneError(KeyError):
  254    """
  255    An error raised when attempting to use a zone that does not exist.
  256    """
  257    pass
  258
  259
  260class InvalidLevelError(ValueError):
  261    """
  262    An error raised when an operation fails because of an invalid zone
  263    level.
  264    """
  265    pass
  266
  267
  268class InvalidDestinationError(ValueError):
  269    """
  270    An error raised when attempting to perform an operation with a
  271    transition but that transition does not lead to a destination that's
  272    compatible with the operation.
  273    """
  274    pass
  275
  276
  277class ExplorationStatusError(ValueError):
  278    """
  279    An error raised when attempting to perform an operation that
  280    requires a previously-visited destination with a decision that
  281    represents a not-yet-visited decision, or vice versa. For
  282    `Situation`s, Exploration states 'unknown', 'hypothesized', and
  283    'noticed' count as "not-yet-visited" while 'exploring' and 'explored'
  284    count as "visited" (see `base.hasBeenVisited`) Meanwhile, in a
  285    `DecisionGraph` where exploration statuses are not present, the
  286    presence or absence of the 'unconfirmed' tag is used to determine
  287    whether something has been confirmed or not.
  288    """
  289    pass
  290
  291
  292WARN_OF_NAME_COLLISIONS = False
  293"""
  294Whether or not to issue warnings when two decision names are the same.
  295"""
  296
  297
  298class DecisionCollisionWarning(Warning):
  299    """
  300    A warning raised when attempting to create a new decision using the
  301    name of a decision that already exists.
  302    """
  303    pass
  304
  305
  306class TransitionCollisionError(ValueError):
  307    """
  308    An error raised when attempting to re-use a transition name for a
  309    new transition, or otherwise when a transition name conflicts with
  310    an already-established transition.
  311    """
  312    pass
  313
  314
  315class TransitionCollisionWarning(Warning):
  316    """
  317    Softer form of a `TransitionCollisionError`.
  318    """
  319    pass
  320
  321
  322class AmbiguousMechanismWarning(Warning):
  323    """
  324    An warning to use when a mechanism reference is potentially
  325    ambiguous. Use `AmbiguousMechanismError` instead if the issue is an
  326    error.
  327    """
  328    pass
  329
  330
  331class AmbiguousMechanismError(ValueError):
  332    """
  333    An error raised when attempting look up a mechanism name but more
  334    than one mechanism shares that name within an applicable search
  335    region.
  336    """
  337    pass
  338
  339
  340class MechanismCollisionError(ValueError):
  341    """
  342    An error raised when attempting to re-use a mechanism name at the
  343    same decision where a mechanism with that name already exists.
  344    """
  345    pass
  346
  347
  348class DecisionCollisionError(ValueError):
  349    """
  350    An error raised when attempting to re-use a decision ID.
  351    """
  352    pass
  353
  354
  355class DomainCollisionError(KeyError):
  356    """
  357    An error raised when attempting to create a domain with the same
  358    name as an existing domain.
  359    """
  360    pass
  361
  362
  363class MissingFocalContextError(KeyError):
  364    """
  365    An error raised when attempting to pick out a focal context with a
  366    name that doesn't exist.
  367    """
  368    pass
  369
  370
  371class FocalContextCollisionError(KeyError):
  372    """
  373    An error raised when attempting to create a focal context with the
  374    same name as an existing focal context.
  375    """
  376    pass
  377
  378
  379class InvalidActionError(TypeError):
  380    """
  381    An error raised when attempting to take an exploration action which
  382    is not correctly formed.
  383    """
  384    pass
  385
  386
  387class ImpossibleActionError(ValueError):
  388    """
  389    An error raised when attempting to take an exploration action which
  390    is correctly formed but which specifies an action that doesn't match
  391    up with the graph state.
  392    """
  393    pass
  394
  395
  396class DoubleActionError(ValueError):
  397    """
  398    An error raised when attempting to set up an `ExplorationAction`
  399    when the current situation already has an action specified.
  400    """
  401    pass
  402
  403
  404class InactiveDomainWarning(Warning):
  405    """
  406    A warning used when an inactive domain is referenced but the
  407    operation in progress can still succeed (for example when
  408    deactivating an already-inactive domain).
  409    """
  410
  411
  412class ZoneCollisionError(ValueError):
  413    """
  414    An error raised when attempting to re-use a zone name for a new zone,
  415    or otherwise when a zone name conflicts with an already-established
  416    zone.
  417    """
  418    pass
  419
  420
  421class InvalidMechanismSpecifierWarning(Warning):
  422    """
  423    A warning used when a mechanism specifier includes both a numerical
  424    decision ID and superfluous domain/zone parts (which get ignored).
  425    """
  426
  427
  428#---------------------#
  429# DecisionGraph class #
  430#---------------------#
  431
  432class DecisionGraph(
  433    graphs.UniqueExitsGraph[base.DecisionID, base.Transition]
  434):
  435    """
  436    Represents a view of the world as a topological graph at a moment in
  437    time. It derives from `networkx.MultiDiGraph`.
  438
  439    Each node (a `Decision`) represents a place in the world where there
  440    are multiple opportunities for travel/action, or a dead end where
  441    you must turn around and go back; typically this is a single room in
  442    a game, but sometimes one room has multiple decision points. Edges
  443    (`Transition`s) represent choices that can be made to travel to
  444    other decision points (e.g., taking the left door), or when they are
  445    self-edges, they represent actions that can be taken within a
  446    location that affect the world or the game state.
  447
  448    Each `Transition` includes a `Effects` dictionary
  449    indicating the effects that it has. Other effects of the transition
  450    that are not simple enough to be included in this format may be
  451    represented in an `DiscreteExploration` by changing the graph in the
  452    next step to reflect further effects of a transition.
  453
  454    In addition to normal transitions between decisions, a
  455    `DecisionGraph` can represent potential transitions which lead to
  456    unknown destinations. These are represented by adding decisions with
  457    the `'unconfirmed'` tag (whose names where not specified begin with
  458    `'_u.'`) with a separate unconfirmed decision for each transition
  459    (although where it's known that two transitions lead to the same
  460    unconfirmed decision, this can be represented as well).
  461
  462    Both nodes and edges can have `Annotation`s associated with them that
  463    include extra details about the explorer's perception of the
  464    situation. They can also have `Tag`s, which represent specific
  465    categories a transition or decision falls into.
  466
  467    Nodes can also be part of one or more `Zones`, and zones can also be
  468    part of other zones, allowing for a hierarchical description of the
  469    underlying space.
  470
  471    Equivalences can be specified to mark that some combination of
  472    capabilities can stand in for another capability.
  473    """
  474    def __init__(self) -> None:
  475        super().__init__()
  476
  477        self.zones: Dict[base.Zone, base.ZoneInfo] = {}
  478        """
  479        Mapping from zone names to zone info
  480        """
  481
  482        self.unknownCount: int = 0
  483        """
  484        Number of unknown decisions that have been created (not number
  485        of current unknown decisions, which is likely lower)
  486        """
  487
  488        self.equivalences: base.Equivalences = {}
  489        """
  490        See `base.Equivalences`. Determines what capabilities and/or
  491        mechanism states can count as active based on alternate
  492        requirements.
  493        """
  494
  495        self.reversionTypes: Dict[str, Set[str]] = {}
  496        """
  497        This tracks shorthand reversion types. See `base.revertedState`
  498        for how these are applied. Keys are custom names and values are
  499        reversion type strings that `base.revertedState` could access.
  500        """
  501
  502        self.nextID: base.DecisionID = 0
  503        """
  504        The ID to use for the next new decision we create.
  505        """
  506
  507        self.nextMechanismID: base.MechanismID = 0
  508        """
  509        ID for the next mechanism.
  510        """
  511
  512        self.mechanisms: Dict[
  513            base.MechanismID,
  514            Tuple[Optional[base.DecisionID], base.MechanismName]
  515        ] = {}
  516        """
  517        Mapping from `MechanismID`s to (`DecisionID`, `MechanismName`)
  518        pairs. For global mechanisms, the `DecisionID` is None.
  519        """
  520
  521        self.globalMechanisms: Dict[
  522            base.MechanismName,
  523            base.MechanismID
  524        ] = {}
  525        """
  526        Global mechanisms
  527        """
  528
  529        self.nameLookup: Dict[base.DecisionName, List[base.DecisionID]] = {}
  530        """
  531        A cache for name -> ID lookups
  532        """
  533
  534    # Note: not hashable
  535
  536    def __eq__(self, other):
  537        """
  538        Equality checker. `DecisionGraph`s can only be equal to other
  539        `DecisionGraph`s, not to other kinds of things.
  540        """
  541        if not isinstance(other, DecisionGraph):
  542            return False
  543        else:
  544            # Checks nodes, edges, and all attached data
  545            if not super().__eq__(other):
  546                return False
  547
  548            # Check unknown count
  549            if self.unknownCount != other.unknownCount:
  550                return False
  551
  552            # Check zones
  553            if self.zones != other.zones:
  554                return False
  555
  556            # Check equivalences
  557            if self.equivalences != other.equivalences:
  558                return False
  559
  560            # Check reversion types
  561            if self.reversionTypes != other.reversionTypes:
  562                return False
  563
  564            # Check mechanisms
  565            if self.nextMechanismID != other.nextMechanismID:
  566                return False
  567
  568            if self.mechanisms != other.mechanisms:
  569                return False
  570
  571            if self.globalMechanisms != other.globalMechanisms:
  572                return False
  573
  574            # Check names:
  575            if self.nameLookup != other.nameLookup:
  576                return False
  577
  578            return True
  579
  580    def listDifferences(
  581        self,
  582        other: 'DecisionGraph'
  583    ) -> Generator[str, None, None]:
  584        """
  585        Generates strings describing differences between this graph and
  586        another graph. This does NOT perform graph matching, so it will
  587        consider graphs different even if they have identical structures
  588        but use different IDs for the nodes in those structures.
  589        """
  590        if not isinstance(other, DecisionGraph):
  591            yield "other is not a graph"
  592        else:
  593            suppress = False
  594            myNodes = set(self.nodes)
  595            theirNodes = set(other.nodes)
  596            for n in myNodes:
  597                if n not in theirNodes:
  598                    suppress = True
  599                    yield (
  600                        f"other graph missing node {n}"
  601                    )
  602                else:
  603                    if self.nodes[n] != other.nodes[n]:
  604                        suppress = True
  605                        yield (
  606                            f"other graph has differences at node {n}:"
  607                            f"\n  Ours:  {self.nodes[n]}"
  608                            f"\nTheirs:  {other.nodes[n]}"
  609                        )
  610                    myDests = self.destinationsFrom(n)
  611                    theirDests = other.destinationsFrom(n)
  612                    for tr in myDests:
  613                        myTo = myDests[tr]
  614                        if tr not in theirDests:
  615                            suppress = True
  616                            yield (
  617                                f"at {self.identityOf(n)}: other graph"
  618                                f" missing transition {tr!r}"
  619                            )
  620                        else:
  621                            theirTo = theirDests[tr]
  622                            if myTo != theirTo:
  623                                suppress = True
  624                                yield (
  625                                    f"at {self.identityOf(n)}: other"
  626                                    f" graph transition {tr!r} leads to"
  627                                    f" {theirTo} instead of {myTo}"
  628                                )
  629                            else:
  630                                myProps = self.edges[n, myTo, tr]  # type:ignore [index] # noqa
  631                                theirProps = other.edges[n, myTo, tr]  # type:ignore [index] # noqa
  632                                if myProps != theirProps:
  633                                    suppress = True
  634                                    yield (
  635                                        f"at {self.identityOf(n)}: other"
  636                                        f" graph transition {tr!r} has"
  637                                        f" different properties:"
  638                                        f"\n  Ours:  {myProps}"
  639                                        f"\nTheirs:  {theirProps}"
  640                                    )
  641            for extra in theirNodes - myNodes:
  642                suppress = True
  643                yield (
  644                    f"other graph has extra node {extra}"
  645                )
  646
  647            # TODO: Fix networkx stubs!
  648            if self.graph != other.graph:  # type:ignore [attr-defined]
  649                suppress = True
  650                yield (
  651                    " different graph attributes:"  # type:ignore [attr-defined]  # noqa
  652                    f"\n  Ours:  {self.graph}"
  653                    f"\nTheirs:  {other.graph}"
  654                )
  655
  656            # Checks any other graph data we might have missed
  657            if not super().__eq__(other) and not suppress:
  658                for attr in dir(self):
  659                    if attr.startswith('__') and attr.endswith('__'):
  660                        continue
  661                    if not hasattr(other, attr):
  662                        yield f"other graph missing attribute: {attr!r}"
  663                    else:
  664                        myVal = getattr(self, attr)
  665                        theirVal = getattr(other, attr)
  666                        if (
  667                            myVal != theirVal
  668                        and not ((callable(myVal) and callable(theirVal)))
  669                        ):
  670                            yield (
  671                                f"other has different val for {attr!r}:"
  672                                f"\n  Ours:  {myVal}"
  673                                f"\nTheirs:  {theirVal}"
  674                            )
  675                for attr in sorted(set(dir(other)) - set(dir(self))):
  676                    yield f"other has extra attribute: {attr!r}"
  677                yield "graph data is different"
  678                # TODO: More detail here!
  679
  680            # Check unknown count
  681            if self.unknownCount != other.unknownCount:
  682                yield "unknown count is different"
  683
  684            # Check zones
  685            if self.zones != other.zones:
  686                yield "zones are different"
  687
  688            # Check equivalences
  689            if self.equivalences != other.equivalences:
  690                yield "equivalences are different"
  691
  692            # Check reversion types
  693            if self.reversionTypes != other.reversionTypes:
  694                yield "reversionTypes are different"
  695
  696            # Check mechanisms
  697            if self.nextMechanismID != other.nextMechanismID:
  698                yield "nextMechanismID is different"
  699
  700            if self.mechanisms != other.mechanisms:
  701                yield "mechanisms are different"
  702
  703            if self.globalMechanisms != other.globalMechanisms:
  704                yield "global mechanisms are different"
  705
  706            # Check names:
  707            if self.nameLookup != other.nameLookup:
  708                for name in self.nameLookup:
  709                    if name not in other.nameLookup:
  710                        yield (
  711                            f"other graph is missing name lookup entry"
  712                            f" for {name!r}"
  713                        )
  714                    else:
  715                        mine = self.nameLookup[name]
  716                        theirs = other.nameLookup[name]
  717                        if theirs != mine:
  718                            yield (
  719                                f"name lookup for {name!r} is {theirs}"
  720                                f" instead of {mine}"
  721                            )
  722                extras = set(other.nameLookup) - set(self.nameLookup)
  723                if extras:
  724                    yield (
  725                        f"other graph has extra name lookup entries:"
  726                        f" {extras}"
  727                    )
  728
  729    def _assignID(self) -> base.DecisionID:
  730        """
  731        Returns the next `base.DecisionID` to use and increments the
  732        next ID counter.
  733        """
  734        result = self.nextID
  735        self.nextID += 1
  736        return result
  737
  738    def _assignMechanismID(self) -> base.MechanismID:
  739        """
  740        Returns the next `base.MechanismID` to use and increments the
  741        next ID counter.
  742        """
  743        result = self.nextMechanismID
  744        self.nextMechanismID += 1
  745        return result
  746
  747    def decisionInfo(self, dID: base.DecisionID) -> DecisionInfo:
  748        """
  749        Retrieves the decision info for the specified decision, as a
  750        live editable dictionary.
  751
  752        For example:
  753
  754        >>> g = DecisionGraph()
  755        >>> g.addDecision('A')
  756        0
  757        >>> g.annotateDecision('A', 'note')
  758        >>> g.decisionInfo(0)
  759        {'name': 'A', 'domain': 'main', 'tags': {}, 'annotations': ['note']}
  760        """
  761        return cast(DecisionInfo, self.nodes[dID])
  762
  763    def resolveDecisions(
  764        self,
  765        spec: base.AnyDecisionSpecifier,
  766        zoneHint: Optional[base.Zone] = None,
  767        domainHint: Optional[base.Domain] = None
  768    ) -> Set[base.DecisionID]:
  769        """
  770        Works like `resolveDecision`, except that it returns a set of
  771        decision IDs. Where `resolveDecision` would raise an
  772        `AmbiguousDecisionSpecifierError`, it instead returns a set with
  773        multiple IDs. Where `resolveDecision` would raise a
  774        `MissingDecisionError`, it instead returns an empty set.
  775
  776        Examples:
  777
  778        >>> g = DecisionGraph()
  779        >>> g.addDecision('A')
  780        0
  781        >>> g.addDecision('B')
  782        1
  783        >>> g.addDecision('C')
  784        2
  785        >>> g.addDecision('A')
  786        3
  787        >>> g.addDecision('B', 'menu')
  788        4
  789        >>> g.createZone('Z', 0)
  790        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
  791 annotations=[])
  792        >>> g.createZone('Z2', 0)
  793        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
  794 annotations=[])
  795        >>> g.createZone('Zup', 1)
  796        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
  797 annotations=[])
  798        >>> g.addDecisionToZone(0, 'Z')
  799        >>> g.addDecisionToZone(1, 'Z')
  800        >>> g.addDecisionToZone(2, 'Z')
  801        >>> g.addDecisionToZone(3, 'Z2')
  802        >>> g.addZoneToZone('Z', 'Zup')
  803        >>> g.addZoneToZone('Z2', 'Zup')
  804        >>> g.resolveDecisions(1)
  805        {1}
  806        >>> g.resolveDecisions('A')
  807        {0, 3}
  808        >>> g.resolveDecisions('B')
  809        {1, 4}
  810        >>> g.resolveDecisions('C')
  811        {2}
  812        >>> g.resolveDecisions('A', 'Z')
  813        {0}
  814        >>> g.resolveDecisions('A', zoneHint='Z2')
  815        {3}
  816        >>> g.resolveDecisions('B', domainHint='main')
  817        {1}
  818        >>> g.resolveDecisions('B', None, 'menu')
  819        {4}
  820        >>> g.resolveDecisions('B', zoneHint='Z2')
  821        set()
  822        >>> g.resolveDecisions('A', domainHint='menu')
  823        set()
  824        >>> g.resolveDecisions('A', domainHint='madeup')
  825        set()
  826        >>> g.resolveDecisions('A', zoneHint='madeup')
  827        set()
  828        >>> g.resolveDecisions(17)
  829        set()
  830        """
  831        # Parse it to either an ID or specifier if it's a string:
  832        if isinstance(spec, str):
  833            try:
  834                spec = int(spec)
  835            except ValueError:
  836                pass
  837
  838        # If it's an ID, check for existence:
  839        if isinstance(spec, base.DecisionID):
  840            if spec in self:
  841                return { spec }
  842            else:
  843                return set()
  844        else:
  845            if isinstance(spec, base.DecisionName):
  846                spec = base.DecisionSpecifier(
  847                    domain=None,
  848                    zone=None,
  849                    name=spec
  850                )
  851            elif not isinstance(spec, base.DecisionSpecifier):
  852                raise TypeError(
  853                    f"Specification is not provided as a"
  854                    f" DecisionSpecifier or other valid type. (got type"
  855                    f" {type(spec)})."
  856                )
  857
  858            # Merge domain hints from spec/args
  859            if (
  860                spec.domain is not None
  861            and domainHint is not None
  862            and spec.domain != domainHint
  863            ):
  864                raise ValueError(
  865                    f"Specifier {repr(spec)} includes domain hint"
  866                    f" {repr(spec.domain)} which is incompatible with"
  867                    f" explicit domain hint {repr(domainHint)}."
  868                )
  869            else:
  870                domainHint = spec.domain or domainHint
  871
  872            # Merge zone hints from spec/args
  873            if (
  874                spec.zone is not None
  875            and zoneHint is not None
  876            and spec.zone != zoneHint
  877            ):
  878                raise ValueError(
  879                    f"Specifier {repr(spec)} includes zone hint"
  880                    f" {repr(spec.zone)} which is incompatible with"
  881                    f" explicit zone hint {repr(zoneHint)}."
  882                )
  883            else:
  884                zoneHint = spec.zone or zoneHint
  885
  886            if spec.name not in self.nameLookup:
  887                return set()
  888            else:
  889                options = self.nameLookup[spec.name]
  890                if len(options) == 0:
  891                    return set()
  892                return {
  893                    opt
  894                    for opt in options
  895                    if (
  896                        domainHint is None
  897                     or self.domainFor(opt) == domainHint
  898                    ) and (
  899                        zoneHint is None
  900                     or zoneHint in self.zoneAncestors(opt)
  901                    )
  902                }
  903
  904    def resolveDecision(
  905        self,
  906        spec: base.AnyDecisionSpecifier,
  907        zoneHint: Optional[base.Zone] = None,
  908        domainHint: Optional[base.Domain] = None
  909    ) -> base.DecisionID:
  910        """
  911        Given a decision specifier returns the ID associated with that
  912        decision, or raises an `AmbiguousDecisionSpecifierError` or a
  913        `MissingDecisionError` if the specified decision is either
  914        missing or ambiguous. Cannot handle strings that contain domain
  915        and/or zone parts; use
  916        `parsing.ParseFormat.parseDecisionSpecifier` to turn such
  917        strings into `DecisionSpecifier`s if you need to first.
  918
  919        Examples:
  920
  921        >>> g = DecisionGraph()
  922        >>> g.addDecision('A')
  923        0
  924        >>> g.addDecision('B')
  925        1
  926        >>> g.addDecision('C')
  927        2
  928        >>> g.addDecision('A')
  929        3
  930        >>> g.addDecision('B', 'menu')
  931        4
  932        >>> g.createZone('Z', 0)
  933        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
  934 annotations=[])
  935        >>> g.createZone('Z2', 0)
  936        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
  937 annotations=[])
  938        >>> g.createZone('Zup', 1)
  939        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
  940 annotations=[])
  941        >>> g.addDecisionToZone(0, 'Z')
  942        >>> g.addDecisionToZone(1, 'Z')
  943        >>> g.addDecisionToZone(2, 'Z')
  944        >>> g.addDecisionToZone(3, 'Z2')
  945        >>> g.addZoneToZone('Z', 'Zup')
  946        >>> g.addZoneToZone('Z2', 'Zup')
  947        >>> g.resolveDecision(1)
  948        1
  949        >>> g.resolveDecision('A')
  950        Traceback (most recent call last):
  951        ...
  952        exploration.core.AmbiguousDecisionSpecifierError...
  953        >>> g.resolveDecision('B')
  954        Traceback (most recent call last):
  955        ...
  956        exploration.core.AmbiguousDecisionSpecifierError...
  957        >>> g.resolveDecision('C')
  958        2
  959        >>> g.resolveDecision('A', 'Z')
  960        0
  961        >>> g.resolveDecision('A', zoneHint='Z2')
  962        3
  963        >>> g.resolveDecision('B', domainHint='main')
  964        1
  965        >>> g.resolveDecision('B', None, 'menu')
  966        4
  967        >>> g.resolveDecision('B', zoneHint='Z2')
  968        Traceback (most recent call last):
  969        ...
  970        exploration.core.MissingDecisionError...
  971        >>> g.resolveDecision('A', domainHint='menu')
  972        Traceback (most recent call last):
  973        ...
  974        exploration.core.MissingDecisionError...
  975        >>> g.resolveDecision('A', domainHint='madeup')
  976        Traceback (most recent call last):
  977        ...
  978        exploration.core.MissingDecisionError...
  979        >>> g.resolveDecision('A', zoneHint='madeup')
  980        Traceback (most recent call last):
  981        ...
  982        exploration.core.MissingDecisionError...
  983        """
  984        options = self.resolveDecisions(spec, zoneHint, domainHint)
  985        if len(options) == 0:  # zero options: decision doesn't exist
  986            if (
  987                (isinstance(spec, str) and spec.isdigit())
  988             or isinstance(spec, int)
  989            ):
  990                raise MissingDecisionError(
  991                    f"There is no decision with ID {int(spec)}."
  992                )
  993            elif isinstance(spec, str):
  994                if spec not in self.nameLookup:
  995                    raise MissingDecisionError(
  996                        f"There is no decision named {spec!r}."
  997                    )
  998                else:
  999                    filterDesc = ""
 1000                    if domainHint is not None:
 1001                        filterDesc += f" in domain {repr(domainHint)}"
 1002                    if zoneHint is not None:
 1003                        filterDesc += f" in zone {repr(zoneHint)}"
 1004                    raise MissingDecisionError(
 1005                        f"There is at least one decision named {spec!r},"
 1006                        f" but there are none {filterDesc}."
 1007                    )
 1008            else:
 1009                assert isinstance(spec, base.DecisionSpecifier)
 1010                if spec.name not in self.nameLookup:
 1011                    raise MissingDecisionError(
 1012                        f"There is no decision named {spec.name!r}."
 1013                    )
 1014                else:
 1015                    filterDesc = ""
 1016                    domainHint = domainHint or spec.domain
 1017                    zoneHint = zoneHint or spec.zone
 1018                    if domainHint is not None:
 1019                        filterDesc += f" in domain {repr(domainHint)}"
 1020                    if zoneHint is not None:
 1021                        filterDesc += f" in zone {repr(zoneHint)}"
 1022                    raise MissingDecisionError(
 1023                        f"There is at least one decision matching {spec!r},"
 1024                        f" but there are none {filterDesc}."
 1025                    )
 1026        elif len(options) > 1:  # multiple options: specifier was ambiguous
 1027            assert not isinstance(spec, int)  # couldn't be ambiguous
 1028            if isinstance(spec, str):
 1029                assert not spec.isdigit()  # couldn't be ambiguous
 1030                raise AmbiguousDecisionSpecifierError(
 1031                    f"There are {len(options)} decisions named"
 1032                    f" {repr(spec)}."
 1033                )
 1034            else:
 1035                assert isinstance(spec, base.DecisionSpecifier)
 1036                filterDesc = ""
 1037                domainHint = domainHint or spec.domain
 1038                zoneHint = zoneHint or spec.zone
 1039                if domainHint is not None:
 1040                    filterDesc += f" in domain {repr(domainHint)}"
 1041                if zoneHint is not None:
 1042                    filterDesc += f" in zone {repr(zoneHint)}"
 1043                raise AmbiguousDecisionSpecifierError(
 1044                    f"There are {len(options)} decisions named"
 1045                    f" {repr(spec.name)}{filterDesc}."
 1046                )
 1047        else:  # only 1 option: successfully resolved to unique decision
 1048            return list(options)[0]
 1049
 1050    def getDecision(
 1051        self,
 1052        decision: base.AnyDecisionSpecifier,
 1053        zoneHint: Optional[base.Zone] = None,
 1054        domainHint: Optional[base.Domain] = None
 1055    ) -> Optional[base.DecisionID]:
 1056        """
 1057        Works like `resolveDecision` but returns None instead of raising
 1058        a `MissingDecisionError` if the specified decision isn't listed.
 1059        May still raise an `AmbiguousDecisionSpecifierError`.
 1060        """
 1061        try:
 1062            return self.resolveDecision(
 1063                decision,
 1064                zoneHint,
 1065                domainHint
 1066            )
 1067        except MissingDecisionError:
 1068            return None
 1069
 1070    def nameFor(
 1071        self,
 1072        decision: base.AnyDecisionSpecifier
 1073    ) -> base.DecisionName:
 1074        """
 1075        Returns the name of the specified decision. Note that names are
 1076        not necessarily unique.
 1077
 1078        Example:
 1079
 1080        >>> d = DecisionGraph()
 1081        >>> d.addDecision('A')
 1082        0
 1083        >>> d.addDecision('B')
 1084        1
 1085        >>> d.addDecision('B')
 1086        2
 1087        >>> d.nameFor(0)
 1088        'A'
 1089        >>> d.nameFor(1)
 1090        'B'
 1091        >>> d.nameFor(2)
 1092        'B'
 1093        >>> d.nameFor(3)
 1094        Traceback (most recent call last):
 1095        ...
 1096        exploration.core.MissingDecisionError...
 1097        """
 1098        dID = self.resolveDecision(decision)
 1099        return self.nodes[dID]['name']
 1100
 1101    def shortIdentity(
 1102        self,
 1103        decision: Optional[base.AnyDecisionSpecifier],
 1104        includeZones: bool = True,
 1105        alwaysDomain: Optional[bool] = None
 1106    ):
 1107        """
 1108        Returns a string containing the name for the given decision,
 1109        prefixed by its level-0 zone(s) and domain. If the value provided
 1110        is `None`, it returns the string "(nowhere)". This is not
 1111        necessarily unique.
 1112
 1113        If `includeZones` is true (the default) then zone information
 1114        is included before the decision name.
 1115
 1116        If `alwaysDomain` is true or false, then the domain information
 1117        will always (or never) be included. If it's `None` (the default)
 1118        then domain info will only be included for decisions which are
 1119        not in the default domain.
 1120
 1121        This string is NOT necessarily valid input to
 1122        `parsing.ParseFormat.parseDecisionSpecifier` (see
 1123        `journal.JournalObserver.identifyingString` for a function that
 1124        can generate that).
 1125        """
 1126        if decision is None:
 1127            return "(nowhere)"
 1128        else:
 1129            dID = self.resolveDecision(decision)
 1130            thisDomain = self.domainFor(dID)
 1131            dSpec = ''
 1132            zSpec = ''
 1133            if (
 1134                alwaysDomain is True
 1135             or (
 1136                    alwaysDomain is None
 1137                and thisDomain != base.DEFAULT_DOMAIN
 1138                )
 1139            ):
 1140                dSpec = thisDomain + '//'  # TODO: Don't hardcode this?
 1141            if includeZones:
 1142                zones = [
 1143                    z
 1144                    for z in self.zoneParents(dID)
 1145                    if self.zones[z].level == 0
 1146                ]
 1147                if len(zones) == 1:
 1148                    zSpec = zones[0] + '::'  # TODO: Don't hardcode this?
 1149                elif len(zones) > 1:
 1150                    zSpec = '[' + ', '.join(sorted(zones)) + ']::'
 1151                # else leave zSpec empty
 1152
 1153            return f"{dSpec}{zSpec}{self.nameFor(dID)}"
 1154
 1155    def identityOf(
 1156        self,
 1157        decision: Optional[base.AnyDecisionSpecifier],
 1158        includeZones: bool = True,
 1159        alwaysDomain: Optional[bool] = None
 1160    ) -> str:
 1161        """
 1162        Returns the given node's ID, plus its `shortIdentity` in
 1163        parentheses. Arguments are passed through to `shortIdentity`.
 1164        """
 1165        if decision is None:
 1166            return "(nowhere)"
 1167        else:
 1168            dID = self.resolveDecision(decision)
 1169            short = self.shortIdentity(decision, includeZones, alwaysDomain)
 1170            return f"{dID} ({short})"
 1171
 1172    def namesListing(
 1173        self,
 1174        decisions: Collection[base.DecisionID],
 1175        includeZones: bool = True,
 1176        indent: int = 2
 1177    ) -> str:
 1178        """
 1179        Returns a multi-line string containing an indented listing of
 1180        the provided decision IDs with their names in parentheses after
 1181        each. Useful for debugging & error messages.
 1182
 1183        Includes level-0 zones where applicable, with a zone separator
 1184        before the decision, unless `includeZones` is set to False. Where
 1185        there are multiple level-0 zones, they're listed together in
 1186        brackets.
 1187
 1188        Uses the string '(none)' when there are no decisions are in the
 1189        list.
 1190
 1191        Set `indent` to something other than 2 to control how much
 1192        indentation is added.
 1193
 1194        For example:
 1195
 1196        >>> g = DecisionGraph()
 1197        >>> g.addDecision('A')
 1198        0
 1199        >>> g.addDecision('B')
 1200        1
 1201        >>> g.addDecision('C')
 1202        2
 1203        >>> g.namesListing(['A', 'C', 'B'])
 1204        '  0 (A)\\n  2 (C)\\n  1 (B)\\n'
 1205        >>> g.namesListing([])
 1206        '  (none)\\n'
 1207        >>> g.createZone('zone', 0)
 1208        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 1209 annotations=[])
 1210        >>> g.createZone('zone2', 0)
 1211        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 1212 annotations=[])
 1213        >>> g.createZone('zoneUp', 1)
 1214        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 1215 annotations=[])
 1216        >>> g.addDecisionToZone(0, 'zone')
 1217        >>> g.addDecisionToZone(1, 'zone')
 1218        >>> g.addDecisionToZone(1, 'zone2')
 1219        >>> g.addDecisionToZone(2, 'zoneUp')  # won't be listed: it's level-1
 1220        >>> g.namesListing(['A', 'C', 'B'])
 1221        '  0 (zone::A)\\n  2 (C)\\n  1 ([zone, zone2]::B)\\n'
 1222        """
 1223        ind = ' ' * indent
 1224        if len(decisions) == 0:
 1225            return ind + '(none)\n'
 1226        else:
 1227            result = ''
 1228            for dID in decisions:
 1229                result += ind + self.identityOf(dID, includeZones) + '\n'
 1230            return result
 1231
 1232    def destinationsListing(
 1233        self,
 1234        destinations: Dict[base.Transition, base.DecisionID],
 1235        includeZones: bool = True,
 1236        indent: int = 2
 1237    ) -> str:
 1238        """
 1239        Returns a multi-line string containing an indented listing of
 1240        the provided transitions along with their destinations and the
 1241        names of those destinations in parentheses. Useful for debugging
 1242        & error messages. (Use e.g., `destinationsFrom` to get a
 1243        transitions -> destinations dictionary in the required format.)
 1244
 1245        Uses the string '(no transitions)' when there are no transitions
 1246        in the dictionary.
 1247
 1248        Set `indent` to something other than 2 to control how much
 1249        indentation is added.
 1250
 1251        For example:
 1252
 1253        >>> g = DecisionGraph()
 1254        >>> g.addDecision('A')
 1255        0
 1256        >>> g.addDecision('B')
 1257        1
 1258        >>> g.addDecision('C')
 1259        2
 1260        >>> g.addTransition('A', 'north', 'B', 'south')
 1261        >>> g.addTransition('B', 'east', 'C', 'west')
 1262        >>> g.addTransition('C', 'southwest', 'A', 'northeast')
 1263        >>> g.destinationsListing(g.destinationsFrom('A'))
 1264        '  north to 1 (B)\\n  northeast to 2 (C)\\n'
 1265        >>> g.destinationsListing(g.destinationsFrom('B'))
 1266        '  south to 0 (A)\\n  east to 2 (C)\\n'
 1267        >>> g.destinationsListing({})
 1268        '  (none)\\n'
 1269        >>> g.createZone('zone', 0)
 1270        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 1271 annotations=[])
 1272        >>> g.addDecisionToZone(0, 'zone')
 1273        >>> g.destinationsListing(g.destinationsFrom('B'))
 1274        '  south to 0 (zone::A)\\n  east to 2 (C)\\n'
 1275        """
 1276        ind = ' ' * indent
 1277        if len(destinations) == 0:
 1278            return ind + '(none)\n'
 1279        else:
 1280            result = ''
 1281            for transition, dID in destinations.items():
 1282                line = f"{transition} to {self.identityOf(dID, includeZones)}"
 1283                result += ind + line + '\n'
 1284            return result
 1285
 1286    def domainFor(self, decision: base.AnyDecisionSpecifier) -> base.Domain:
 1287        """
 1288        Returns the domain that a decision belongs to.
 1289        """
 1290        dID = self.resolveDecision(decision)
 1291        return self.nodes[dID]['domain']
 1292
 1293    def allDecisionsInDomain(
 1294        self,
 1295        domain: base.Domain
 1296    ) -> Set[base.DecisionID]:
 1297        """
 1298        Returns the set of all `DecisionID`s for decisions in the
 1299        specified domain.
 1300        """
 1301        return set(dID for dID in self if self.nodes[dID]['domain'] == domain)
 1302
 1303    def destination(
 1304        self,
 1305        decision: base.AnyDecisionSpecifier,
 1306        transition: base.Transition
 1307    ) -> base.DecisionID:
 1308        """
 1309        Overrides base `UniqueExitsGraph.destination` to raise
 1310        `MissingDecisionError` or `MissingTransitionError` as
 1311        appropriate, and to work with an `AnyDecisionSpecifier`.
 1312        """
 1313        dID = self.resolveDecision(decision)
 1314        try:
 1315            return super().destination(dID, transition)
 1316        except KeyError:
 1317            raise MissingTransitionError(
 1318                f"Transition {transition!r} does not exist at decision"
 1319                f" {self.identityOf(dID)}."
 1320            )
 1321
 1322    def getDestination(
 1323        self,
 1324        decision: base.AnyDecisionSpecifier,
 1325        transition: base.Transition,
 1326        default: Any = None
 1327    ) -> Optional[base.DecisionID]:
 1328        """
 1329        Overrides base `UniqueExitsGraph.getDestination` with different
 1330        argument names, since those matter for the edit DSL.
 1331        """
 1332        dID = self.resolveDecision(decision)
 1333        return super().getDestination(dID, transition)
 1334
 1335    def destinationsFrom(
 1336        self,
 1337        decision: base.AnyDecisionSpecifier
 1338    ) -> Dict[base.Transition, base.DecisionID]:
 1339        """
 1340        Override that just changes the type of the exception from a
 1341        `KeyError` to a `MissingDecisionError` when the source does not
 1342        exist.
 1343        """
 1344        dID = self.resolveDecision(decision)
 1345        return super().destinationsFrom(dID)
 1346
 1347    def newTransitionNameFrom(
 1348        self,
 1349        decision: base.AnyDecisionSpecifier,
 1350        baseName: base.Transition
 1351    ) -> base.Transition:
 1352        """
 1353        Given a decision and a desired transition name, returns a
 1354        transition name that doesn't match any existing transition from
 1355        the specified destination. Returns the given name as-is if it
 1356        doesn't collide with an existing transition name, otherwise
 1357        appends a number to it, starting with 2. Note that a number will
 1358        be appended even if the base name already has a number at the
 1359        end, so for example if a decision already has 'up' and 'up2' as
 1360        options, asking for a new transition based on 'up' will give
 1361        'up3', but asking for a new transition based on 'up2' will give
 1362        'up22'.
 1363
 1364        Some examples:
 1365
 1366        >>> g = DecisionGraph()
 1367        >>> g.addDecision('A')
 1368        0
 1369        >>> g.newTransitionNameFrom('A', 'up')
 1370        'up'
 1371        >>> g.addDecision('B')
 1372        1
 1373        >>> g.addTransition('A', 'up', 'B')
 1374        >>> g.newTransitionNameFrom('A', 'up')
 1375        'up2'
 1376        >>> g.addTransition('A', 'up2', 'B')
 1377        >>> g.newTransitionNameFrom('A', 'up')
 1378        'up3'
 1379        >>> g.newTransitionNameFrom('A', 'up2')  # suffixes not parsed
 1380        'up22'
 1381        """
 1382        already = self.destinationsFrom(decision)
 1383        candidate = baseName
 1384        i = 2
 1385        while candidate in already:
 1386            candidate = baseName + str(i)
 1387            i += 1
 1388        return candidate 
 1389
 1390    def bothEnds(
 1391        self,
 1392        decision: base.AnyDecisionSpecifier,
 1393        transition: base.Transition
 1394    ) -> Set[base.DecisionID]:
 1395        """
 1396        Returns a set containing the `DecisionID`(s) for both the start
 1397        and end of the specified transition. Raises a
 1398        `MissingDecisionError` or `MissingTransitionError`if the
 1399        specified decision and/or transition do not exist.
 1400
 1401        Note that for actions since the source and destination are the
 1402        same, the set will have only one element.
 1403        """
 1404        dID = self.resolveDecision(decision)
 1405        result = {dID}
 1406        dest = self.destination(dID, transition)
 1407        if dest is not None:
 1408            result.add(dest)
 1409        return result
 1410
 1411    def decisionActions(
 1412        self,
 1413        decision: base.AnyDecisionSpecifier
 1414    ) -> Set[base.Transition]:
 1415        """
 1416        Retrieves the set of self-edges at a decision. Editing the set
 1417        will not affect the graph.
 1418
 1419        Example:
 1420
 1421        >>> g = DecisionGraph()
 1422        >>> g.addDecision('A')
 1423        0
 1424        >>> g.addDecision('B')
 1425        1
 1426        >>> g.addDecision('C')
 1427        2
 1428        >>> g.addAction('A', 'action1')
 1429        >>> g.addAction('A', 'action2')
 1430        >>> g.addAction('B', 'action3')
 1431        >>> sorted(g.decisionActions('A'))
 1432        ['action1', 'action2']
 1433        >>> g.decisionActions('B')
 1434        {'action3'}
 1435        >>> g.decisionActions('C')
 1436        set()
 1437        """
 1438        result = set()
 1439        dID = self.resolveDecision(decision)
 1440        for transition, dest in self.destinationsFrom(dID).items():
 1441            if dest == dID:
 1442                result.add(transition)
 1443        return result
 1444
 1445    def getTransitionProperties(
 1446        self,
 1447        decision: base.AnyDecisionSpecifier,
 1448        transition: base.Transition
 1449    ) -> TransitionProperties:
 1450        """
 1451        Returns a dictionary containing transition properties for the
 1452        specified transition from the specified decision. The properties
 1453        included are:
 1454
 1455        - 'requirement': The requirement for the transition.
 1456        - 'consequence': Any consequence of the transition.
 1457        - 'tags': Any tags applied to the transition.
 1458        - 'annotations': Any annotations on the transition.
 1459
 1460        The reciprocal of the transition is not included.
 1461
 1462        The result is a clone of the stored properties; edits to the
 1463        dictionary will NOT modify the graph.
 1464        """
 1465        dID = self.resolveDecision(decision)
 1466        dest = self.destination(dID, transition)
 1467
 1468        info: TransitionProperties = copy.deepcopy(
 1469            self.edges[dID, dest, transition]  # type:ignore
 1470        )
 1471        return {
 1472            'requirement': info.get('requirement', base.ReqNothing()),
 1473            'consequence': info.get('consequence', []),
 1474            'tags': info.get('tags', {}),
 1475            'annotations': info.get('annotations', [])
 1476        }
 1477
 1478    def setTransitionProperties(
 1479        self,
 1480        decision: base.AnyDecisionSpecifier,
 1481        transition: base.Transition,
 1482        requirement: Optional[base.Requirement] = None,
 1483        consequence: Optional[base.Consequence] = None,
 1484        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 1485        annotations: Optional[List[base.Annotation]] = None
 1486    ) -> None:
 1487        """
 1488        Sets one or more transition properties all at once. Can be used
 1489        to set the requirement, consequence, tags, and/or annotations.
 1490        Old values are overwritten, although if `None`s are provided (or
 1491        arguments are omitted), corresponding properties are not
 1492        updated.
 1493
 1494        To add tags or annotations to existing tags/annotations instead
 1495        of replacing them, use `tagTransition` or `annotateTransition`
 1496        instead.
 1497        """
 1498        dID = self.resolveDecision(decision)
 1499        if requirement is not None:
 1500            self.setTransitionRequirement(dID, transition, requirement)
 1501        if consequence is not None:
 1502            self.setConsequence(dID, transition, consequence)
 1503        if tags is not None:
 1504            dest = self.destination(dID, transition)
 1505            # TODO: Submit pull request to update MultiDiGraph stubs in
 1506            # types-networkx to include OutMultiEdgeView that accepts
 1507            # from/to/key tuples as indices.
 1508            info = cast(
 1509                TransitionProperties,
 1510                self.edges[dID, dest, transition]  # type:ignore
 1511            )
 1512            info['tags'] = tags
 1513        if annotations is not None:
 1514            dest = self.destination(dID, transition)
 1515            info = cast(
 1516                TransitionProperties,
 1517                self.edges[dID, dest, transition]  # type:ignore
 1518            )
 1519            info['annotations'] = annotations
 1520
 1521    def getTransitionRequirement(
 1522        self,
 1523        decision: base.AnyDecisionSpecifier,
 1524        transition: base.Transition
 1525    ) -> base.Requirement:
 1526        """
 1527        Returns the `Requirement` for accessing a specific transition at
 1528        a specific decision. For transitions which don't have
 1529        requirements, returns a `ReqNothing` instance.
 1530        """
 1531        dID = self.resolveDecision(decision)
 1532        dest = self.destination(dID, transition)
 1533
 1534        info = cast(
 1535            TransitionProperties,
 1536            self.edges[dID, dest, transition]  # type:ignore
 1537        )
 1538
 1539        return info.get('requirement', base.ReqNothing())
 1540
 1541    def setTransitionRequirement(
 1542        self,
 1543        decision: base.AnyDecisionSpecifier,
 1544        transition: base.Transition,
 1545        requirement: Optional[base.Requirement]
 1546    ) -> None:
 1547        """
 1548        Sets the `Requirement` for accessing a specific transition at
 1549        a specific decision. Raises a `KeyError` if the decision or
 1550        transition does not exist.
 1551
 1552        Deletes the requirement if `None` is given as the requirement.
 1553
 1554        Use `parsing.ParseFormat.parseRequirement` first if you have a
 1555        requirement in string format.
 1556
 1557        Does not raise an error if deletion is requested for a
 1558        non-existent requirement, and silently overwrites any previous
 1559        requirement.
 1560        """
 1561        dID = self.resolveDecision(decision)
 1562
 1563        dest = self.destination(dID, transition)
 1564
 1565        info = cast(
 1566            TransitionProperties,
 1567            self.edges[dID, dest, transition]  # type:ignore
 1568        )
 1569
 1570        if requirement is None:
 1571            try:
 1572                del info['requirement']
 1573            except KeyError:
 1574                pass
 1575        else:
 1576            if not isinstance(requirement, base.Requirement):
 1577                raise TypeError(
 1578                    f"Invalid requirement type: {type(requirement)}"
 1579                )
 1580
 1581            info['requirement'] = requirement
 1582
 1583    def getConsequence(
 1584        self,
 1585        decision: base.AnyDecisionSpecifier,
 1586        transition: base.Transition
 1587    ) -> base.Consequence:
 1588        """
 1589        Retrieves the consequence of a transition.
 1590
 1591        A `KeyError` is raised if the specified decision/transition
 1592        combination doesn't exist.
 1593        """
 1594        dID = self.resolveDecision(decision)
 1595
 1596        dest = self.destination(dID, transition)
 1597
 1598        info = cast(
 1599            TransitionProperties,
 1600            self.edges[dID, dest, transition]  # type:ignore
 1601        )
 1602
 1603        return info.get('consequence', [])
 1604
 1605    def addConsequence(
 1606        self,
 1607        decision: base.AnyDecisionSpecifier,
 1608        transition: base.Transition,
 1609        consequence: base.Consequence
 1610    ) -> Tuple[int, int]:
 1611        """
 1612        Adds the given `Consequence` to the consequence list for the
 1613        specified transition, extending that list at the end. Note that
 1614        this does NOT make a copy of the consequence, so it should not
 1615        be used to copy consequences from one transition to another
 1616        without making a deep copy first.
 1617
 1618        A `MissingDecisionError` or a `MissingTransitionError` is raised
 1619        if the specified decision/transition combination doesn't exist.
 1620
 1621        Returns a pair of integers indicating the minimum and maximum
 1622        depth-first-traversal-indices of the added consequence part(s)
 1623        (inclusive).
 1624
 1625        The outer consequence list itself (index 0) is not counted.
 1626
 1627        >>> d = DecisionGraph()
 1628        >>> d.addDecision('A')
 1629        0
 1630        >>> d.addDecision('B')
 1631        1
 1632        >>> d.addTransition('A', 'fwd', 'B', 'rev')
 1633        >>> d.addConsequence('A', 'fwd', [base.effect(gain='sword')])
 1634        (1, 1)
 1635        >>> d.addConsequence('B', 'rev', [base.effect(lose='sword')])
 1636        (1, 1)
 1637        >>> ef = d.getConsequence('A', 'fwd')
 1638        >>> er = d.getConsequence('B', 'rev')
 1639        >>> ef == [base.effect(gain='sword')]
 1640        True
 1641        >>> er == [base.effect(lose='sword')]
 1642        True
 1643        >>> d.addConsequence('A', 'fwd', [base.effect(deactivate=True)])
 1644        (2, 2)
 1645        >>> ef = d.getConsequence('A', 'fwd')
 1646        >>> ef == [base.effect(gain='sword'), base.effect(deactivate=True)]
 1647        True
 1648        >>> d.addConsequence(
 1649        ...     'A',
 1650        ...     'fwd',  # adding to consequence with 3 parts already
 1651        ...     [  # outer list not counted because it merges
 1652        ...         base.challenge(  # 1 part
 1653        ...             None,
 1654        ...             0,
 1655        ...             [base.effect(gain=('flowers', 3))],  # 2 parts
 1656        ...             [base.effect(gain=('flowers', 1))]  # 2 parts
 1657        ...         )
 1658        ...     ]
 1659        ... )  # note indices below are inclusive; indices are 3, 4, 5, 6, 7
 1660        (3, 7)
 1661        """
 1662        dID = self.resolveDecision(decision)
 1663
 1664        dest = self.destination(dID, transition)
 1665
 1666        info = cast(
 1667            TransitionProperties,
 1668            self.edges[dID, dest, transition]  # type:ignore
 1669        )
 1670
 1671        existing = info.setdefault('consequence', [])
 1672        startIndex = base.countParts(existing)
 1673        existing.extend(consequence)
 1674        endIndex = base.countParts(existing) - 1
 1675        return (startIndex, endIndex)
 1676
 1677    def setConsequence(
 1678        self,
 1679        decision: base.AnyDecisionSpecifier,
 1680        transition: base.Transition,
 1681        consequence: base.Consequence
 1682    ) -> None:
 1683        """
 1684        Replaces the transition consequence for the given transition at
 1685        the given decision. Any previous consequence is discarded. See
 1686        `Consequence` for the structure of these. Note that this does
 1687        NOT make a copy of the consequence, do that first to avoid
 1688        effect-entanglement if you're copying a consequence.
 1689
 1690        A `MissingDecisionError` or a `MissingTransitionError` is raised
 1691        if the specified decision/transition combination doesn't exist.
 1692        """
 1693        dID = self.resolveDecision(decision)
 1694
 1695        dest = self.destination(dID, transition)
 1696
 1697        info = cast(
 1698            TransitionProperties,
 1699            self.edges[dID, dest, transition]  # type:ignore
 1700        )
 1701
 1702        info['consequence'] = consequence
 1703
 1704    def addEquivalence(
 1705        self,
 1706        requirement: base.Requirement,
 1707        capabilityOrMechanismState: Union[
 1708            base.Capability,
 1709            Tuple[base.MechanismID, base.MechanismState]
 1710        ]
 1711    ) -> None:
 1712        """
 1713        Adds the given requirement as an equivalence for the given
 1714        capability or the given mechanism state. Note that having a
 1715        capability via an equivalence does not count as actually having
 1716        that capability; it only counts for the purpose of satisfying
 1717        `Requirement`s.
 1718
 1719        Note also that because a mechanism-based requirement looks up
 1720        the specific mechanism locally based on a name, an equivalence
 1721        defined in one location may affect mechanism requirements in
 1722        other locations unless the mechanism name in the requirement is
 1723        zone-qualified to be specific. But in such situations the base
 1724        mechanism would have caused issues in any case.
 1725        """
 1726        self.equivalences.setdefault(
 1727            capabilityOrMechanismState,
 1728            set()
 1729        ).add(requirement)
 1730
 1731    def removeEquivalence(
 1732        self,
 1733        requirement: base.Requirement,
 1734        capabilityOrMechanismState: Union[
 1735            base.Capability,
 1736            Tuple[base.MechanismID, base.MechanismState]
 1737        ]
 1738    ) -> None:
 1739        """
 1740        Removes an equivalence. Raises a `KeyError` if no such
 1741        equivalence existed.
 1742        """
 1743        self.equivalences[capabilityOrMechanismState].remove(requirement)
 1744
 1745    def hasAnyEquivalents(
 1746        self,
 1747        capabilityOrMechanismState: Union[
 1748            base.Capability,
 1749            Tuple[base.MechanismID, base.MechanismState]
 1750        ]
 1751    ) -> bool:
 1752        """
 1753        Returns `True` if the given capability or mechanism state has at
 1754        least one equivalence.
 1755        """
 1756        return capabilityOrMechanismState in self.equivalences
 1757
 1758    def allEquivalents(
 1759        self,
 1760        capabilityOrMechanismState: Union[
 1761            base.Capability,
 1762            Tuple[base.MechanismID, base.MechanismState]
 1763        ]
 1764    ) -> Set[base.Requirement]:
 1765        """
 1766        Returns the set of equivalences for the given capability. This is
 1767        a live set which may be modified (it's probably better to use
 1768        `addEquivalence` and `removeEquivalence` instead...).
 1769        """
 1770        return self.equivalences.setdefault(
 1771            capabilityOrMechanismState,
 1772            set()
 1773        )
 1774
 1775    def reversionType(self, name: str, equivalentTo: Set[str]) -> None:
 1776        """
 1777        Specifies a new reversion type, so that when used in a reversion
 1778        aspects set with a colon before the name, all items in the
 1779        `equivalentTo` value will be added to that set. These may
 1780        include other custom reversion type names (with the colon) but
 1781        take care not to create an equivalence loop which would result
 1782        in a crash.
 1783
 1784        If you re-use the same name, it will override the old equivalence
 1785        for that name.
 1786        """
 1787        self.reversionTypes[name] = equivalentTo
 1788
 1789    def addAction(
 1790        self,
 1791        decision: base.AnyDecisionSpecifier,
 1792        action: base.Transition,
 1793        requires: Optional[base.Requirement] = None,
 1794        consequence: Optional[base.Consequence] = None,
 1795        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 1796        annotations: Optional[List[base.Annotation]] = None,
 1797    ) -> None:
 1798        """
 1799        Adds the given action as a possibility at the given decision. An
 1800        action is just a self-edge, which can have requirements like any
 1801        edge, and which can have consequences like any edge.
 1802        The optional arguments are given to `setTransitionRequirement`
 1803        and `setConsequence`; see those functions for descriptions
 1804        of what they mean.
 1805
 1806        Raises a `KeyError` if a transition with the given name already
 1807        exists at the given decision.
 1808        """
 1809        if tags is None:
 1810            tags = {}
 1811        if annotations is None:
 1812            annotations = []
 1813
 1814        dID = self.resolveDecision(decision)
 1815
 1816        self.add_edge(
 1817            dID,
 1818            dID,
 1819            key=action,
 1820            tags=tags,
 1821            annotations=annotations
 1822        )
 1823        self.setTransitionRequirement(dID, action, requires)
 1824        if consequence is not None:
 1825            self.setConsequence(dID, action, consequence)
 1826
 1827    def tagDecision(
 1828        self,
 1829        decision: base.AnyDecisionSpecifier,
 1830        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
 1831        tagValue: Union[
 1832            base.TagValue,
 1833            type[base.NoTagValue]
 1834        ] = base.NoTagValue
 1835    ) -> None:
 1836        """
 1837        Adds a tag (or many tags from a dictionary of tags) to a
 1838        decision, using `1` as the value if no value is provided. It's
 1839        a `ValueError` to provide a value when a dictionary of tags is
 1840        provided to set multiple tags at once.
 1841
 1842        Note that certain tags have special meanings:
 1843
 1844        - 'unconfirmed' is used for decisions that represent unconfirmed
 1845            parts of the graph (this is separate from the 'unknown'
 1846            and/or 'hypothesized' exploration statuses, which are only
 1847            tracked in a `DiscreteExploration`, not in a `DecisionGraph`).
 1848            Various methods require this tag and many also add or remove
 1849            it.
 1850        """
 1851        if isinstance(tagOrTags, base.Tag):
 1852            if tagValue is base.NoTagValue:
 1853                tagValue = 1
 1854
 1855            # Not sure why this cast is necessary given the `if` above...
 1856            tagValue = cast(base.TagValue, tagValue)
 1857
 1858            tagOrTags = {tagOrTags: tagValue}
 1859
 1860        elif tagValue is not base.NoTagValue:
 1861            raise ValueError(
 1862                "Provided a dictionary to update multiple tags, but"
 1863                " also a tag value."
 1864            )
 1865
 1866        dID = self.resolveDecision(decision)
 1867
 1868        tagsAlready = self.nodes[dID].setdefault('tags', {})
 1869        tagsAlready.update(tagOrTags)
 1870
 1871    def untagDecision(
 1872        self,
 1873        decision: base.AnyDecisionSpecifier,
 1874        tag: base.Tag
 1875    ) -> Union[base.TagValue, type[base.NoTagValue]]:
 1876        """
 1877        Removes a tag from a decision. Returns the tag's old value if
 1878        the tag was present and got removed, or `NoTagValue` if the tag
 1879        wasn't present.
 1880        """
 1881        dID = self.resolveDecision(decision)
 1882
 1883        target = self.nodes[dID]['tags']
 1884        try:
 1885            return target.pop(tag)
 1886        except KeyError:
 1887            return base.NoTagValue
 1888
 1889    def decisionTags(
 1890        self,
 1891        decision: base.AnyDecisionSpecifier
 1892    ) -> Dict[base.Tag, base.TagValue]:
 1893        """
 1894        Returns the dictionary of tags for a decision. Edits to the
 1895        returned value will be applied to the graph.
 1896        """
 1897        dID = self.resolveDecision(decision)
 1898
 1899        return self.nodes[dID]['tags']
 1900
 1901    def annotateDecision(
 1902        self,
 1903        decision: base.AnyDecisionSpecifier,
 1904        annotationOrAnnotations: Union[
 1905            base.Annotation,
 1906            Sequence[base.Annotation]
 1907        ]
 1908    ) -> None:
 1909        """
 1910        Adds an annotation to a decision's annotations list.
 1911        """
 1912        dID = self.resolveDecision(decision)
 1913
 1914        if isinstance(annotationOrAnnotations, base.Annotation):
 1915            annotationOrAnnotations = [annotationOrAnnotations]
 1916        self.nodes[dID]['annotations'].extend(annotationOrAnnotations)
 1917
 1918    def decisionAnnotations(
 1919        self,
 1920        decision: base.AnyDecisionSpecifier
 1921    ) -> List[base.Annotation]:
 1922        """
 1923        Returns the list of annotations for the specified decision.
 1924        Modifying the list affects the graph.
 1925        """
 1926        dID = self.resolveDecision(decision)
 1927
 1928        return self.nodes[dID]['annotations']
 1929
 1930    def tagTransition(
 1931        self,
 1932        decision: base.AnyDecisionSpecifier,
 1933        transition: base.Transition,
 1934        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
 1935        tagValue: Union[
 1936            base.TagValue,
 1937            type[base.NoTagValue]
 1938        ] = base.NoTagValue
 1939    ) -> None:
 1940        """
 1941        Adds a tag (or each tag from a dictionary) to a transition
 1942        coming out of a specific decision. `1` will be used as the
 1943        default value if a single tag is supplied; supplying a tag value
 1944        when providing a dictionary of multiple tags to update is a
 1945        `ValueError`.
 1946
 1947        Note that certain transition tags have special meanings:
 1948        - 'trigger' causes any actions (but not normal transitions) that
 1949            it applies to to be automatically triggered when
 1950            `advanceSituation` is called and the decision they're
 1951            attached to is active in the new situation (as long as the
 1952            action's requirements are met). This happens once per
 1953            situation; use 'wait' steps to re-apply triggers.
 1954        """
 1955        dID = self.resolveDecision(decision)
 1956
 1957        dest = self.destination(dID, transition)
 1958        if isinstance(tagOrTags, base.Tag):
 1959            if tagValue is base.NoTagValue:
 1960                tagValue = 1
 1961
 1962            # Not sure why this is necessary given the `if` above...
 1963            tagValue = cast(base.TagValue, tagValue)
 1964
 1965            tagOrTags = {tagOrTags: tagValue}
 1966        elif tagValue is not base.NoTagValue:
 1967            raise ValueError(
 1968                "Provided a dictionary to update multiple tags, but"
 1969                " also a tag value."
 1970            )
 1971
 1972        info = cast(
 1973            TransitionProperties,
 1974            self.edges[dID, dest, transition]  # type:ignore
 1975        )
 1976
 1977        info.setdefault('tags', {}).update(tagOrTags)
 1978
 1979    def untagTransition(
 1980        self,
 1981        decision: base.AnyDecisionSpecifier,
 1982        transition: base.Transition,
 1983        tagOrTags: Union[base.Tag, Set[base.Tag]]
 1984    ) -> None:
 1985        """
 1986        Removes a tag (or each tag in a set) from a transition coming out
 1987        of a specific decision. Raises a `KeyError` if (one of) the
 1988        specified tag(s) is not currently applied to the specified
 1989        transition.
 1990        """
 1991        dID = self.resolveDecision(decision)
 1992
 1993        dest = self.destination(dID, transition)
 1994        if isinstance(tagOrTags, base.Tag):
 1995            tagOrTags = {tagOrTags}
 1996
 1997        info = cast(
 1998            TransitionProperties,
 1999            self.edges[dID, dest, transition]  # type:ignore
 2000        )
 2001        tagsAlready = info.setdefault('tags', {})
 2002
 2003        for tag in tagOrTags:
 2004            tagsAlready.pop(tag)
 2005
 2006    def transitionTags(
 2007        self,
 2008        decision: base.AnyDecisionSpecifier,
 2009        transition: base.Transition
 2010    ) -> Dict[base.Tag, base.TagValue]:
 2011        """
 2012        Returns the dictionary of tags for a transition. Edits to the
 2013        returned dictionary will be applied to the graph.
 2014        """
 2015        dID = self.resolveDecision(decision)
 2016
 2017        dest = self.destination(dID, transition)
 2018        info = cast(
 2019            TransitionProperties,
 2020            self.edges[dID, dest, transition]  # type:ignore
 2021        )
 2022        return info.setdefault('tags', {})
 2023
 2024    def annotateTransition(
 2025        self,
 2026        decision: base.AnyDecisionSpecifier,
 2027        transition: base.Transition,
 2028        annotations: Union[base.Annotation, Sequence[base.Annotation]]
 2029    ) -> None:
 2030        """
 2031        Adds an annotation (or a sequence of annotations) to a
 2032        transition's annotations list.
 2033        """
 2034        dID = self.resolveDecision(decision)
 2035
 2036        dest = self.destination(dID, transition)
 2037        if isinstance(annotations, base.Annotation):
 2038            annotations = [annotations]
 2039        info = cast(
 2040            TransitionProperties,
 2041            self.edges[dID, dest, transition]  # type:ignore
 2042        )
 2043        info['annotations'].extend(annotations)
 2044
 2045    def transitionAnnotations(
 2046        self,
 2047        decision: base.AnyDecisionSpecifier,
 2048        transition: base.Transition
 2049    ) -> List[base.Annotation]:
 2050        """
 2051        Returns the annotation list for a specific transition at a
 2052        specific decision. Editing the list affects the graph.
 2053        """
 2054        dID = self.resolveDecision(decision)
 2055
 2056        dest = self.destination(dID, transition)
 2057        info = cast(
 2058            TransitionProperties,
 2059            self.edges[dID, dest, transition]  # type:ignore
 2060        )
 2061        return info['annotations']
 2062
 2063    def annotateZone(
 2064        self,
 2065        zone: base.Zone,
 2066        annotations: Union[base.Annotation, Sequence[base.Annotation]]
 2067    ) -> None:
 2068        """
 2069        Adds an annotation (or many annotations from a sequence) to a
 2070        zone.
 2071
 2072        Raises a `MissingZoneError` if the specified zone does not exist.
 2073        """
 2074        if zone not in self.zones:
 2075            raise MissingZoneError(
 2076                f"Can't add annotation(s) to zone {zone!r} because that"
 2077                f" zone doesn't exist yet."
 2078            )
 2079
 2080        if isinstance(annotations, base.Annotation):
 2081            annotations = [ annotations ]
 2082
 2083        self.zones[zone].annotations.extend(annotations)
 2084
 2085    def zoneAnnotations(self, zone: base.Zone) -> List[base.Annotation]:
 2086        """
 2087        Returns the list of annotations for the specified zone (empty if
 2088        none have been added yet).
 2089        """
 2090        return self.zones[zone].annotations
 2091
 2092    def tagZone(
 2093        self,
 2094        zone: base.Zone,
 2095        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
 2096        tagValue: Union[
 2097            base.TagValue,
 2098            type[base.NoTagValue]
 2099        ] = base.NoTagValue
 2100    ) -> None:
 2101        """
 2102        Adds a tag (or many tags from a dictionary of tags) to a
 2103        zone, using `1` as the value if no value is provided. It's
 2104        a `ValueError` to provide a value when a dictionary of tags is
 2105        provided to set multiple tags at once.
 2106
 2107        Raises a `MissingZoneError` if the specified zone does not exist.
 2108        """
 2109        if zone not in self.zones:
 2110            raise MissingZoneError(
 2111                f"Can't add tag(s) to zone {zone!r} because that zone"
 2112                f" doesn't exist yet."
 2113            )
 2114
 2115        if isinstance(tagOrTags, base.Tag):
 2116            if tagValue is base.NoTagValue:
 2117                tagValue = 1
 2118
 2119            # Not sure why this cast is necessary given the `if` above...
 2120            tagValue = cast(base.TagValue, tagValue)
 2121
 2122            tagOrTags = {tagOrTags: tagValue}
 2123
 2124        elif tagValue is not base.NoTagValue:
 2125            raise ValueError(
 2126                "Provided a dictionary to update multiple tags, but"
 2127                " also a tag value."
 2128            )
 2129
 2130        tagsAlready = self.zones[zone].tags
 2131        tagsAlready.update(tagOrTags)
 2132
 2133    def untagZone(
 2134        self,
 2135        zone: base.Zone,
 2136        tag: base.Tag
 2137    ) -> Union[base.TagValue, type[base.NoTagValue]]:
 2138        """
 2139        Removes a tag from a zone. Returns the tag's old value if the
 2140        tag was present and got removed, or `NoTagValue` if the tag
 2141        wasn't present.
 2142
 2143        Raises a `MissingZoneError` if the specified zone does not exist.
 2144        """
 2145        if zone not in self.zones:
 2146            raise MissingZoneError(
 2147                f"Can't remove tag {tag!r} from zone {zone!r} because"
 2148                f" that zone doesn't exist yet."
 2149            )
 2150        target = self.zones[zone].tags
 2151        try:
 2152            return target.pop(tag)
 2153        except KeyError:
 2154            return base.NoTagValue
 2155
 2156    def zoneTags(
 2157        self,
 2158        zone: base.Zone
 2159    ) -> Dict[base.Tag, base.TagValue]:
 2160        """
 2161        Returns the dictionary of tags for a zone. Edits to the returned
 2162        value will be applied to the graph. Returns an empty tags
 2163        dictionary if called on a zone that didn't have any tags
 2164        previously, but raises a `MissingZoneError` if attempting to get
 2165        tags for a zone which does not exist.
 2166
 2167        For example:
 2168
 2169        >>> g = DecisionGraph()
 2170        >>> g.addDecision('A')
 2171        0
 2172        >>> g.addDecision('B')
 2173        1
 2174        >>> g.createZone('Zone')
 2175        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2176 annotations=[])
 2177        >>> g.tagZone('Zone', 'color', 'blue')
 2178        >>> g.tagZone(
 2179        ...     'Zone',
 2180        ...     {'shape': 'square', 'color': 'red', 'sound': 'loud'}
 2181        ... )
 2182        >>> g.untagZone('Zone', 'sound')
 2183        'loud'
 2184        >>> g.zoneTags('Zone')
 2185        {'color': 'red', 'shape': 'square'}
 2186        """
 2187        if zone in self.zones:
 2188            return self.zones[zone].tags
 2189        else:
 2190            raise MissingZoneError(
 2191                f"Tags for zone {zone!r} don't exist because that"
 2192                f" zone has not been created yet."
 2193            )
 2194
 2195    def createZone(self, zone: base.Zone, level: int = 0) -> base.ZoneInfo:
 2196        """
 2197        Creates an empty zone with the given name at the given level
 2198        (default 0). Raises a `ZoneCollisionError` if that zone name is
 2199        already in use (at any level), including if it's in use by a
 2200        decision.
 2201
 2202        Raises an `InvalidLevelError` if the level value is less than 0.
 2203
 2204        Returns the `ZoneInfo` for the new blank zone.
 2205
 2206        For example:
 2207
 2208        >>> d = DecisionGraph()
 2209        >>> d.createZone('Z', 0)
 2210        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2211 annotations=[])
 2212        >>> d.getZoneInfo('Z')
 2213        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2214 annotations=[])
 2215        >>> d.createZone('Z2', 0)
 2216        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2217 annotations=[])
 2218        >>> d.createZone('Z3', -1)  # level -1 is not valid (must be >= 0)
 2219        Traceback (most recent call last):
 2220        ...
 2221        exploration.core.InvalidLevelError...
 2222        >>> d.createZone('Z2')  # Name Z2 is already in use
 2223        Traceback (most recent call last):
 2224        ...
 2225        exploration.core.ZoneCollisionError...
 2226        """
 2227        if level < 0:
 2228            raise InvalidLevelError(
 2229                "Cannot create a zone with a negative level."
 2230            )
 2231        if zone in self.zones:
 2232            raise ZoneCollisionError(f"Zone {zone!r} already exists.")
 2233        if zone in self:
 2234            raise ZoneCollisionError(
 2235                f"A decision named {zone!r} already exists, so a zone"
 2236                f" with that name cannot be created."
 2237            )
 2238        info: base.ZoneInfo = base.ZoneInfo(
 2239            level=level,
 2240            parents=set(),
 2241            contents=set(),
 2242            tags={},
 2243            annotations=[]
 2244        )
 2245        self.zones[zone] = info
 2246        return info
 2247
 2248    def getZoneInfo(self, zone: base.Zone) -> Optional[base.ZoneInfo]:
 2249        """
 2250        Returns the `ZoneInfo` (level, parents, and contents) for the
 2251        specified zone, or `None` if that zone does not exist.
 2252
 2253        For example:
 2254
 2255        >>> d = DecisionGraph()
 2256        >>> d.createZone('Z', 0)
 2257        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2258 annotations=[])
 2259        >>> d.getZoneInfo('Z')
 2260        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2261 annotations=[])
 2262        >>> d.createZone('Z2', 0)
 2263        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2264 annotations=[])
 2265        >>> d.getZoneInfo('Z2')
 2266        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2267 annotations=[])
 2268        """
 2269        return self.zones.get(zone)
 2270
 2271    def deleteZone(self, zone: base.Zone) -> base.ZoneInfo:
 2272        """
 2273        Deletes the specified zone, returning a `ZoneInfo` object with
 2274        the information on the level, parents, and contents of that zone.
 2275
 2276        Raises a `MissingZoneError` if the zone in question does not
 2277        exist.
 2278
 2279        The zone will be removed as a child/parent of any zones that used
 2280        to contain it or be contained in it.
 2281
 2282        For example:
 2283
 2284        >>> d = DecisionGraph()
 2285        >>> d.createZone('Z', 0)
 2286        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2287 annotations=[])
 2288        >>> d.getZoneInfo('Z')
 2289        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2290 annotations=[])
 2291        >>> d.deleteZone('Z')
 2292        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2293 annotations=[])
 2294        >>> d.getZoneInfo('Z') is None  # no info any more
 2295        True
 2296        >>> d.deleteZone('Z')  # can't re-delete
 2297        Traceback (most recent call last):
 2298        ...
 2299        exploration.core.MissingZoneError...
 2300        """
 2301        info = self.getZoneInfo(zone)
 2302        if info is None:
 2303            raise MissingZoneError(
 2304                f"Cannot delete zone {zone!r}: it does not exist."
 2305            )
 2306        for sub in info.contents:
 2307            if 'zones' in self.nodes[sub]:
 2308                try:
 2309                    self.nodes[sub]['zones'].remove(zone)
 2310                except KeyError:
 2311                    pass
 2312        del self.zones[zone]
 2313        # Clean up child/contents info in ALL other zones
 2314        for otherZoneInfo in self.zones.values():
 2315            if zone in otherZoneInfo.parents:
 2316                otherZoneInfo.parents.remove(zone)
 2317            if zone in otherZoneInfo.contents:
 2318                otherZoneInfo.contents.remove(zone)
 2319        return info
 2320
 2321    def addDecisionToZone(
 2322        self,
 2323        decision: base.AnyDecisionSpecifier,
 2324        zone: base.Zone
 2325    ) -> None:
 2326        """
 2327        Adds a decision directly to a zone. Should normally only be used
 2328        with level-0 zones. Raises a `MissingZoneError` if the specified
 2329        zone did not already exist.
 2330
 2331        For example:
 2332
 2333        >>> d = DecisionGraph()
 2334        >>> d.addDecision('A')
 2335        0
 2336        >>> d.addDecision('B')
 2337        1
 2338        >>> d.addDecision('C')
 2339        2
 2340        >>> d.createZone('Z', 0)
 2341        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2342 annotations=[])
 2343        >>> d.addDecisionToZone('A', 'Z')
 2344        >>> d.getZoneInfo('Z')
 2345        ZoneInfo(level=0, parents=set(), contents={0}, tags={},\
 2346 annotations=[])
 2347        >>> d.addDecisionToZone('B', 'Z')
 2348        >>> d.getZoneInfo('Z')
 2349        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
 2350 annotations=[])
 2351        """
 2352        dID = self.resolveDecision(decision)
 2353
 2354        if zone not in self.zones:
 2355            raise MissingZoneError(f"Zone {zone!r} does not exist.")
 2356
 2357        self.zones[zone].contents.add(dID)
 2358        self.nodes[dID].setdefault('zones', set()).add(zone)
 2359
 2360    def removeDecisionFromZone(
 2361        self,
 2362        decision: base.AnyDecisionSpecifier,
 2363        zone: base.Zone,
 2364        thorough: bool = False
 2365    ) -> bool:
 2366        """
 2367        Removes a decision from a zone if it had been in it, returning
 2368        True if that decision had been in that zone, and False if it was
 2369        not in that zone, including if that zone didn't exist.
 2370
 2371        Note that this only removes a decision from direct zone
 2372        membership. If the decision is a member of one or more zones
 2373        which are (directly or indirectly) sub-zones of the target zone,
 2374        the decision will remain in those zones, and will still be
 2375        indirectly part of the target zone afterwards. You can set
 2376        `thorough` to True to also remove the decision from any immediate
 2377        parents which are descendants of the specified zone, thereby
 2378        ensuring that it isn't afterwards even indirectly included in
 2379        that zone, even though this may affect membership in multiple
 2380        zones at different levels.
 2381
 2382        When 'thorough' is used the result is True even if the decision
 2383        had been an indirect member of the target zone; without it,
 2384        False is returned for indirect members.
 2385
 2386        Examples:
 2387
 2388        >>> g = DecisionGraph()
 2389        >>> g.addDecision('A')
 2390        0
 2391        >>> g.addDecision('B')
 2392        1
 2393        >>> g.createZone('level0', 0)
 2394        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2395 annotations=[])
 2396        >>> g.createZone('level1', 1)
 2397        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2398 annotations=[])
 2399        >>> g.createZone('level2', 2)
 2400        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2401 annotations=[])
 2402        >>> g.createZone('level3', 3)
 2403        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
 2404 annotations=[])
 2405        >>> g.addDecisionToZone('A', 'level0')
 2406        >>> g.addDecisionToZone('B', 'level0')
 2407        >>> g.addZoneToZone('level0', 'level1')
 2408        >>> g.addZoneToZone('level1', 'level2')
 2409        >>> g.addZoneToZone('level2', 'level3')
 2410        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
 2411        >>> g.removeDecisionFromZone('A', 'level1')
 2412        False
 2413        >>> g.zoneParents(0)
 2414        {'level0'}
 2415        >>> g.removeDecisionFromZone('A', 'level0')
 2416        True
 2417        >>> g.zoneParents(0)
 2418        set()
 2419        >>> g.removeDecisionFromZone('A', 'level0')
 2420        False
 2421        >>> g.removeDecisionFromZone('B', 'level0')
 2422        True
 2423        >>> g.zoneParents(1)
 2424        {'level2'}
 2425        >>> g.removeDecisionFromZone('B', 'level0')
 2426        False
 2427        >>> g.removeDecisionFromZone('B', 'level2')
 2428        True
 2429        >>> g.zoneParents(1)
 2430        set()
 2431
 2432        Example of 'thorough' argument:
 2433
 2434        >>> g = DecisionGraph()
 2435        >>> g.addDecision('A')
 2436        0
 2437        >>> g.addDecision('B')
 2438        1
 2439        >>> g.addDecision('C')
 2440        2
 2441        >>> g.createZone('level0', 0)
 2442        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2443 annotations=[])
 2444        >>> g.createZone('level1', 1)
 2445        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2446 annotations=[])
 2447        >>> g.addDecisionToZone('A', 'level0')
 2448        >>> g.addDecisionToZone('B', 'level0')
 2449        >>> g.addDecisionToZone('C', 'level0')
 2450        >>> g.addDecisionToZone('B', 'level1')  # also direct
 2451        >>> g.addDecisionToZone('C', 'level1')  # also direct
 2452        >>> g.addZoneToZone('level0', 'level1')
 2453        >>> g.removeDecisionFromZone('A', 'level1')  # indirect member
 2454        False
 2455        >>> g.allDecisionsInZone('level1')  # A is still in there indirectly
 2456        {0, 1, 2}
 2457        >>> g.removeDecisionFromZone('A', 'level1', True)
 2458        True
 2459        >>> g.allDecisionsInZone('level1')  # A is now gone
 2460        {1, 2}
 2461        >>> g.zoneParents(0)  # removed from 'level0'
 2462        set()
 2463        >>> g.removeDecisionFromZone('B', 'level1')  # not thorough
 2464        True
 2465        >>> g.allDecisionsInZone('level1')  # B still there indirectly
 2466        {1, 2}
 2467        >>> g.removeDecisionFromZone('B', 'level1', True)  # thorough
 2468        True
 2469        >>> g.allDecisionsInZone('level1')  # now gone
 2470        {2}
 2471        >>> g.removeDecisionFromZone('C', 'level1', True)  # 1st time
 2472        True
 2473        >>> g.allDecisionsInZone('level1')  # now gone
 2474        set()
 2475        """
 2476        dID = self.resolveDecision(decision)
 2477
 2478        if zone not in self.zones:
 2479            return False
 2480
 2481        if thorough:
 2482            parents = self.nodes[dID]['zones']  # editable reference
 2483            discard = set()
 2484            for parentZone in parents:
 2485                if parentZone == zone:
 2486                    info = self.zones[parentZone]
 2487                    info.contents.remove(dID)
 2488                    discard.add(zone)
 2489                elif zone in self.zoneAncestors(parentZone):
 2490                    info = self.zones[parentZone]
 2491                    info.contents.remove(dID)
 2492                    discard.add(parentZone)
 2493            if discard:
 2494                for indirectZone in discard:
 2495                    parents.remove(indirectZone)
 2496                return True
 2497            else:
 2498                return False
 2499        else:
 2500            info = self.zones[zone]
 2501            if dID not in info.contents:
 2502                return False
 2503            else:
 2504                info.contents.remove(dID)
 2505                try:
 2506                    self.nodes[dID]['zones'].remove(zone)
 2507                except KeyError:
 2508                    pass
 2509                return True
 2510
 2511    def addZoneToZone(
 2512        self,
 2513        addIt: base.Zone,
 2514        addTo: base.Zone
 2515    ) -> None:
 2516        """
 2517        Adds a zone to another zone. The `addIt` one must be at a
 2518        strictly lower level than the `addTo` zone, or an
 2519        `InvalidLevelError` will be raised.
 2520
 2521        If the zone to be added didn't already exist, it is created at
 2522        one level below the target zone. Similarly, if the zone being
 2523        added to didn't already exist, it is created at one level above
 2524        the target zone. If neither existed, a `MissingZoneError` will
 2525        be raised.
 2526
 2527        For example:
 2528
 2529        >>> d = DecisionGraph()
 2530        >>> d.addDecision('A')
 2531        0
 2532        >>> d.addDecision('B')
 2533        1
 2534        >>> d.addDecision('C')
 2535        2
 2536        >>> d.createZone('Z', 0)
 2537        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2538 annotations=[])
 2539        >>> d.addDecisionToZone('A', 'Z')
 2540        >>> d.addDecisionToZone('B', 'Z')
 2541        >>> d.getZoneInfo('Z')
 2542        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
 2543 annotations=[])
 2544        >>> d.createZone('Z2', 0)
 2545        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2546 annotations=[])
 2547        >>> d.addDecisionToZone('B', 'Z2')
 2548        >>> d.addDecisionToZone('C', 'Z2')
 2549        >>> d.getZoneInfo('Z2')
 2550        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
 2551 annotations=[])
 2552        >>> d.createZone('l1Z', 1)
 2553        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2554 annotations=[])
 2555        >>> d.createZone('l2Z', 2)
 2556        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2557 annotations=[])
 2558        >>> d.addZoneToZone('Z', 'l1Z')
 2559        >>> d.getZoneInfo('Z')
 2560        ZoneInfo(level=0, parents={'l1Z'}, contents={0, 1}, tags={},\
 2561 annotations=[])
 2562        >>> d.getZoneInfo('l1Z')
 2563        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
 2564 annotations=[])
 2565        >>> d.addZoneToZone('l1Z', 'l2Z')
 2566        >>> d.getZoneInfo('l1Z')
 2567        ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\
 2568 annotations=[])
 2569        >>> d.getZoneInfo('l2Z')
 2570        ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\
 2571 annotations=[])
 2572        >>> d.addZoneToZone('Z2', 'l2Z')
 2573        >>> d.getZoneInfo('Z2')
 2574        ZoneInfo(level=0, parents={'l2Z'}, contents={1, 2}, tags={},\
 2575 annotations=[])
 2576        >>> l2i = d.getZoneInfo('l2Z')
 2577        >>> l2i.level
 2578        2
 2579        >>> l2i.parents
 2580        set()
 2581        >>> sorted(l2i.contents)
 2582        ['Z2', 'l1Z']
 2583        >>> d.addZoneToZone('NZ', 'NZ2')
 2584        Traceback (most recent call last):
 2585        ...
 2586        exploration.core.MissingZoneError...
 2587        >>> d.addZoneToZone('Z', 'l1Z2')
 2588        >>> zi = d.getZoneInfo('Z')
 2589        >>> zi.level
 2590        0
 2591        >>> sorted(zi.parents)
 2592        ['l1Z', 'l1Z2']
 2593        >>> sorted(zi.contents)
 2594        [0, 1]
 2595        >>> d.getZoneInfo('l1Z2')
 2596        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
 2597 annotations=[])
 2598        >>> d.addZoneToZone('NZ', 'l1Z')
 2599        >>> d.getZoneInfo('NZ')
 2600        ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\
 2601 annotations=[])
 2602        >>> zi = d.getZoneInfo('l1Z')
 2603        >>> zi.level
 2604        1
 2605        >>> zi.parents
 2606        {'l2Z'}
 2607        >>> sorted(zi.contents)
 2608        ['NZ', 'Z']
 2609        """
 2610        # Create one or the other (but not both) if they're missing
 2611        addInfo = self.getZoneInfo(addIt)
 2612        toInfo = self.getZoneInfo(addTo)
 2613        if addInfo is None and toInfo is None:
 2614            raise MissingZoneError(
 2615                f"Cannot add zone {addIt!r} to zone {addTo!r}: neither"
 2616                f" exists already."
 2617            )
 2618
 2619        # Create missing addIt
 2620        elif addInfo is None:
 2621            toInfo = cast(base.ZoneInfo, toInfo)
 2622            newLevel = toInfo.level - 1
 2623            if newLevel < 0:
 2624                raise InvalidLevelError(
 2625                    f"Zone {addTo!r} is at level {toInfo.level} and so"
 2626                    f" a new zone cannot be added underneath it."
 2627                )
 2628            addInfo = self.createZone(addIt, newLevel)
 2629
 2630        # Create missing addTo
 2631        elif toInfo is None:
 2632            addInfo = cast(base.ZoneInfo, addInfo)
 2633            newLevel = addInfo.level + 1
 2634            if newLevel < 0:
 2635                raise InvalidLevelError(
 2636                    f"Zone {addIt!r} is at level {addInfo.level} (!!!)"
 2637                    f" and so a new zone cannot be added above it."
 2638                )
 2639            toInfo = self.createZone(addTo, newLevel)
 2640
 2641        # Now both addInfo and toInfo are defined
 2642        if addInfo.level >= toInfo.level:
 2643            raise InvalidLevelError(
 2644                f"Cannot add zone {addIt!r} at level {addInfo.level}"
 2645                f" to zone {addTo!r} at level {toInfo.level}: zones can"
 2646                f" only contain zones of lower levels."
 2647            )
 2648
 2649        # Now both addInfo and toInfo are defined
 2650        toInfo.contents.add(addIt)
 2651        addInfo.parents.add(addTo)
 2652
 2653    def removeZoneFromZone(
 2654        self,
 2655        removeIt: base.Zone,
 2656        removeFrom: base.Zone
 2657    ) -> bool:
 2658        """
 2659        Removes a zone from a zone if it had been in it, returning True
 2660        if that zone had been in that zone, and False if it was not in
 2661        that zone, including if either zone did not exist.
 2662
 2663        For example:
 2664
 2665        >>> d = DecisionGraph()
 2666        >>> d.createZone('Z', 0)
 2667        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2668 annotations=[])
 2669        >>> d.createZone('Z2', 0)
 2670        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2671 annotations=[])
 2672        >>> d.createZone('l1Z', 1)
 2673        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2674 annotations=[])
 2675        >>> d.createZone('l2Z', 2)
 2676        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2677 annotations=[])
 2678        >>> d.addZoneToZone('Z', 'l1Z')
 2679        >>> d.addZoneToZone('l1Z', 'l2Z')
 2680        >>> d.getZoneInfo('Z')
 2681        ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\
 2682 annotations=[])
 2683        >>> d.getZoneInfo('l1Z')
 2684        ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\
 2685 annotations=[])
 2686        >>> d.getZoneInfo('l2Z')
 2687        ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\
 2688 annotations=[])
 2689        >>> d.removeZoneFromZone('l1Z', 'l2Z')
 2690        True
 2691        >>> d.getZoneInfo('l1Z')
 2692        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
 2693 annotations=[])
 2694        >>> d.getZoneInfo('l2Z')
 2695        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2696 annotations=[])
 2697        >>> d.removeZoneFromZone('Z', 'l1Z')
 2698        True
 2699        >>> d.getZoneInfo('Z')
 2700        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2701 annotations=[])
 2702        >>> d.getZoneInfo('l1Z')
 2703        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2704 annotations=[])
 2705        >>> d.removeZoneFromZone('Z', 'l1Z')
 2706        False
 2707        >>> d.removeZoneFromZone('Z', 'madeup')
 2708        False
 2709        >>> d.removeZoneFromZone('nope', 'madeup')
 2710        False
 2711        >>> d.removeZoneFromZone('nope', 'l1Z')
 2712        False
 2713        """
 2714        remInfo = self.getZoneInfo(removeIt)
 2715        fromInfo = self.getZoneInfo(removeFrom)
 2716
 2717        if remInfo is None or fromInfo is None:
 2718            return False
 2719
 2720        if removeIt not in fromInfo.contents:
 2721            return False
 2722
 2723        remInfo.parents.remove(removeFrom)
 2724        fromInfo.contents.remove(removeIt)
 2725        return True
 2726
 2727    def decisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]:
 2728        """
 2729        Returns a set of all decisions included directly in the given
 2730        zone, not counting decisions included via intermediate
 2731        sub-zones (see `allDecisionsInZone` to include those).
 2732
 2733        Raises a `MissingZoneError` if the specified zone does not
 2734        exist.
 2735
 2736        The returned set is a copy, not a live editable set.
 2737
 2738        For example:
 2739
 2740        >>> d = DecisionGraph()
 2741        >>> d.addDecision('A')
 2742        0
 2743        >>> d.addDecision('B')
 2744        1
 2745        >>> d.addDecision('C')
 2746        2
 2747        >>> d.createZone('Z', 0)
 2748        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2749 annotations=[])
 2750        >>> d.addDecisionToZone('A', 'Z')
 2751        >>> d.addDecisionToZone('B', 'Z')
 2752        >>> d.getZoneInfo('Z')
 2753        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
 2754 annotations=[])
 2755        >>> d.decisionsInZone('Z')
 2756        {0, 1}
 2757        >>> d.createZone('Z2', 0)
 2758        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2759 annotations=[])
 2760        >>> d.addDecisionToZone('B', 'Z2')
 2761        >>> d.addDecisionToZone('C', 'Z2')
 2762        >>> d.getZoneInfo('Z2')
 2763        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
 2764 annotations=[])
 2765        >>> d.decisionsInZone('Z')
 2766        {0, 1}
 2767        >>> d.decisionsInZone('Z2')
 2768        {1, 2}
 2769        >>> d.createZone('l1Z', 1)
 2770        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2771 annotations=[])
 2772        >>> d.addZoneToZone('Z', 'l1Z')
 2773        >>> d.decisionsInZone('Z')
 2774        {0, 1}
 2775        >>> d.decisionsInZone('l1Z')
 2776        set()
 2777        >>> d.decisionsInZone('madeup')
 2778        Traceback (most recent call last):
 2779        ...
 2780        exploration.core.MissingZoneError...
 2781        >>> zDec = d.decisionsInZone('Z')
 2782        >>> zDec.add(2)  # won't affect the zone
 2783        >>> zDec
 2784        {0, 1, 2}
 2785        >>> d.decisionsInZone('Z')
 2786        {0, 1}
 2787        """
 2788        info = self.getZoneInfo(zone)
 2789        if info is None:
 2790            raise MissingZoneError(f"Zone {zone!r} does not exist.")
 2791
 2792        # Everything that's not a zone must be a decision
 2793        return {
 2794            item
 2795            for item in info.contents
 2796            if isinstance(item, base.DecisionID)
 2797        }
 2798
 2799    def subZones(self, zone: base.Zone) -> Set[base.Zone]:
 2800        """
 2801        Returns the set of all immediate sub-zones of the given zone.
 2802        Will be an empty set if there are no sub-zones; raises a
 2803        `MissingZoneError` if the specified zone does not exit.
 2804
 2805        The returned set is a copy, not a live editable set.
 2806
 2807        For example:
 2808
 2809        >>> d = DecisionGraph()
 2810        >>> d.createZone('Z', 0)
 2811        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2812 annotations=[])
 2813        >>> d.subZones('Z')
 2814        set()
 2815        >>> d.createZone('l1Z', 1)
 2816        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2817 annotations=[])
 2818        >>> d.addZoneToZone('Z', 'l1Z')
 2819        >>> d.subZones('Z')
 2820        set()
 2821        >>> d.subZones('l1Z')
 2822        {'Z'}
 2823        >>> s = d.subZones('l1Z')
 2824        >>> s.add('Q')  # doesn't affect the zone
 2825        >>> sorted(s)
 2826        ['Q', 'Z']
 2827        >>> d.subZones('l1Z')
 2828        {'Z'}
 2829        >>> d.subZones('madeup')
 2830        Traceback (most recent call last):
 2831        ...
 2832        exploration.core.MissingZoneError...
 2833        """
 2834        info = self.getZoneInfo(zone)
 2835        if info is None:
 2836            raise MissingZoneError(f"Zone {zone!r} does not exist.")
 2837
 2838        # Sub-zones will appear in self.zones
 2839        return {
 2840            item
 2841            for item in info.contents
 2842            if isinstance(item, base.Zone)
 2843        }
 2844
 2845    def allDecisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]:
 2846        """
 2847        Returns a set containing all decisions in the given zone,
 2848        including those included via sub-zones.
 2849
 2850        Raises a `MissingZoneError` if the specified zone does not
 2851        exist.`
 2852
 2853        For example:
 2854
 2855        >>> d = DecisionGraph()
 2856        >>> d.addDecision('A')
 2857        0
 2858        >>> d.addDecision('B')
 2859        1
 2860        >>> d.addDecision('C')
 2861        2
 2862        >>> d.createZone('Z', 0)
 2863        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2864 annotations=[])
 2865        >>> d.addDecisionToZone('A', 'Z')
 2866        >>> d.addDecisionToZone('B', 'Z')
 2867        >>> d.getZoneInfo('Z')
 2868        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
 2869 annotations=[])
 2870        >>> d.decisionsInZone('Z')
 2871        {0, 1}
 2872        >>> d.allDecisionsInZone('Z')
 2873        {0, 1}
 2874        >>> d.createZone('Z2', 0)
 2875        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2876 annotations=[])
 2877        >>> d.addDecisionToZone('B', 'Z2')
 2878        >>> d.addDecisionToZone('C', 'Z2')
 2879        >>> d.getZoneInfo('Z2')
 2880        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
 2881 annotations=[])
 2882        >>> d.decisionsInZone('Z')
 2883        {0, 1}
 2884        >>> d.decisionsInZone('Z2')
 2885        {1, 2}
 2886        >>> d.allDecisionsInZone('Z2')
 2887        {1, 2}
 2888        >>> d.createZone('l1Z', 1)
 2889        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2890 annotations=[])
 2891        >>> d.createZone('l2Z', 2)
 2892        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2893 annotations=[])
 2894        >>> d.addZoneToZone('Z', 'l1Z')
 2895        >>> d.addZoneToZone('l1Z', 'l2Z')
 2896        >>> d.addZoneToZone('Z2', 'l2Z')
 2897        >>> d.decisionsInZone('Z')
 2898        {0, 1}
 2899        >>> d.decisionsInZone('Z2')
 2900        {1, 2}
 2901        >>> d.decisionsInZone('l1Z')
 2902        set()
 2903        >>> d.allDecisionsInZone('l1Z')
 2904        {0, 1}
 2905        >>> d.allDecisionsInZone('l2Z')
 2906        {0, 1, 2}
 2907        """
 2908        result: Set[base.DecisionID] = set()
 2909        info = self.getZoneInfo(zone)
 2910        if info is None:
 2911            raise MissingZoneError(f"Zone {zone!r} does not exist.")
 2912
 2913        for item in info.contents:
 2914            if isinstance(item, base.Zone):
 2915                # This can't be an error because of the condition above
 2916                result |= self.allDecisionsInZone(item)
 2917            else:  # it's a decision
 2918                result.add(item)
 2919
 2920        return result
 2921
 2922    def zoneHierarchyLevel(self, zone: base.Zone) -> int:
 2923        """
 2924        Returns the hierarchy level of the given zone, as stored in its
 2925        zone info.
 2926
 2927        By convention, level-0 zones contain decisions directly, and
 2928        higher-level zones contain zones of lower levels. This
 2929        convention is not enforced, and there could be exceptions to it.
 2930
 2931        Raises a `MissingZoneError` if the specified zone does not
 2932        exist.
 2933
 2934        For example:
 2935
 2936        >>> d = DecisionGraph()
 2937        >>> d.createZone('Z', 0)
 2938        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2939 annotations=[])
 2940        >>> d.createZone('l1Z', 1)
 2941        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2942 annotations=[])
 2943        >>> d.createZone('l5Z', 5)
 2944        ZoneInfo(level=5, parents=set(), contents=set(), tags={},\
 2945 annotations=[])
 2946        >>> d.zoneHierarchyLevel('Z')
 2947        0
 2948        >>> d.zoneHierarchyLevel('l1Z')
 2949        1
 2950        >>> d.zoneHierarchyLevel('l5Z')
 2951        5
 2952        >>> d.zoneHierarchyLevel('madeup')
 2953        Traceback (most recent call last):
 2954        ...
 2955        exploration.core.MissingZoneError...
 2956        """
 2957        info = self.getZoneInfo(zone)
 2958        if info is None:
 2959            raise MissingZoneError(f"Zone {zone!r} dose not exist.")
 2960
 2961        return info.level
 2962
 2963    def zoneParents(
 2964        self,
 2965        zoneOrDecision: Union[base.Zone, base.DecisionID]
 2966    ) -> Set[base.Zone]:
 2967        """
 2968        Returns the set of all zones which directly contain the target
 2969        zone or decision.
 2970
 2971        Raises a `MissingDecisionError` if the target is neither a valid
 2972        zone nor a valid decision.
 2973
 2974        Returns a copy, not a live editable set.
 2975
 2976        Example:
 2977
 2978        >>> g = DecisionGraph()
 2979        >>> g.addDecision('A')
 2980        0
 2981        >>> g.addDecision('B')
 2982        1
 2983        >>> g.createZone('level0', 0)
 2984        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2985 annotations=[])
 2986        >>> g.createZone('level1', 1)
 2987        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2988 annotations=[])
 2989        >>> g.createZone('level2', 2)
 2990        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2991 annotations=[])
 2992        >>> g.createZone('level3', 3)
 2993        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
 2994 annotations=[])
 2995        >>> g.addDecisionToZone('A', 'level0')
 2996        >>> g.addDecisionToZone('B', 'level0')
 2997        >>> g.addZoneToZone('level0', 'level1')
 2998        >>> g.addZoneToZone('level1', 'level2')
 2999        >>> g.addZoneToZone('level2', 'level3')
 3000        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
 3001        >>> sorted(g.zoneParents(0))
 3002        ['level0']
 3003        >>> sorted(g.zoneParents(1))
 3004        ['level0', 'level2']
 3005        """
 3006        if zoneOrDecision in self.zones:
 3007            zoneOrDecision = cast(base.Zone, zoneOrDecision)
 3008            info = cast(base.ZoneInfo, self.getZoneInfo(zoneOrDecision))
 3009            return copy.copy(info.parents)
 3010        elif zoneOrDecision in self:
 3011            return self.nodes[zoneOrDecision].get('zones', set())
 3012        else:
 3013            raise MissingDecisionError(
 3014                f"Name {zoneOrDecision!r} is neither a valid zone nor a"
 3015                f" valid decision."
 3016            )
 3017
 3018    def zoneAncestors(
 3019        self,
 3020        zoneOrDecision: Union[base.Zone, base.DecisionID],
 3021        exclude: Set[base.Zone] = set(),
 3022        atLevel: Optional[int] = None
 3023    ) -> Set[base.Zone]:
 3024        """
 3025        Returns the set of zones which contain the target zone or
 3026        decision, either directly or indirectly. The target is not
 3027        included in the set.
 3028
 3029        Any ones listed in the `exclude` set are also excluded, as are
 3030        any of their ancestors which are not also ancestors of the
 3031        target zone via another path of inclusion.
 3032
 3033        If `atLevel` is not `None`, then only zones at that hierarchy
 3034        level will be included.
 3035
 3036        Raises a `MissingDecisionError` if the target is nether a valid
 3037        zone nor a valid decision.
 3038
 3039        Example:
 3040
 3041        >>> g = DecisionGraph()
 3042        >>> g.addDecision('A')
 3043        0
 3044        >>> g.addDecision('B')
 3045        1
 3046        >>> g.createZone('level0', 0)
 3047        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 3048 annotations=[])
 3049        >>> g.createZone('level1', 1)
 3050        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 3051 annotations=[])
 3052        >>> g.createZone('level2', 2)
 3053        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 3054 annotations=[])
 3055        >>> g.createZone('level3', 3)
 3056        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
 3057 annotations=[])
 3058        >>> g.addDecisionToZone('A', 'level0')
 3059        >>> g.addDecisionToZone('B', 'level0')
 3060        >>> g.addZoneToZone('level0', 'level1')
 3061        >>> g.addZoneToZone('level1', 'level2')
 3062        >>> g.addZoneToZone('level2', 'level3')
 3063        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
 3064        >>> sorted(g.zoneAncestors(0))
 3065        ['level0', 'level1', 'level2', 'level3']
 3066        >>> sorted(g.zoneAncestors(1))
 3067        ['level0', 'level1', 'level2', 'level3']
 3068        >>> sorted(g.zoneParents(0))
 3069        ['level0']
 3070        >>> sorted(g.zoneParents(1))
 3071        ['level0', 'level2']
 3072        >>> sorted(g.zoneAncestors(0, atLevel=2))
 3073        ['level2']
 3074        >>> sorted(g.zoneAncestors(0, exclude={'level2'}))
 3075        ['level0', 'level1']
 3076        """
 3077        # Copy is important here!
 3078        result = set(self.zoneParents(zoneOrDecision))
 3079        result -= exclude
 3080        for parent in copy.copy(result):
 3081            # Recursively dig up ancestors, but exclude
 3082            # results-so-far to avoid re-enumerating when there are
 3083            # multiple braided inclusion paths.
 3084            result |= self.zoneAncestors(parent, result | exclude, atLevel)
 3085
 3086        if atLevel is not None:
 3087            return {
 3088                z for z in result if self.zoneHierarchyLevel(z) == atLevel
 3089            }
 3090        else:
 3091            return result
 3092
 3093    def region(
 3094        self,
 3095        decision: base.DecisionID,
 3096        useLevel: int=1
 3097    ) -> Optional[base.Zone]:
 3098        """
 3099        Returns the 'region' that this decision belongs to. 'Regions'
 3100        are level-1 zones, but when a decision is in multiple level-1
 3101        zones, its region counts as the smallest of those zones in terms
 3102        of total decisions contained, breaking ties by the one with the
 3103        alphabetically earlier name.
 3104
 3105        Always returns a single zone name string, unless the target
 3106        decision is not in any level-1 zones, in which case it returns
 3107        `None`.
 3108
 3109        If `useLevel` is specified, then zones of the specified level
 3110        will be used instead of level-1 zones.
 3111
 3112        Example:
 3113
 3114        >>> g = DecisionGraph()
 3115        >>> g.addDecision('A')
 3116        0
 3117        >>> g.addDecision('B')
 3118        1
 3119        >>> g.addDecision('C')
 3120        2
 3121        >>> g.addDecision('D')
 3122        3
 3123        >>> g.createZone('zoneX', 0)
 3124        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 3125 annotations=[])
 3126        >>> g.createZone('regionA', 1)
 3127        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 3128 annotations=[])
 3129        >>> g.createZone('zoneY', 0)
 3130        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 3131 annotations=[])
 3132        >>> g.createZone('regionB', 1)
 3133        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 3134 annotations=[])
 3135        >>> g.createZone('regionC', 1)
 3136        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 3137 annotations=[])
 3138        >>> g.createZone('quadrant', 2)
 3139        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 3140 annotations=[])
 3141        >>> g.addDecisionToZone('A', 'zoneX')
 3142        >>> g.addDecisionToZone('B', 'zoneY')
 3143        >>> # C is not in any level-1 zones
 3144        >>> g.addDecisionToZone('D', 'zoneX')
 3145        >>> g.addDecisionToZone('D', 'zoneY')  # D is in both
 3146        >>> g.addZoneToZone('zoneX', 'regionA')
 3147        >>> g.addZoneToZone('zoneY', 'regionB')
 3148        >>> g.addZoneToZone('zoneX', 'regionC')  # includes both
 3149        >>> g.addZoneToZone('zoneY', 'regionC')
 3150        >>> g.addZoneToZone('regionA', 'quadrant')
 3151        >>> g.addZoneToZone('regionB', 'quadrant')
 3152        >>> g.addDecisionToZone('C', 'regionC')  # Direct in level-2
 3153        >>> sorted(g.allDecisionsInZone('zoneX'))
 3154        [0, 3]
 3155        >>> sorted(g.allDecisionsInZone('zoneY'))
 3156        [1, 3]
 3157        >>> sorted(g.allDecisionsInZone('regionA'))
 3158        [0, 3]
 3159        >>> sorted(g.allDecisionsInZone('regionB'))
 3160        [1, 3]
 3161        >>> sorted(g.allDecisionsInZone('regionC'))
 3162        [0, 1, 2, 3]
 3163        >>> sorted(g.allDecisionsInZone('quadrant'))
 3164        [0, 1, 3]
 3165        >>> g.region(0)  # for A; region A is smaller than region C
 3166        'regionA'
 3167        >>> g.region(1)  # for B; region B is also smaller than C
 3168        'regionB'
 3169        >>> g.region(2)  # for C
 3170        'regionC'
 3171        >>> g.region(3)  # for D; tie broken alphabetically
 3172        'regionA'
 3173        >>> g.region(0, useLevel=0)  # for A at level 0
 3174        'zoneX'
 3175        >>> g.region(1, useLevel=0)  # for B at level 0
 3176        'zoneY'
 3177        >>> g.region(2, useLevel=0) is None  # for C at level 0 (none)
 3178        True
 3179        >>> g.region(3, useLevel=0)  # for D at level 0; tie
 3180        'zoneX'
 3181        >>> g.region(0, useLevel=2) # for A at level 2
 3182        'quadrant'
 3183        >>> g.region(1, useLevel=2) # for B at level 2
 3184        'quadrant'
 3185        >>> g.region(2, useLevel=2) is None # for C at level 2 (none)
 3186        True
 3187        >>> g.region(3, useLevel=2)  # for D at level 2
 3188        'quadrant'
 3189        """
 3190        relevant = self.zoneAncestors(decision, atLevel=useLevel)
 3191        if len(relevant) == 0:
 3192            return None
 3193        elif len(relevant) == 1:
 3194            for zone in relevant:
 3195                return zone
 3196            return None  # not really necessary but keeps mypy happy
 3197        else:
 3198            # more than one zone ancestor at the relevant hierarchy
 3199            # level: need to measure their sizes
 3200            minSize = None
 3201            candidates = []
 3202            for zone in relevant:
 3203                size = len(self.allDecisionsInZone(zone))
 3204                if minSize is None or size < minSize:
 3205                    candidates = [zone]
 3206                    minSize = size
 3207                elif size == minSize:
 3208                    candidates.append(zone)
 3209            return min(candidates)
 3210
 3211    def zoneEdges(self, zone: base.Zone) -> Optional[
 3212        Tuple[
 3213            Set[Tuple[base.DecisionID, base.Transition]],
 3214            Set[Tuple[base.DecisionID, base.Transition]]
 3215        ]
 3216    ]:
 3217        """
 3218        Given a zone to look at, finds all of the transitions which go
 3219        out of and into that zone, ignoring internal transitions between
 3220        decisions in the zone. This includes all decisions in sub-zones.
 3221        The return value is a pair of sets for outgoing and then
 3222        incoming transitions, where each transition is specified as a
 3223        (sourceID, transitionName) pair.
 3224
 3225        Returns `None` if the target zone isn't yet fully defined.
 3226
 3227        Note that this takes time proportional to *all* edges plus *all*
 3228        nodes in the graph no matter how large or small the zone in
 3229        question is.
 3230
 3231        >>> g = DecisionGraph()
 3232        >>> g.addDecision('A')
 3233        0
 3234        >>> g.addDecision('B')
 3235        1
 3236        >>> g.addDecision('C')
 3237        2
 3238        >>> g.addDecision('D')
 3239        3
 3240        >>> g.addTransition('A', 'up', 'B', 'down')
 3241        >>> g.addTransition('B', 'right', 'C', 'left')
 3242        >>> g.addTransition('C', 'down', 'D', 'up')
 3243        >>> g.addTransition('D', 'left', 'A', 'right')
 3244        >>> g.addTransition('A', 'tunnel', 'C', 'tunnel')
 3245        >>> g.createZone('Z', 0)
 3246        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 3247 annotations=[])
 3248        >>> g.createZone('ZZ', 1)
 3249        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 3250 annotations=[])
 3251        >>> g.addZoneToZone('Z', 'ZZ')
 3252        >>> g.addDecisionToZone('A', 'Z')
 3253        >>> g.addDecisionToZone('B', 'Z')
 3254        >>> g.addDecisionToZone('D', 'ZZ')
 3255        >>> outgoing, incoming = g.zoneEdges('Z')  # TODO: Sort for testing
 3256        >>> sorted(outgoing)
 3257        [(0, 'right'), (0, 'tunnel'), (1, 'right')]
 3258        >>> sorted(incoming)
 3259        [(2, 'left'), (2, 'tunnel'), (3, 'left')]
 3260        >>> outgoing, incoming = g.zoneEdges('ZZ')
 3261        >>> sorted(outgoing)
 3262        [(0, 'tunnel'), (1, 'right'), (3, 'up')]
 3263        >>> sorted(incoming)
 3264        [(2, 'down'), (2, 'left'), (2, 'tunnel')]
 3265        >>> g.zoneEdges('madeup') is None
 3266        True
 3267        """
 3268        # Find the interior nodes
 3269        try:
 3270            interior = self.allDecisionsInZone(zone)
 3271        except MissingZoneError:
 3272            return None
 3273
 3274        # Set up our result
 3275        results: Tuple[
 3276            Set[Tuple[base.DecisionID, base.Transition]],
 3277            Set[Tuple[base.DecisionID, base.Transition]]
 3278        ] = (set(), set())
 3279
 3280        # Because finding incoming edges requires searching the entire
 3281        # graph anyways, it's more efficient to just consider each edge
 3282        # once.
 3283        for fromDecision in self:
 3284            fromThere = self[fromDecision]
 3285            for toDecision in fromThere:
 3286                for transition in fromThere[toDecision]:
 3287                    sourceIn = fromDecision in interior
 3288                    destIn = toDecision in interior
 3289                    if sourceIn and not destIn:
 3290                        results[0].add((fromDecision, transition))
 3291                    elif destIn and not sourceIn:
 3292                        results[1].add((fromDecision, transition))
 3293
 3294        return results
 3295
 3296    def replaceZonesInHierarchy(
 3297        self,
 3298        target: base.AnyDecisionSpecifier,
 3299        zone: base.Zone,
 3300        level: int
 3301    ) -> None:
 3302        """
 3303        This method replaces one or more zones which contain the
 3304        specified `target` decision with a specific zone, at a specific
 3305        level in the zone hierarchy (see `zoneHierarchyLevel`). If the
 3306        named zone doesn't yet exist, it will be created.
 3307
 3308        To do this, it looks at all zones which contain the target
 3309        decision directly or indirectly (see `zoneAncestors`) and which
 3310        are at the specified level.
 3311
 3312        - Any direct children of those zones which are ancestors of the
 3313            target decision are removed from those zones and placed into
 3314            the new zone instead, regardless of their levels. Indirect
 3315            children are not affected (except perhaps indirectly via
 3316            their parents' ancestors changing).
 3317        - The new zone is placed into every direct parent of those
 3318            zones, regardless of their levels (those parents are by
 3319            definition all ancestors of the target decision).
 3320        - If there were no zones at the target level, every zone at the
 3321            next level down which is an ancestor of the target decision
 3322            (or just that decision if the level is 0) is placed into the
 3323            new zone as a direct child (and is removed from any previous
 3324            parents it had). In this case, the new zone will also be
 3325            added as a sub-zone to every ancestor of the target decision
 3326            at the level above the specified level, if there are any.
 3327            * In this case, if there are no zones at the level below the
 3328                specified level, the highest level of zones smaller than
 3329                that is treated as the level below, down to targeting
 3330                the decision itself.
 3331            * Similarly, if there are no zones at the level above the
 3332                specified level but there are zones at a higher level,
 3333                the new zone will be added to each of the zones in the
 3334                lowest level above the target level that has zones in it.
 3335
 3336        A `MissingDecisionError` will be raised if the specified
 3337        decision is not valid, or if the decision is left as default but
 3338        there is no current decision in the exploration.
 3339
 3340        An `InvalidLevelError` will be raised if the level is less than
 3341        zero.
 3342
 3343        Example:
 3344
 3345        >>> g = DecisionGraph()
 3346        >>> g.addDecision('decision')
 3347        0
 3348        >>> g.addDecision('alternate')
 3349        1
 3350        >>> g.createZone('zone0', 0)
 3351        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 3352 annotations=[])
 3353        >>> g.createZone('zone1', 1)
 3354        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 3355 annotations=[])
 3356        >>> g.createZone('zone2.1', 2)
 3357        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 3358 annotations=[])
 3359        >>> g.createZone('zone2.2', 2)
 3360        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 3361 annotations=[])
 3362        >>> g.createZone('zone3', 3)
 3363        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
 3364 annotations=[])
 3365        >>> g.addDecisionToZone('decision', 'zone0')
 3366        >>> g.addDecisionToZone('alternate', 'zone0')
 3367        >>> g.addZoneToZone('zone0', 'zone1')
 3368        >>> g.addZoneToZone('zone1', 'zone2.1')
 3369        >>> g.addZoneToZone('zone1', 'zone2.2')
 3370        >>> g.addZoneToZone('zone2.1', 'zone3')
 3371        >>> g.addZoneToZone('zone2.2', 'zone3')
 3372        >>> g.zoneHierarchyLevel('zone0')
 3373        0
 3374        >>> g.zoneHierarchyLevel('zone1')
 3375        1
 3376        >>> g.zoneHierarchyLevel('zone2.1')
 3377        2
 3378        >>> g.zoneHierarchyLevel('zone2.2')
 3379        2
 3380        >>> g.zoneHierarchyLevel('zone3')
 3381        3
 3382        >>> sorted(g.decisionsInZone('zone0'))
 3383        [0, 1]
 3384        >>> sorted(g.zoneAncestors('zone0'))
 3385        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
 3386        >>> g.subZones('zone1')
 3387        {'zone0'}
 3388        >>> g.zoneParents('zone0')
 3389        {'zone1'}
 3390        >>> g.replaceZonesInHierarchy('decision', 'new0', 0)
 3391        >>> g.zoneParents('zone0')
 3392        {'zone1'}
 3393        >>> g.zoneParents('new0')
 3394        {'zone1'}
 3395        >>> sorted(g.zoneAncestors('zone0'))
 3396        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
 3397        >>> sorted(g.zoneAncestors('new0'))
 3398        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
 3399        >>> g.decisionsInZone('zone0')
 3400        {1}
 3401        >>> g.decisionsInZone('new0')
 3402        {0}
 3403        >>> sorted(g.subZones('zone1'))
 3404        ['new0', 'zone0']
 3405        >>> g.zoneParents('new0')
 3406        {'zone1'}
 3407        >>> g.replaceZonesInHierarchy('decision', 'new1', 1)
 3408        >>> sorted(g.zoneAncestors(0))
 3409        ['new0', 'new1', 'zone2.1', 'zone2.2', 'zone3']
 3410        >>> g.subZones('zone1')
 3411        {'zone0'}
 3412        >>> g.subZones('new1')
 3413        {'new0'}
 3414        >>> g.zoneParents('new0')
 3415        {'new1'}
 3416        >>> sorted(g.zoneParents('zone1'))
 3417        ['zone2.1', 'zone2.2']
 3418        >>> sorted(g.zoneParents('new1'))
 3419        ['zone2.1', 'zone2.2']
 3420        >>> g.zoneParents('zone2.1')
 3421        {'zone3'}
 3422        >>> g.zoneParents('zone2.2')
 3423        {'zone3'}
 3424        >>> sorted(g.subZones('zone2.1'))
 3425        ['new1', 'zone1']
 3426        >>> sorted(g.subZones('zone2.2'))
 3427        ['new1', 'zone1']
 3428        >>> sorted(g.allDecisionsInZone('zone2.1'))
 3429        [0, 1]
 3430        >>> sorted(g.allDecisionsInZone('zone2.2'))
 3431        [0, 1]
 3432        >>> g.replaceZonesInHierarchy('decision', 'new2', 2)
 3433        >>> g.zoneParents('zone2.1')
 3434        {'zone3'}
 3435        >>> g.zoneParents('zone2.2')
 3436        {'zone3'}
 3437        >>> g.subZones('zone2.1')
 3438        {'zone1'}
 3439        >>> g.subZones('zone2.2')
 3440        {'zone1'}
 3441        >>> g.subZones('new2')
 3442        {'new1'}
 3443        >>> g.zoneParents('new2')
 3444        {'zone3'}
 3445        >>> g.allDecisionsInZone('zone2.1')
 3446        {1}
 3447        >>> g.allDecisionsInZone('zone2.2')
 3448        {1}
 3449        >>> g.allDecisionsInZone('new2')
 3450        {0}
 3451        >>> sorted(g.subZones('zone3'))
 3452        ['new2', 'zone2.1', 'zone2.2']
 3453        >>> g.zoneParents('zone3')
 3454        set()
 3455        >>> sorted(g.allDecisionsInZone('zone3'))
 3456        [0, 1]
 3457        >>> g.replaceZonesInHierarchy('decision', 'new3', 3)
 3458        >>> sorted(g.subZones('zone3'))
 3459        ['zone2.1', 'zone2.2']
 3460        >>> g.subZones('new3')
 3461        {'new2'}
 3462        >>> g.zoneParents('zone3')
 3463        set()
 3464        >>> g.zoneParents('new3')
 3465        set()
 3466        >>> g.allDecisionsInZone('zone3')
 3467        {1}
 3468        >>> g.allDecisionsInZone('new3')
 3469        {0}
 3470        >>> g.replaceZonesInHierarchy('decision', 'new4', 5)
 3471        >>> g.subZones('new4')
 3472        {'new3'}
 3473        >>> g.zoneHierarchyLevel('new4')
 3474        5
 3475
 3476        Another example of level collapse when trying to replace a zone
 3477        at a level above :
 3478
 3479        >>> g = DecisionGraph()
 3480        >>> g.addDecision('A')
 3481        0
 3482        >>> g.addDecision('B')
 3483        1
 3484        >>> g.createZone('level0', 0)
 3485        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 3486 annotations=[])
 3487        >>> g.createZone('level1', 1)
 3488        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 3489 annotations=[])
 3490        >>> g.createZone('level2', 2)
 3491        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 3492 annotations=[])
 3493        >>> g.createZone('level3', 3)
 3494        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
 3495 annotations=[])
 3496        >>> g.addDecisionToZone('B', 'level0')
 3497        >>> g.addZoneToZone('level0', 'level1')
 3498        >>> g.addZoneToZone('level1', 'level2')
 3499        >>> g.addZoneToZone('level2', 'level3')
 3500        >>> g.addDecisionToZone('A', 'level3') # missing some zone levels
 3501        >>> g.zoneHierarchyLevel('level3')
 3502        3
 3503        >>> g.replaceZonesInHierarchy('A', 'newFirst', 1)
 3504        >>> g.zoneHierarchyLevel('newFirst')
 3505        1
 3506        >>> g.decisionsInZone('newFirst')
 3507        {0}
 3508        >>> g.decisionsInZone('level3')
 3509        set()
 3510        >>> sorted(g.allDecisionsInZone('level3'))
 3511        [0, 1]
 3512        >>> g.subZones('newFirst')
 3513        set()
 3514        >>> sorted(g.subZones('level3'))
 3515        ['level2', 'newFirst']
 3516        >>> g.zoneParents('newFirst')
 3517        {'level3'}
 3518        >>> g.replaceZonesInHierarchy('A', 'newSecond', 2)
 3519        >>> g.zoneHierarchyLevel('newSecond')
 3520        2
 3521        >>> g.decisionsInZone('newSecond')
 3522        set()
 3523        >>> g.allDecisionsInZone('newSecond')
 3524        {0}
 3525        >>> g.subZones('newSecond')
 3526        {'newFirst'}
 3527        >>> g.zoneParents('newSecond')
 3528        {'level3'}
 3529        >>> g.zoneParents('newFirst')
 3530        {'newSecond'}
 3531        >>> sorted(g.subZones('level3'))
 3532        ['level2', 'newSecond']
 3533        """
 3534        tID = self.resolveDecision(target)
 3535
 3536        if level < 0:
 3537            raise InvalidLevelError(
 3538                f"Target level must be positive (got {level})."
 3539            )
 3540
 3541        info = self.getZoneInfo(zone)
 3542        if info is None:
 3543            info = self.createZone(zone, level)
 3544        elif level != info.level:
 3545            raise InvalidLevelError(
 3546                f"Target level ({level}) does not match the level of"
 3547                f" the target zone ({zone!r} at level {info.level})."
 3548            )
 3549
 3550        # Collect both parents & ancestors
 3551        parents = self.zoneParents(tID)
 3552        ancestors = set(self.zoneAncestors(tID))
 3553
 3554        # Map from levels to sets of zones from the ancestors pool
 3555        levelMap: Dict[int, Set[base.Zone]] = {}
 3556        highest = -1
 3557        for ancestor in ancestors:
 3558            ancestorLevel = self.zoneHierarchyLevel(ancestor)
 3559            levelMap.setdefault(ancestorLevel, set()).add(ancestor)
 3560            if ancestorLevel > highest:
 3561                highest = ancestorLevel
 3562
 3563        # Figure out if we have target zones to replace or not
 3564        reparentDecision = False
 3565        if level in levelMap:
 3566            # If there are zones at the target level,
 3567            targetZones = levelMap[level]
 3568
 3569            above = set()
 3570            below = set()
 3571
 3572            for replaced in targetZones:
 3573                above |= self.zoneParents(replaced)
 3574                below |= self.subZones(replaced)
 3575                if replaced in parents:
 3576                    reparentDecision = True
 3577
 3578            # Only ancestors should be reparented
 3579            below &= ancestors
 3580
 3581        else:
 3582            # Find levels w/ zones in them above + below
 3583            levelBelow = level - 1
 3584            levelAbove = level + 1
 3585            below = levelMap.get(levelBelow, set())
 3586            above = levelMap.get(levelAbove, set())
 3587
 3588            while len(below) == 0 and levelBelow > 0:
 3589                levelBelow -= 1
 3590                below = levelMap.get(levelBelow, set())
 3591
 3592            if len(below) == 0:
 3593                reparentDecision = True
 3594
 3595            while len(above) == 0 and levelAbove < highest:
 3596                levelAbove += 1
 3597                above = levelMap.get(levelAbove, set())
 3598
 3599        # Handle re-parenting zones below
 3600        for under in below:
 3601            for parent in self.zoneParents(under):
 3602                if parent in ancestors:
 3603                    self.removeZoneFromZone(under, parent)
 3604            self.addZoneToZone(under, zone)
 3605
 3606        # Add this zone to each parent
 3607        for parent in above:
 3608            self.addZoneToZone(zone, parent)
 3609
 3610        # Re-parent the decision itself if necessary
 3611        if reparentDecision:
 3612            # (using set() here to avoid size-change-during-iteration)
 3613            for parent in set(parents):
 3614                self.removeDecisionFromZone(tID, parent)
 3615            self.addDecisionToZone(tID, zone)
 3616
 3617    def getReciprocal(
 3618        self,
 3619        decision: base.AnyDecisionSpecifier,
 3620        transition: base.Transition
 3621    ) -> Optional[base.Transition]:
 3622        """
 3623        Returns the reciprocal edge for the specified transition from the
 3624        specified decision (see `setReciprocal`). Returns
 3625        `None` if no reciprocal has been established for that
 3626        transition, or if that decision or transition does not exist.
 3627        """
 3628        dID = self.resolveDecision(decision)
 3629
 3630        dest = self.getDestination(dID, transition)
 3631        if dest is not None:
 3632            info = cast(
 3633                TransitionProperties,
 3634                self.edges[dID, dest, transition]  # type:ignore
 3635            )
 3636            recip = info.get("reciprocal")
 3637            if recip is not None and not isinstance(recip, base.Transition):
 3638                raise ValueError(f"Invalid reciprocal value: {repr(recip)}")
 3639            return recip
 3640        else:
 3641            return None
 3642
 3643    def setReciprocal(
 3644        self,
 3645        decision: base.AnyDecisionSpecifier,
 3646        transition: base.Transition,
 3647        reciprocal: Optional[base.Transition],
 3648        setBoth: bool = True,
 3649        cleanup: bool = True
 3650    ) -> None:
 3651        """
 3652        Sets the 'reciprocal' transition for a particular transition from
 3653        a particular decision, and removes the reciprocal property from
 3654        any old reciprocal transition.
 3655
 3656        Raises a `MissingDecisionError` or a `MissingTransitionError` if
 3657        the specified decision or transition does not exist.
 3658
 3659        Raises an `InvalidDestinationError` if the reciprocal transition
 3660        does not exist, or if it does exist but does not lead back to
 3661        the decision the transition came from.
 3662
 3663        If `setBoth` is True (the default) then the transition which is
 3664        being identified as a reciprocal will also have its reciprocal
 3665        property set, pointing back to the primary transition being
 3666        modified, and any old reciprocal of that transition will have its
 3667        reciprocal set to None. If you want to create a situation with
 3668        non-exclusive reciprocals, use `setBoth=False`.
 3669
 3670        If `cleanup` is True (the default) then abandoned reciprocal
 3671        transitions (for both edges if `setBoth` was true) have their
 3672        reciprocal properties removed. Set `cleanup` to false if you want
 3673        to retain them, although this will result in non-exclusive
 3674        reciprocal relationships.
 3675
 3676        If the `reciprocal` value is None, this deletes the reciprocal
 3677        value entirely, and if `setBoth` is true, it does this for the
 3678        previous reciprocal edge as well. No error is raised in this case
 3679        when there was not already a reciprocal to delete.
 3680
 3681        Note that one should remove a reciprocal relationship before
 3682        redirecting either edge of the pair in a way that gives it a new
 3683        reciprocal, since otherwise, a later attempt to remove the
 3684        reciprocal with `setBoth` set to True (the default) will end up
 3685        deleting the reciprocal information from the other edge that was
 3686        already modified. There is no way to reliably detect and avoid
 3687        this, because two different decisions could (and often do in
 3688        practice) have transitions with identical names, meaning that the
 3689        reciprocal value will still be the same, but it will indicate a
 3690        different edge in virtue of the destination of the edge changing.
 3691
 3692        ## Example
 3693
 3694        >>> g = DecisionGraph()
 3695        >>> g.addDecision('G')
 3696        0
 3697        >>> g.addDecision('H')
 3698        1
 3699        >>> g.addDecision('I')
 3700        2
 3701        >>> g.addTransition('G', 'up', 'H', 'down')
 3702        >>> g.addTransition('G', 'next', 'H', 'prev')
 3703        >>> g.addTransition('H', 'next', 'I', 'prev')
 3704        >>> g.addTransition('H', 'return', 'G')
 3705        >>> g.setReciprocal('G', 'up', 'next') # Error w/ destinations
 3706        Traceback (most recent call last):
 3707        ...
 3708        exploration.core.InvalidDestinationError...
 3709        >>> g.setReciprocal('G', 'up', 'none') # Doesn't exist
 3710        Traceback (most recent call last):
 3711        ...
 3712        exploration.core.MissingTransitionError...
 3713        >>> g.getReciprocal('G', 'up')
 3714        'down'
 3715        >>> g.getReciprocal('H', 'down')
 3716        'up'
 3717        >>> g.getReciprocal('H', 'return') is None
 3718        True
 3719        >>> g.setReciprocal('G', 'up', 'return')
 3720        >>> g.getReciprocal('G', 'up')
 3721        'return'
 3722        >>> g.getReciprocal('H', 'down') is None
 3723        True
 3724        >>> g.getReciprocal('H', 'return')
 3725        'up'
 3726        >>> g.setReciprocal('H', 'return', None) # remove the reciprocal
 3727        >>> g.getReciprocal('G', 'up') is None
 3728        True
 3729        >>> g.getReciprocal('H', 'down') is None
 3730        True
 3731        >>> g.getReciprocal('H', 'return') is None
 3732        True
 3733        >>> g.setReciprocal('G', 'up', 'down', setBoth=False) # one-way
 3734        >>> g.getReciprocal('G', 'up')
 3735        'down'
 3736        >>> g.getReciprocal('H', 'down') is None
 3737        True
 3738        >>> g.getReciprocal('H', 'return') is None
 3739        True
 3740        >>> g.setReciprocal('H', 'return', 'up', setBoth=False) # non-sym
 3741        >>> g.getReciprocal('G', 'up')
 3742        'down'
 3743        >>> g.getReciprocal('H', 'down') is None
 3744        True
 3745        >>> g.getReciprocal('H', 'return')
 3746        'up'
 3747        >>> g.setReciprocal('H', 'down', 'up') # setBoth not needed
 3748        >>> g.getReciprocal('G', 'up')
 3749        'down'
 3750        >>> g.getReciprocal('H', 'down')
 3751        'up'
 3752        >>> g.getReciprocal('H', 'return') # unchanged
 3753        'up'
 3754        >>> g.setReciprocal('G', 'up', 'return', cleanup=False) # no cleanup
 3755        >>> g.getReciprocal('G', 'up')
 3756        'return'
 3757        >>> g.getReciprocal('H', 'down')
 3758        'up'
 3759        >>> g.getReciprocal('H', 'return') # unchanged
 3760        'up'
 3761        >>> # Cleanup only applies to reciprocal if setBoth is true
 3762        >>> g.setReciprocal('H', 'down', 'up', setBoth=False)
 3763        >>> g.getReciprocal('G', 'up')
 3764        'return'
 3765        >>> g.getReciprocal('H', 'down')
 3766        'up'
 3767        >>> g.getReciprocal('H', 'return') # not cleaned up w/out setBoth
 3768        'up'
 3769        >>> g.setReciprocal('H', 'down', 'up') # with cleanup and setBoth
 3770        >>> g.getReciprocal('G', 'up')
 3771        'down'
 3772        >>> g.getReciprocal('H', 'down')
 3773        'up'
 3774        >>> g.getReciprocal('H', 'return') is None # cleaned up
 3775        True
 3776        """
 3777        dID = self.resolveDecision(decision)
 3778
 3779        dest = self.destination(dID, transition) # possible KeyError
 3780        if reciprocal is None:
 3781            rDest = None
 3782        else:
 3783            rDest = self.getDestination(dest, reciprocal)
 3784
 3785        # Set or delete reciprocal property
 3786        if reciprocal is None:
 3787            # Delete the property
 3788            info = self.edges[dID, dest, transition]  # type:ignore
 3789
 3790            old = info.pop('reciprocal')
 3791            if setBoth:
 3792                rDest = self.getDestination(dest, old)
 3793                if rDest != dID:
 3794                    raise RuntimeError(
 3795                        f"Invalid reciprocal {old!r} for transition"
 3796                        f" {transition!r} from {self.identityOf(dID)}:"
 3797                        f" destination is {rDest}."
 3798                    )
 3799                rInfo = self.edges[dest, dID, old]  # type:ignore
 3800                if 'reciprocal' in rInfo:
 3801                    del rInfo['reciprocal']
 3802        else:
 3803            # Set the property, checking for errors first
 3804            if rDest is None:
 3805                raise MissingTransitionError(
 3806                    f"Reciprocal transition {reciprocal!r} for"
 3807                    f" transition {transition!r} from decision"
 3808                    f" {self.identityOf(dID)} does not exist at"
 3809                    f" decision {self.identityOf(dest)}"
 3810                )
 3811
 3812            if rDest != dID:
 3813                raise InvalidDestinationError(
 3814                    f"Reciprocal transition {reciprocal!r} from"
 3815                    f" decision {self.identityOf(dest)} does not lead"
 3816                    f" back to decision {self.identityOf(dID)}."
 3817                )
 3818
 3819            eProps = self.edges[dID, dest, transition]  # type:ignore [index]
 3820            abandoned = eProps.get('reciprocal')
 3821            eProps['reciprocal'] = reciprocal
 3822            if cleanup and abandoned not in (None, reciprocal):
 3823                aProps = self.edges[dest, dID, abandoned]  # type:ignore
 3824                if 'reciprocal' in aProps:
 3825                    del aProps['reciprocal']
 3826
 3827            if setBoth:
 3828                rProps = self.edges[dest, dID, reciprocal]  # type:ignore
 3829                revAbandoned = rProps.get('reciprocal')
 3830                rProps['reciprocal'] = transition
 3831                # Sever old reciprocal relationship
 3832                if cleanup and revAbandoned not in (None, transition):
 3833                    raProps = self.edges[
 3834                        dID,  # type:ignore
 3835                        dest,
 3836                        revAbandoned
 3837                    ]
 3838                    del raProps['reciprocal']
 3839
 3840    def getReciprocalPair(
 3841        self,
 3842        decision: base.AnyDecisionSpecifier,
 3843        transition: base.Transition
 3844    ) -> Optional[Tuple[base.DecisionID, base.Transition]]:
 3845        """
 3846        Returns a tuple containing both the destination decision ID and
 3847        the transition at that decision which is the reciprocal of the
 3848        specified destination & transition. Returns `None` if no
 3849        reciprocal has been established for that transition, or if that
 3850        decision or transition does not exist.
 3851
 3852        >>> g = DecisionGraph()
 3853        >>> g.addDecision('A')
 3854        0
 3855        >>> g.addDecision('B')
 3856        1
 3857        >>> g.addDecision('C')
 3858        2
 3859        >>> g.addTransition('A', 'up', 'B', 'down')
 3860        >>> g.addTransition('B', 'right', 'C', 'left')
 3861        >>> g.addTransition('A', 'oneway', 'C')
 3862        >>> g.getReciprocalPair('A', 'up')
 3863        (1, 'down')
 3864        >>> g.getReciprocalPair('B', 'down')
 3865        (0, 'up')
 3866        >>> g.getReciprocalPair('B', 'right')
 3867        (2, 'left')
 3868        >>> g.getReciprocalPair('C', 'left')
 3869        (1, 'right')
 3870        >>> g.getReciprocalPair('C', 'up') is None
 3871        True
 3872        >>> g.getReciprocalPair('Q', 'up') is None
 3873        True
 3874        >>> g.getReciprocalPair('A', 'tunnel') is None
 3875        True
 3876        """
 3877        try:
 3878            dID = self.resolveDecision(decision)
 3879        except MissingDecisionError:
 3880            return None
 3881
 3882        reciprocal = self.getReciprocal(dID, transition)
 3883        if reciprocal is None:
 3884            return None
 3885        else:
 3886            destination = self.getDestination(dID, transition)
 3887            if destination is None:
 3888                return None
 3889            else:
 3890                return (destination, reciprocal)
 3891
 3892    def addDecision(
 3893        self,
 3894        name: base.DecisionName,
 3895        domain: Optional[base.Domain] = None,
 3896        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 3897        annotations: Optional[List[base.Annotation]] = None
 3898    ) -> base.DecisionID:
 3899        """
 3900        Adds a decision to the graph, without any transitions yet. Each
 3901        decision will be assigned an ID so name collisions are allowed,
 3902        but it's usually best to keep names unique at least within each
 3903        zone. If no domain is provided, the `DEFAULT_DOMAIN` will be
 3904        used for the decision's domain. A dictionary of tags and/or a
 3905        list of annotations (strings in both cases) may be provided.
 3906
 3907        Returns the newly-assigned `DecisionID` for the decision it
 3908        created.
 3909
 3910        Emits a `DecisionCollisionWarning` if a decision with the
 3911        provided name already exists and the `WARN_OF_NAME_COLLISIONS`
 3912        global variable is set to `True`.
 3913        """
 3914        # Defaults
 3915        if domain is None:
 3916            domain = base.DEFAULT_DOMAIN
 3917        if tags is None:
 3918            tags = {}
 3919        if annotations is None:
 3920            annotations = []
 3921
 3922        # Error checking
 3923        if name in self.nameLookup and WARN_OF_NAME_COLLISIONS:
 3924            warnings.warn(
 3925                (
 3926                    f"Adding decision {name!r}: Another decision with"
 3927                    f" that name already exists."
 3928                ),
 3929                DecisionCollisionWarning
 3930            )
 3931
 3932        dID = self._assignID()
 3933
 3934        # Add the decision
 3935        self.add_node(
 3936            dID,
 3937            name=name,
 3938            domain=domain,
 3939            tags=tags,
 3940            annotations=annotations
 3941        )
 3942        #TODO: Elide tags/annotations if they're empty?
 3943
 3944        # Track it in our `nameLookup` dictionary
 3945        self.nameLookup.setdefault(name, []).append(dID)
 3946
 3947        return dID
 3948
 3949    def addIdentifiedDecision(
 3950        self,
 3951        dID: base.DecisionID,
 3952        name: base.DecisionName,
 3953        domain: Optional[base.Domain] = None,
 3954        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 3955        annotations: Optional[List[base.Annotation]] = None
 3956    ) -> None:
 3957        """
 3958        Adds a new decision to the graph using a specific decision ID,
 3959        rather than automatically assigning a new decision ID like
 3960        `addDecision` does. Otherwise works like `addDecision`.
 3961
 3962        Raises a `DecisionCollisionError` if the specified decision ID
 3963        is already in use.
 3964        """
 3965        # Defaults
 3966        if domain is None:
 3967            domain = base.DEFAULT_DOMAIN
 3968        if tags is None:
 3969            tags = {}
 3970        if annotations is None:
 3971            annotations = []
 3972
 3973        # Error checking
 3974        if dID in self.nodes:
 3975            raise DecisionCollisionError(
 3976                f"Cannot add a node with id {dID} and name {name!r}:"
 3977                f" that ID is already used by node {self.identityOf(dID)}"
 3978            )
 3979
 3980        if name in self.nameLookup and WARN_OF_NAME_COLLISIONS:
 3981            warnings.warn(
 3982                (
 3983                    f"Adding decision {name!r}: Another decision with"
 3984                    f" that name already exists."
 3985                ),
 3986                DecisionCollisionWarning
 3987            )
 3988
 3989        # Add the decision
 3990        self.add_node(
 3991            dID,
 3992            name=name,
 3993            domain=domain,
 3994            tags=tags,
 3995            annotations=annotations
 3996        )
 3997        #TODO: Elide tags/annotations if they're empty?
 3998
 3999        # Track it in our `nameLookup` dictionary
 4000        self.nameLookup.setdefault(name, []).append(dID)
 4001
 4002    def addTransition(
 4003        self,
 4004        fromDecision: base.AnyDecisionSpecifier,
 4005        name: base.Transition,
 4006        toDecision: base.AnyDecisionSpecifier,
 4007        reciprocal: Optional[base.Transition] = None,
 4008        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 4009        annotations: Optional[List[base.Annotation]] = None,
 4010        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
 4011        revAnnotations: Optional[List[base.Annotation]] = None,
 4012        requires: Optional[base.Requirement] = None,
 4013        consequence: Optional[base.Consequence] = None,
 4014        revRequires: Optional[base.Requirement] = None,
 4015        revConsequece: Optional[base.Consequence] = None
 4016    ) -> None:
 4017        """
 4018        Adds a transition connecting two decisions. A specifier for each
 4019        decision is required, as is a name for the transition. If a
 4020        `reciprocal` is provided, a reciprocal edge will be added in the
 4021        opposite direction using that name; by default only the specified
 4022        edge is added. A `TransitionCollisionError` will be raised if the
 4023        `reciprocal` matches the name of an existing edge at the
 4024        destination decision.
 4025
 4026        Both decisions must already exist, or a `MissingDecisionError`
 4027        will be raised.
 4028
 4029        A dictionary of tags and/or a list of annotations may be
 4030        provided. Tags and/or annotations for the reverse edge may also
 4031        be specified if one is being added.
 4032
 4033        The `requires`, `consequence`, `revRequires`, and `revConsequece`
 4034        arguments specify requirements and/or consequences of the new
 4035        outgoing and reciprocal edges.
 4036
 4037        An example:
 4038
 4039        >>> g = DecisionGraph()
 4040        >>> g.addDecision('A')
 4041        0
 4042        >>> g.addDecision('B')
 4043        1
 4044        >>> g.addDecision('C')
 4045        2
 4046        >>> g.addTransition('A', 'up', 'B', 'down')
 4047        >>> g.destinationsFrom('A')
 4048        {'up': 1}
 4049        >>> g.destinationsFrom('B')
 4050        {'down': 0}
 4051        >>> g.addTransition('A', 'right', 'C', 'left')
 4052        >>> g.destinationsFrom('A')
 4053        {'up': 1, 'right': 2}
 4054        >>> g.destinationsFrom('C')
 4055        {'left': 0}
 4056        """
 4057        # Defaults
 4058        if tags is None:
 4059            tags = {}
 4060        if annotations is None:
 4061            annotations = []
 4062        if revTags is None:
 4063            revTags = {}
 4064        if revAnnotations is None:
 4065            revAnnotations = []
 4066
 4067        # Error checking
 4068        fromID = self.resolveDecision(fromDecision)
 4069        toID = self.resolveDecision(toDecision)
 4070
 4071        # Note: have to check this first so we don't add the forward edge
 4072        # and then error out after a side effect!
 4073        if (
 4074            reciprocal is not None
 4075        and self.getDestination(toDecision, reciprocal) is not None
 4076        ):
 4077            raise TransitionCollisionError(
 4078                f"Cannot add a transition from"
 4079                f" {self.identityOf(fromDecision)} to"
 4080                f" {self.identityOf(toDecision)} with reciprocal edge"
 4081                f" {reciprocal!r}: {reciprocal!r} is already used as an"
 4082                f" edge name at {self.identityOf(toDecision)}."
 4083            )
 4084
 4085        # Add the edge
 4086        self.add_edge(
 4087            fromID,
 4088            toID,
 4089            key=name,
 4090            tags=tags,
 4091            annotations=annotations
 4092        )
 4093        self.setTransitionRequirement(fromID, name, requires)
 4094        if consequence is not None:
 4095            self.setConsequence(fromID, name, consequence)
 4096        if reciprocal is not None:
 4097            # Add the reciprocal edge
 4098            self.add_edge(
 4099                toID,
 4100                fromID,
 4101                key=reciprocal,
 4102                tags=revTags,
 4103                annotations=revAnnotations
 4104            )
 4105            self.setReciprocal(fromID, name, reciprocal)
 4106            self.setTransitionRequirement(
 4107                toID,
 4108                reciprocal,
 4109                revRequires
 4110            )
 4111            if revConsequece is not None:
 4112                self.setConsequence(toID, reciprocal, revConsequece)
 4113
 4114    def removeTransition(
 4115        self,
 4116        fromDecision: base.AnyDecisionSpecifier,
 4117        transition: base.Transition,
 4118        removeReciprocal=False
 4119    ) -> Union[
 4120        TransitionProperties,
 4121        Tuple[TransitionProperties, TransitionProperties]
 4122    ]:
 4123        """
 4124        Removes a transition. If `removeReciprocal` is true (False is the
 4125        default) any reciprocal transition will also be removed (but no
 4126        error will occur if there wasn't a reciprocal).
 4127
 4128        For each removed transition, *every* transition that targeted
 4129        that transition as its reciprocal will have its reciprocal set to
 4130        `None`, to avoid leaving any invalid reciprocal values.
 4131
 4132        Raises a `KeyError` if either the target decision or the target
 4133        transition does not exist.
 4134
 4135        Returns a transition properties dictionary with the properties
 4136        of the removed transition, or if `removeReciprocal` is true,
 4137        returns a pair of such dictionaries for the target transition
 4138        and its reciprocal.
 4139
 4140        ## Example
 4141
 4142        >>> g = DecisionGraph()
 4143        >>> g.addDecision('A')
 4144        0
 4145        >>> g.addDecision('B')
 4146        1
 4147        >>> g.addTransition('A', 'up', 'B', 'down', tags={'wide'})
 4148        >>> g.addTransition('A', 'in', 'B', 'out') # we won't touch this
 4149        >>> g.addTransition('A', 'next', 'B')
 4150        >>> g.setReciprocal('A', 'next', 'down', setBoth=False)
 4151        >>> p = g.removeTransition('A', 'up')
 4152        >>> p['tags']
 4153        {'wide'}
 4154        >>> g.destinationsFrom('A')
 4155        {'in': 1, 'next': 1}
 4156        >>> g.destinationsFrom('B')
 4157        {'down': 0, 'out': 0}
 4158        >>> g.getReciprocal('B', 'down') is None
 4159        True
 4160        >>> g.getReciprocal('A', 'next') # Asymmetrical left over
 4161        'down'
 4162        >>> g.getReciprocal('A', 'in') # not affected
 4163        'out'
 4164        >>> g.getReciprocal('B', 'out') # not affected
 4165        'in'
 4166        >>> # Now with removeReciprocal set to True
 4167        >>> g.addTransition('A', 'up', 'B') # add this back in
 4168        >>> g.setReciprocal('A', 'up', 'down') # sets both
 4169        >>> p = g.removeTransition('A', 'up', removeReciprocal=True)
 4170        >>> g.destinationsFrom('A')
 4171        {'in': 1, 'next': 1}
 4172        >>> g.destinationsFrom('B')
 4173        {'out': 0}
 4174        >>> g.getReciprocal('A', 'next') is None
 4175        True
 4176        >>> g.getReciprocal('A', 'in') # not affected
 4177        'out'
 4178        >>> g.getReciprocal('B', 'out') # not affected
 4179        'in'
 4180        >>> g.removeTransition('A', 'none')
 4181        Traceback (most recent call last):
 4182        ...
 4183        exploration.core.MissingTransitionError...
 4184        >>> g.removeTransition('Z', 'nope')
 4185        Traceback (most recent call last):
 4186        ...
 4187        exploration.core.MissingDecisionError...
 4188        """
 4189        # Resolve target ID
 4190        fromID = self.resolveDecision(fromDecision)
 4191
 4192        # raises if either is missing:
 4193        destination = self.destination(fromID, transition)
 4194        reciprocal = self.getReciprocal(fromID, transition)
 4195
 4196        # Get dictionaries of parallel & antiparallel edges to be
 4197        # checked for invalid reciprocals after removing edges
 4198        # Note: these will update live as we remove edges
 4199        allAntiparallel = self[destination][fromID]
 4200        allParallel = self[fromID][destination]
 4201
 4202        # Remove the target edge
 4203        fProps = self.getTransitionProperties(fromID, transition)
 4204        self.remove_edge(fromID, destination, transition)
 4205
 4206        # Clean up any dangling reciprocal values
 4207        for tProps in allAntiparallel.values():
 4208            if tProps.get('reciprocal') == transition:
 4209                del tProps['reciprocal']
 4210
 4211        # Remove the reciprocal if requested
 4212        if removeReciprocal and reciprocal is not None:
 4213            rProps = self.getTransitionProperties(destination, reciprocal)
 4214            self.remove_edge(destination, fromID, reciprocal)
 4215
 4216            # Clean up any dangling reciprocal values
 4217            for tProps in allParallel.values():
 4218                if tProps.get('reciprocal') == reciprocal:
 4219                    del tProps['reciprocal']
 4220
 4221            return (fProps, rProps)
 4222        else:
 4223            return fProps
 4224
 4225    def addMechanism(
 4226        self,
 4227        name: base.MechanismName,
 4228        where: Optional[base.AnyDecisionSpecifier] = None
 4229    ) -> base.MechanismID:
 4230        """
 4231        Creates a new mechanism with the given name at the specified
 4232        decision, returning its assigned ID. If `where` is `None`, it
 4233        creates a global mechanism. Raises a `MechanismCollisionError`
 4234        if a mechanism with the same name already exists at a specified
 4235        decision (or already exists as a global mechanism).
 4236
 4237        Note that if the decision is deleted, the mechanism will be as
 4238        well.
 4239
 4240        Since `MechanismState`s are not tracked by `DecisionGraph`s but
 4241        instead are part of a `State`, the mechanism won't be in any
 4242        particular state, which means it will be treated as being in the
 4243        `base.DEFAULT_MECHANISM_STATE`.
 4244        """
 4245        if where is None:
 4246            mechs = self.globalMechanisms
 4247            dID = None
 4248        else:
 4249            dID = self.resolveDecision(where)
 4250            mechs = self.nodes[dID].setdefault('mechanisms', {})
 4251
 4252        if name in mechs:
 4253            if dID is None:
 4254                raise MechanismCollisionError(
 4255                    f"A global mechanism named {name!r} already exists."
 4256                )
 4257            else:
 4258                raise MechanismCollisionError(
 4259                    f"A mechanism named {name!r} already exists at"
 4260                    f" decision {self.identityOf(dID)}."
 4261                )
 4262
 4263        mID = self._assignMechanismID()
 4264        mechs[name] = mID
 4265        self.mechanisms[mID] = (dID, name)
 4266        return mID
 4267
 4268    def mechanismsAt(
 4269        self,
 4270        decision: base.AnyDecisionSpecifier
 4271    ) -> Dict[base.MechanismName, base.MechanismID]:
 4272        """
 4273        Returns a dictionary mapping mechanism names to their IDs for
 4274        all mechanisms at the specified decision.
 4275        """
 4276        dID = self.resolveDecision(decision)
 4277
 4278        return self.nodes[dID]['mechanisms']
 4279
 4280    def mechanismDetails(
 4281        self,
 4282        mID: base.MechanismID
 4283    ) -> Optional[Tuple[Optional[base.DecisionID], base.MechanismName]]:
 4284        """
 4285        Returns a tuple containing the decision ID and mechanism name
 4286        for the specified mechanism. Returns `None` if there is no
 4287        mechanism with that ID. For global mechanisms, `None` is used in
 4288        place of a decision ID.
 4289        """
 4290        return self.mechanisms.get(mID)
 4291
 4292    def deleteMechanism(self, mID: base.MechanismID) -> None:
 4293        """
 4294        Deletes the specified mechanism.
 4295        """
 4296        name, dID = self.mechanisms.pop(mID)
 4297
 4298        del self.nodes[dID]['mechanisms'][name]
 4299
 4300    def localLookup(
 4301        self,
 4302        startFrom: Union[
 4303            base.AnyDecisionSpecifier,
 4304            Collection[base.AnyDecisionSpecifier]
 4305        ],
 4306        findAmong: Callable[
 4307            ['DecisionGraph', Union[Set[base.DecisionID], str]],
 4308            Optional[LookupResult]
 4309        ],
 4310        fallbackLayerName: Optional[str] = "fallback",
 4311        fallbackToAllDecisions: bool = True
 4312    ) -> Optional[LookupResult]:
 4313        """
 4314        Looks up some kind of result in the graph by starting from a
 4315        base set of decisions and widening the search iteratively based
 4316        on zones. This first searches for result(s) in the set of
 4317        decisions given, then in the set of all decisions which are in
 4318        level-0 zones containing those decisions, then in level-1 zones,
 4319        etc. When it runs out of relevant zones, it will check all
 4320        decisions which are in any domain that a decision from the
 4321        initial search set is in, and then if `fallbackLayerName` is a
 4322        string, it will provide that string instead of a set of decision
 4323        IDs to the `findAmong` function as the next layer to search.
 4324        After the `fallbackLayerName` is used, if
 4325        `fallbackToAllDecisions` is `True` (the default) a final search
 4326        will be run on all decisions in the graph. The provided
 4327        `findAmong` function is called on each successive decision ID
 4328        set, until it generates a non-`None` result. We stop and return
 4329        that non-`None` result as soon as one is generated. But if none
 4330        of the decision sets consulted generate non-`None` results, then
 4331        the entire result will be `None`.
 4332        """
 4333        # Normalize starting decisions to a set
 4334        if isinstance(startFrom, (int, str, base.DecisionSpecifier)):
 4335            startFrom = set([startFrom])
 4336
 4337        # Resolve decision IDs; convert to set
 4338        searchArea: Union[Set[base.DecisionID], str] = set(
 4339            self.resolveDecision(spec) for spec in startFrom
 4340        )
 4341
 4342        # Find all ancestor zones & all relevant domains
 4343        allAncestors = set()
 4344        relevantDomains = set()
 4345        for startingDecision in searchArea:
 4346            allAncestors |= self.zoneAncestors(startingDecision)
 4347            relevantDomains.add(self.domainFor(startingDecision))
 4348
 4349        # Build layers dictionary
 4350        ancestorLayers: Dict[int, Set[base.Zone]] = {}
 4351        for zone in allAncestors:
 4352            info = self.getZoneInfo(zone)
 4353            assert info is not None
 4354            level = info.level
 4355            ancestorLayers.setdefault(level, set()).add(zone)
 4356
 4357        searchLayers: LookupLayersList = (
 4358            cast(LookupLayersList, [None])
 4359          + cast(LookupLayersList, sorted(ancestorLayers.keys()))
 4360          + cast(LookupLayersList, ["domains"])
 4361        )
 4362        if fallbackLayerName is not None:
 4363            searchLayers.append("fallback")
 4364
 4365        if fallbackToAllDecisions:
 4366            searchLayers.append("all")
 4367
 4368        # Continue our search through zone layers
 4369        for layer in searchLayers:
 4370            # Update search area on subsequent iterations
 4371            if layer == "domains":
 4372                searchArea = set()
 4373                for relevant in relevantDomains:
 4374                    searchArea |= self.allDecisionsInDomain(relevant)
 4375            elif layer == "fallback":
 4376                assert fallbackLayerName is not None
 4377                searchArea = fallbackLayerName
 4378            elif layer == "all":
 4379                searchArea = set(self.nodes)
 4380            elif layer is not None:
 4381                layer = cast(int, layer)  # must be an integer
 4382                searchZones = ancestorLayers[layer]
 4383                searchArea = set()
 4384                for zone in searchZones:
 4385                    searchArea |= self.allDecisionsInZone(zone)
 4386            # else it's the first iteration and we use the starting
 4387            # searchArea
 4388
 4389            try:
 4390                searchResult: Optional[LookupResult] = findAmong(
 4391                    self,
 4392                    searchArea
 4393                )
 4394            except Exception as e:
 4395                note = f" (Search started from: {startFrom!r})"
 4396                if hasattr(e, "add_note"):
 4397                    e.add_note(note)
 4398                else:
 4399                    e.args = (e.args[0] + note,) + e.args[1:]
 4400                raise e
 4401
 4402            if searchResult is not None:
 4403                return searchResult
 4404
 4405        # Didn't find any non-None results.
 4406        return None
 4407
 4408    @staticmethod
 4409    def uniqueMechanismFinder(name: base.MechanismName) -> Callable[
 4410        ['DecisionGraph', Union[Set[base.DecisionID], str]],
 4411        Optional[base.MechanismID]
 4412    ]:
 4413        """
 4414        Returns a search function that looks for the given mechanism ID,
 4415        suitable for use with `localLookup`. The finder will raise a
 4416        `AmbiguousMechanismError` if it finds more than one mechanism
 4417        with the specified name at the same level of the search.
 4418        """
 4419        def namedMechanismFinder(
 4420            graph: 'DecisionGraph',
 4421            searchIn: Union[Set[base.DecisionID], str]
 4422        ) -> Optional[base.MechanismID]:
 4423            """
 4424            Generated finder function for `localLookup` to find a unique
 4425            mechanism by name.
 4426            """
 4427            candidates: List[base.MechanismID] = []
 4428
 4429            if searchIn == "fallback":
 4430                if name in graph.globalMechanisms:
 4431                    candidates = [graph.globalMechanisms[name]]
 4432
 4433            else:
 4434                assert isinstance(searchIn, set)
 4435                for dID in searchIn:
 4436                    mechs = graph.nodes[dID].get('mechanisms', {})
 4437                    if name in mechs:
 4438                        candidates.append(mechs[name])
 4439
 4440            if len(candidates) > 1:
 4441                raise AmbiguousMechanismError(
 4442                    f"There are {len(candidates)} mechanisms named {name!r}"
 4443                    f" in the search area ({len(searchIn)} decisions(s))."
 4444                )
 4445            elif len(candidates) == 1:
 4446                return candidates[0]
 4447            else:
 4448                return None
 4449
 4450        return namedMechanismFinder
 4451
 4452    def lookupMechanism(
 4453        self,
 4454        startFrom: Union[
 4455            base.AnyDecisionSpecifier,
 4456            Collection[base.AnyDecisionSpecifier]
 4457        ],
 4458        name: base.MechanismName
 4459    ) -> base.MechanismID:
 4460        """
 4461        Looks up the mechanism with the given name 'closest' to the
 4462        given decision or set of decisions. First it looks for a
 4463        mechanism with that name that's at one of those decisions. Then
 4464        it starts looking in level-0 zones which contain any of them,
 4465        then in level-1 zones, and so on. If it finds two mechanisms
 4466        with the target name during the same search pass, it raises a
 4467        `AmbiguousMechanismError`, but if it finds one it returns it.
 4468        Raises a `MissingMechanismError` if there is no mechanisms with
 4469        that name among global mechanisms (searched after the last
 4470        applicable level of zones) or anywhere in the graph (which is the
 4471        final level of search after checking global mechanisms).
 4472
 4473        For example:
 4474
 4475        >>> d = DecisionGraph()
 4476        >>> d.addDecision('A')
 4477        0
 4478        >>> d.addDecision('B')
 4479        1
 4480        >>> d.addDecision('C')
 4481        2
 4482        >>> d.addDecision('D')
 4483        3
 4484        >>> d.addDecision('E')
 4485        4
 4486        >>> d.addMechanism('switch', 'A')
 4487        0
 4488        >>> d.addMechanism('switch', 'B')
 4489        1
 4490        >>> d.addMechanism('switch', 'C')
 4491        2
 4492        >>> d.addMechanism('lever', 'D')
 4493        3
 4494        >>> d.addMechanism('lever', None)  # global
 4495        4
 4496        >>> d.createZone('Z1', 0)
 4497        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 4498 annotations=[])
 4499        >>> d.createZone('Z2', 0)
 4500        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 4501 annotations=[])
 4502        >>> d.createZone('Zup', 1)
 4503        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 4504 annotations=[])
 4505        >>> d.addDecisionToZone('A', 'Z1')
 4506        >>> d.addDecisionToZone('B', 'Z1')
 4507        >>> d.addDecisionToZone('C', 'Z2')
 4508        >>> d.addDecisionToZone('D', 'Z2')
 4509        >>> d.addDecisionToZone('E', 'Z1')
 4510        >>> d.addZoneToZone('Z1', 'Zup')
 4511        >>> d.addZoneToZone('Z2', 'Zup')
 4512        >>> d.lookupMechanism(set(), 'switch')  # 3x among all decisions
 4513        Traceback (most recent call last):
 4514        ...
 4515        exploration.core.AmbiguousMechanismError...
 4516        >>> d.lookupMechanism(set(), 'lever')  # 1x global > 1x all
 4517        4
 4518        >>> d.lookupMechanism({'D'}, 'lever')  # local
 4519        3
 4520        >>> d.lookupMechanism({'A'}, 'lever')  # found at D via Zup
 4521        3
 4522        >>> d.lookupMechanism({'A', 'D'}, 'lever')  # local again
 4523        3
 4524        >>> d.lookupMechanism({'A'}, 'switch')  # local
 4525        0
 4526        >>> d.lookupMechanism({'B'}, 'switch')  # local
 4527        1
 4528        >>> d.lookupMechanism({'C'}, 'switch')  # local
 4529        2
 4530        >>> d.lookupMechanism({'A', 'B'}, 'switch')  # ambiguous
 4531        Traceback (most recent call last):
 4532        ...
 4533        exploration.core.AmbiguousMechanismError...
 4534        >>> d.lookupMechanism({'A', 'B', 'C'}, 'switch')  # ambiguous
 4535        Traceback (most recent call last):
 4536        ...
 4537        exploration.core.AmbiguousMechanismError...
 4538        >>> d.lookupMechanism({'B', 'D'}, 'switch')  # not ambiguous
 4539        1
 4540        >>> d.lookupMechanism({'E', 'D'}, 'switch')  # ambiguous at L0 zone
 4541        Traceback (most recent call last):
 4542        ...
 4543        exploration.core.AmbiguousMechanismError...
 4544        >>> d.lookupMechanism({'E'}, 'switch')  # ambiguous at L0 zone
 4545        Traceback (most recent call last):
 4546        ...
 4547        exploration.core.AmbiguousMechanismError...
 4548        >>> d.lookupMechanism({'D'}, 'switch')  # found at L0 zone
 4549        2
 4550        """
 4551        result = self.localLookup(
 4552            startFrom,
 4553            DecisionGraph.uniqueMechanismFinder(name)
 4554        )
 4555        if result is None:
 4556            raise MissingMechanismError(
 4557                f"No mechanism named {name!r}"
 4558            )
 4559        else:
 4560            return result
 4561
 4562    def resolveMechanism(
 4563        self,
 4564        specifier: base.AnyMechanismSpecifier,
 4565        startFrom: Union[
 4566            None,
 4567            base.AnyDecisionSpecifier,
 4568            Collection[base.AnyDecisionSpecifier]
 4569        ] = None
 4570    ) -> base.MechanismID:
 4571        """
 4572        Works like `lookupMechanism`, except it accepts a
 4573        `base.AnyMechanismSpecifier` which may have position information
 4574        baked in, and so the `startFrom` information is optional. If
 4575        position information isn't specified in the mechanism specifier
 4576        and startFrom is not provided, the mechanism is searched for at
 4577        the global scope and then in the entire graph. On the other
 4578        hand, if the specifier includes any position information, the
 4579        startFrom value provided here will be ignored.
 4580        """
 4581        if isinstance(specifier, base.MechanismID):
 4582            return specifier
 4583
 4584        elif isinstance(specifier, base.MechanismName):
 4585            if startFrom is None:
 4586                startFrom = set()
 4587            return self.lookupMechanism(startFrom, specifier)
 4588
 4589        elif isinstance(specifier, base.MechanismSpecifier):
 4590            domain, zone, decision, mechanism = specifier
 4591            if domain is None and zone is None and decision is None:
 4592                if startFrom is None:
 4593                    startFrom = set()
 4594                return self.lookupMechanism(startFrom, mechanism)
 4595
 4596            elif isinstance(decision, base.DecisionID):
 4597                # Specifying a decision ID restricts the mechanism to
 4598                # appear at exactly that decision and NOT be global...
 4599                if domain is not None or zone is not None:
 4600                    warnings.warn(
 4601                        (
 4602                            f"Mechanism specifier includes domain and/or"
 4603                            f" zone in addition to decision-by-ID:"
 4604                            f" {specifier!r}"
 4605                        ),
 4606                        InvalidMechanismSpecifierWarning
 4607                    )
 4608
 4609                mechs = self.nodes[decision].get('mechanisms', {})
 4610                found = mechs.get(mechanism)
 4611                if found is None:
 4612                    raise MissingMechanismError(
 4613                        f"No mechanism named {mechanism!r} at specific"
 4614                        f" decision {self.identityOf(decision)}"
 4615                    )
 4616                return found
 4617
 4618            elif decision is not None:
 4619                startFrom = self.resolveDecisions(
 4620                    base.DecisionSpecifier(domain, zone, decision)
 4621                )
 4622                return self.lookupMechanism(startFrom, mechanism)
 4623
 4624            else:  # decision is None but domain and/or zone aren't
 4625                startFrom = set()
 4626                if zone is not None:
 4627                    baseStart = self.allDecisionsInZone(zone)
 4628                else:
 4629                    baseStart = set(self)
 4630
 4631                if domain is None:
 4632                    startFrom = baseStart
 4633                else:
 4634                    for dID in baseStart:
 4635                        if self.domainFor(dID) == domain:
 4636                            startFrom.add(dID)
 4637                return self.lookupMechanism(startFrom, mechanism)
 4638
 4639        else:
 4640            raise TypeError(
 4641                f"Invalid mechanism specifier: {repr(specifier)}"
 4642                f"\n(Must be a mechanism ID, mechanism name, or"
 4643                f" mechanism specifier tuple)"
 4644            )
 4645
 4646    def legibleMechanismSpecifier(
 4647        self,
 4648        mID: base.MechanismID,
 4649        minimal: bool = False
 4650    ) -> Union[base.MechanismSpecifier, base.MechanismID]:
 4651        '''
 4652        Given a mechanism ID, returns an unambiguous
 4653        `base.MechanismSpecifier` for that mechanism, including
 4654        domain/zone/decision-name parts as necessary. If there is no
 4655        unambiguous specifier for that mechanism, returns the mechanism
 4656        ID as-is. Also returns the ID as-is if no mechanism with that ID
 4657        exists.
 4658
 4659        Set `minimal` to True (default is `False`) to use a minimal
 4660        unambiguous specifier (more likely to be made ambiguous by
 4661        future decision/mechanism additions).
 4662
 4663        Some examples:
 4664
 4665        >>> g = DecisionGraph()
 4666        >>> g.addDecision('A')
 4667        0
 4668        >>> g.addDecision('B')
 4669        1
 4670        >>> g.addDecision('C')
 4671        2
 4672        >>> g.addDecision('A')
 4673        3
 4674        >>> g.addDecision('C')
 4675        4
 4676        >>> g.createZone('Z', 0)
 4677        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 4678 annotations=[])
 4679        >>> g.createZone('Q', 0)
 4680        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 4681 annotations=[])
 4682        >>> g.addDecisionToZone(0, 'Z')
 4683        >>> g.addDecisionToZone('B', 'Z')
 4684        >>> g.addDecisionToZone(3, 'Q')
 4685        >>> g.addMechanism('global')
 4686        0
 4687        >>> g.addMechanism('door', 0)
 4688        1
 4689        >>> g.addMechanism('door', 2)
 4690        2
 4691        >>> g.addMechanism('block', 2)
 4692        3
 4693        >>> g.addMechanism('lever', 0)
 4694        4
 4695        >>> g.addMechanism('lever', 'B')
 4696        5
 4697        >>> g.addMechanism('door', 3)
 4698        6
 4699        >>> g.addMechanism('block', 4)
 4700        7
 4701        >>> g.legibleMechanismSpecifier(0)
 4702        MechanismSpecifier(domain=None, zone=None, decision=None, name='global')
 4703        >>> g.legibleMechanismSpecifier(1)
 4704        MechanismSpecifier(domain='main', zone='Z', decision='A', name='door')
 4705        >>> g.legibleMechanismSpecifier(2)
 4706        MechanismSpecifier(domain='main', zone=None, decision='C', name='door')
 4707        >>> g.legibleMechanismSpecifier(3)  # ambiguous with 'block' at C[4]
 4708        3
 4709        >>> g.legibleMechanismSpecifier(4)
 4710        MechanismSpecifier(domain='main', zone='Z', decision='A', name='lever')
 4711        >>> g.legibleMechanismSpecifier(5)
 4712        MechanismSpecifier(domain='main', zone='Z', decision='B', name='lever')
 4713        >>> g.legibleMechanismSpecifier(6)
 4714        MechanismSpecifier(domain='main', zone='Q', decision='A', name='door')
 4715        >>> g.legibleMechanismSpecifier(7)  # ambiguous with 'block' at C[2]
 4716        7
 4717        '''
 4718        details = self.mechanismDetails(mID)
 4719        if details is None:
 4720            return mID
 4721        elif not minimal:
 4722            # Go straight to as full a specifier as we can
 4723            dID, mName = details
 4724            if dID is None:
 4725                maybe = base.MechanismSpecifier(None, None, None, mName)
 4726                try:
 4727                    resolved = self.resolveMechanism(maybe)
 4728                    if resolved == mID:
 4729                        return maybe
 4730                    else:
 4731                        return mID
 4732                    # Else got wrong one without specifying more info
 4733                except (
 4734                    AmbiguousMechanismError,
 4735                    AmbiguousDecisionSpecifierError
 4736                ):
 4737                    # Not specific enough
 4738                    return mID
 4739                except MissingMechanismError:
 4740                    # Nothing findable with that name; return mID as-is
 4741                    # Note: This *should* be caught by case above instead I
 4742                    # think, but doesn't hurt to be defensive here
 4743                    return mID
 4744            else:
 4745                # Look up decision's info
 4746                dInfo = self.decisionInfo(dID)
 4747                dName = dInfo["name"]
 4748                dDomain = dInfo["domain"]
 4749
 4750                # Include domain and first zone we can find that's
 4751                # unambiguous
 4752                for zone in self.zoneParents(dID):
 4753                    maybe = base.MechanismSpecifier(
 4754                        dDomain,
 4755                        zone,
 4756                        dName,
 4757                        mName
 4758                    )
 4759                    try:
 4760                        resolved = self.resolveMechanism(maybe)
 4761                        if resolved == mID:
 4762                            return maybe
 4763                        else:
 4764                            # Got wrong one for this zone
 4765                            continue
 4766                    except (
 4767                        AmbiguousMechanismError,
 4768                        AmbiguousDecisionSpecifierError
 4769                    ):
 4770                        # Not specific enough
 4771                        continue
 4772                    except MissingMechanismError:
 4773                        # Shouldn't be possible, but just in case
 4774                        return mID
 4775
 4776                # Try without a zone
 4777                maybe = base.MechanismSpecifier(
 4778                    dDomain,
 4779                    None,
 4780                    dName,
 4781                    mName
 4782                )
 4783                try:
 4784                    resolved = self.resolveMechanism(maybe)
 4785                    if resolved == mID:
 4786                        return maybe
 4787                    else:
 4788                        # Got wrong one; no altenratives
 4789                        return mID
 4790                except (
 4791                    AmbiguousMechanismError,
 4792                    AmbiguousDecisionSpecifierError
 4793                ) as e:
 4794                    # Not specific enough
 4795                    return mID
 4796                except MissingMechanismError:
 4797                    # Shouldn't be possible, but just in case
 4798                    return mID
 4799        else:
 4800            # Minimal requested; details were available
 4801            dID, mName = details
 4802            # First try bare specifier with just name. Should catch
 4803            # global mechanisms as well as unique locals
 4804            maybe = base.MechanismSpecifier(None, None, None, mName)
 4805            try:
 4806                resolved = self.resolveMechanism(maybe)
 4807                if resolved == mID:
 4808                    return maybe
 4809                # Else got wrong one without specifying more info
 4810            except (
 4811                AmbiguousMechanismError,
 4812                AmbiguousDecisionSpecifierError
 4813            ):
 4814                # Not specific enough
 4815                pass
 4816            except MissingMechanismError:
 4817                # Nothing findable with that name; return mID as-is
 4818                # Note: This *should* be caught by case above instead I
 4819                # think, but doesn't hurt to be defensive here
 4820                return mID
 4821
 4822            # A global mechanism we couldn't resolve
 4823            if dID is None:
 4824                return mID
 4825
 4826            # Look up decision's info
 4827            dInfo = self.decisionInfo(dID)
 4828            dName = dInfo["name"]
 4829            dDomain = dInfo["domain"]
 4830
 4831            # Try with just decision name
 4832            maybe = base.MechanismSpecifier(None, None, dName, mName)
 4833            try:
 4834                resolved = self.resolveMechanism(maybe)
 4835                if resolved == mID:
 4836                    return maybe
 4837                # Else got wrong one without specifying more info
 4838            except (
 4839                AmbiguousMechanismError,
 4840                AmbiguousDecisionSpecifierError
 4841            ):
 4842                # Not specific enough
 4843                pass
 4844            except MissingMechanismError:
 4845                # Shouldn't be possible, but just in case
 4846                return mID
 4847
 4848            # Try each possible direct parent zone
 4849            for zone in self.zoneParents(dID):
 4850                maybe = base.MechanismSpecifier(None, zone, dName, mName)
 4851                try:
 4852                    resolved = self.resolveMechanism(maybe)
 4853                    if resolved == mID:
 4854                        return maybe
 4855                    # Else got wrong one without specifying more info
 4856                except (
 4857                    AmbiguousMechanismError,
 4858                    AmbiguousDecisionSpecifierError
 4859                ):
 4860                    # Not specific enough
 4861                    pass
 4862                except MissingMechanismError:
 4863                    # Shouldn't be possible, but just in case
 4864                    return mID
 4865
 4866            # No zones or none specific enough: try adding domain w/
 4867            # each zone
 4868            for zone in self.zoneParents(dID):
 4869                maybe = base.MechanismSpecifier(dDomain, zone, dName, mName)
 4870                try:
 4871                    resolved = self.resolveMechanism(maybe)
 4872                    if resolved == mID:
 4873                        return maybe
 4874                    # Else got wrong one without specifying more info
 4875                except (
 4876                    AmbiguousMechanismError,
 4877                    AmbiguousDecisionSpecifierError
 4878                ):
 4879                    # Not specific enough
 4880                    pass
 4881                except MissingMechanismError:
 4882                    # Shouldn't be possible, but just in case
 4883                    return mID
 4884
 4885            # Nothing but ID is specific enough
 4886            return mID
 4887
 4888    def walkConsequenceMechanisms(
 4889        self,
 4890        consequence: base.Consequence,
 4891        searchFrom: Set[base.DecisionID],
 4892        replaceNames: int = 0
 4893    ) -> Generator[base.MechanismID, None, None]:
 4894        """
 4895        Yields each requirement in the given `base.Consequence`,
 4896        including those in `base.Condition`s, `base.ConditionalSkill`s
 4897        within `base.Challenge`s, and those set or toggled by
 4898        `base.Effect`s. The `searchFrom` argument specifies where to
 4899        start searching for mechanisms, since requirements include them
 4900        by name, not by ID.
 4901
 4902        If `replaceNames` is set to 1, any mechanism names resolved
 4903        during this process will be replaced by full mechanism
 4904        specifiers (see `DecisionGraph.legibleMechanismSpecifier`) that
 4905        include domain and a zone. Note that if the process crashes due
 4906        to an ambiguous mechanism name, requirements up to that point
 4907        will still have been changed.
 4908        
 4909        The default for `replaceNames` (0) will not change any mechanism
 4910        names/specifiers. Setting it to 2 instead of 1 will cause it to
 4911        use the least-specific unambiguous mechanism specifier it can
 4912        find (but note that if you're going to keep adding to the graph,
 4913        such specifiers are more likely to become ambiguous in the
 4914        future).
 4915
 4916        Set `replaceNames` to 3 to replace names with mechanism IDs
 4917        only.
 4918        """
 4919        for (index, part) in base.walkParts(consequence):
 4920            if isinstance(part, dict):
 4921                if 'skills' in part:  # a Challenge
 4922                    part = cast(base.Challenge, part)
 4923                    for cSkill in part['skills'].walk():
 4924                        if isinstance(cSkill, base.ConditionalSkill):
 4925                            yield from self.walkRequirementMechanisms(
 4926                                cSkill.requirement,
 4927                                searchFrom,
 4928                                replaceNames
 4929                            )
 4930                elif 'condition' in part:  # a Condition
 4931                    part = cast(base.Condition, part)
 4932                    yield from self.walkRequirementMechanisms(
 4933                        part['condition'],
 4934                        searchFrom,
 4935                        replaceNames
 4936                    )
 4937                elif 'value' in part:  # an Effect
 4938                    part = cast(base.Effect, part)
 4939                    val = part['value']
 4940                    if part['type'] == 'set':
 4941                        if (
 4942                            isinstance(val, tuple)
 4943                        and len(val) == 2
 4944                        and isinstance(val[1], base.MechanismState)
 4945                        ):
 4946                            resolved = self.resolveMechanism(
 4947                                cast(base.AnyMechanismSpecifier, val[0]),
 4948                                searchFrom
 4949                            )
 4950                            if replaceNames == 1:
 4951                                spec = self.legibleMechanismSpecifier(
 4952                                    resolved,
 4953                                    False
 4954                                )
 4955                            elif replaceNames == 2:
 4956                                spec = self.legibleMechanismSpecifier(
 4957                                    resolved,
 4958                                    True
 4959                                )
 4960                            elif replaceNames == 3:
 4961                                spec = resolved
 4962                            elif replaceNames != 0:
 4963                                raise ValueError(
 4964                                    f"Invalid replaceNames value:"
 4965                                    f" {replaceNames!r}"
 4966                                )
 4967                            if replaceNames > 0:
 4968                                part['value'] = (spec, val[1])
 4969                            yield resolved
 4970                    elif part['type'] == 'toggle':
 4971                        if isinstance(val, tuple):
 4972                            assert len(val) == 2
 4973                            between = cast(List[base.MechanismState], val[1])
 4974                            resolved = self.resolveMechanism(
 4975                                cast(base.AnyMechanismSpecifier, val[0]),
 4976                                searchFrom
 4977                            )
 4978                            if replaceNames == 1:
 4979                                spec = self.legibleMechanismSpecifier(
 4980                                    resolved,
 4981                                    False
 4982                                )
 4983                            elif replaceNames == 2:
 4984                                spec = self.legibleMechanismSpecifier(
 4985                                    resolved,
 4986                                    True
 4987                                )
 4988                            elif replaceNames == 3:
 4989                                spec = resolved
 4990                            elif replaceNames != 0:
 4991                                raise ValueError(
 4992                                    f"Invalid replaceNames value:"
 4993                                    f" {replaceNames!r}"
 4994                                )
 4995                            if replaceNames > 0:
 4996                                part['value'] = (spec, between)
 4997                            yield resolved
 4998            else:
 4999                # Sub-parts will get walked in separate iterations
 5000                pass
 5001
 5002    def walkRequirementMechanisms(
 5003        self,
 5004        req: base.Requirement,
 5005        searchFrom: Set[base.DecisionID],
 5006        replaceNames: int = 0
 5007    ) -> Generator[base.MechanismID, None, None]:
 5008        """
 5009        Given a requirement, yields any mechanisms mentioned in that
 5010        requirement, in depth-first traversal order.
 5011
 5012        If `replaceNames` is 1, 2, or 3 (default is 0) then the
 5013        requirement is actually edited to replace any mechanism names
 5014        with either a specifier or their resolved IDs. See
 5015        `walkConsequenceMechanisms` for `replaceNames` details.
 5016        """
 5017        for part in req.walk():
 5018            if isinstance(part, base.ReqMechanism):
 5019                mech = part.mechanism
 5020                resolved = self.resolveMechanism(
 5021                    mech,
 5022                    startFrom=searchFrom
 5023                )
 5024                if replaceNames in (1, 2, 3):
 5025                    if replaceNames == 1:
 5026                        spec = self.legibleMechanismSpecifier(
 5027                            resolved,
 5028                            False
 5029                        )
 5030                    elif replaceNames == 2:
 5031                        spec = self.legibleMechanismSpecifier(
 5032                            resolved,
 5033                            True
 5034                        )
 5035                    elif replaceNames == 3:
 5036                        spec = resolved
 5037                    part.mechanism = spec
 5038                elif replaceNames != 0:
 5039                    raise ValueError(
 5040                        f"Invalid replaceNames value:"
 5041                        f" {replaceNames!r}"
 5042                    )
 5043                yield resolved
 5044
 5045    def addUnexploredEdge(
 5046        self,
 5047        fromDecision: base.AnyDecisionSpecifier,
 5048        name: base.Transition,
 5049        destinationName: Optional[base.DecisionName] = None,
 5050        reciprocal: Optional[base.Transition] = None,
 5051        toDomain: Optional[base.Domain] = None,
 5052        placeInZone: Optional[base.Zone] = None,
 5053        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 5054        annotations: Optional[List[base.Annotation]] = None,
 5055        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
 5056        revAnnotations: Optional[List[base.Annotation]] = None,
 5057        requires: Optional[base.Requirement] = None,
 5058        consequence: Optional[base.Consequence] = None,
 5059        revRequires: Optional[base.Requirement] = None,
 5060        revConsequece: Optional[base.Consequence] = None
 5061    ) -> base.DecisionID:
 5062        """
 5063        Adds a transition connecting to a new decision named `'_u.-n-'`
 5064        where '-n-' is the number of unknown decisions (named or not)
 5065        that have ever been created in this graph (or using the
 5066        specified destination name if one is provided). This represents
 5067        a transition to an unknown destination. The destination node
 5068        gets tagged 'unconfirmed'.
 5069
 5070        This also adds a reciprocal transition in the reverse direction,
 5071        unless `reciprocal` is left as the default `None`. The reciprocal
 5072        will use the provided name. The new decision will be in the same
 5073        domain as the decision it's connected to, unless `toDecision` is
 5074        specified, in which case it will be in that domain.
 5075
 5076        The new decision will not be placed into any zones, unless
 5077        `placeInZone` is specified, in which case it will be placed into
 5078        that zone. If that zone needs to be created, it will be created
 5079        at level 0; in that case that zone will be added to any
 5080        grandparent zones of the decision we're branching off of. If
 5081        `placeInZone` is set to `base.DefaultZone`, then the new
 5082        decision will be placed into each parent zone of the decision
 5083        we're branching off of, as long as the new decision is in the
 5084        same domain as the decision we're branching from (otherwise only
 5085        an explicit `placeInZone` would apply).
 5086
 5087        The ID of the decision that was created is returned.
 5088
 5089        A `MissingDecisionError` will be raised if the starting decision
 5090        does not exist, a `TransitionCollisionError` will be raised if
 5091        it exists but already has a transition with the given name, and a
 5092        `DecisionCollisionWarning` will be issued if a decision with the
 5093        specified destination name already exists (won't happen when
 5094        using an automatic name).
 5095
 5096        Lists of tags and/or annotations (strings in both cases) may be
 5097        provided. These may also be provided for the reciprocal edge.
 5098
 5099        Similarly, requirements and/or consequences for either edge may
 5100        be provided.
 5101
 5102        ## Example
 5103
 5104        >>> g = DecisionGraph()
 5105        >>> g.addDecision('A')
 5106        0
 5107        >>> g.addUnexploredEdge('A', 'up')
 5108        1
 5109        >>> g.nameFor(1)
 5110        '_u.0'
 5111        >>> g.decisionTags(1)
 5112        {'unconfirmed': 1}
 5113        >>> g.getReciprocal('A', 'up') is None
 5114        True
 5115        >>> g.addUnexploredEdge('A', 'right', 'B', 'left')
 5116        2
 5117        >>> g.nameFor(2)
 5118        'B'
 5119        >>> g.decisionTags(2)
 5120        {'unconfirmed': 1}
 5121        >>> g.getReciprocal('A', 'right')
 5122        'left'
 5123        >>> g.addUnexploredEdge('A', 'down', None, 'up')
 5124        3
 5125        >>> g.nameFor(3)
 5126        '_u.2'
 5127        >>> g.addUnexploredEdge(
 5128        ...    '_u.0',
 5129        ...    'beyond',
 5130        ...    None,
 5131        ...    'return',
 5132        ...    toDomain='otherDomain',
 5133        ...    tags={'fast':1},
 5134        ...    revTags={'slow':1},
 5135        ...    annotations=['comment'],
 5136        ...    revAnnotations=['one', 'two'],
 5137        ...    requires=base.ReqCapability('dash'),
 5138        ...    revRequires=base.ReqCapability('super dash'),
 5139        ...    consequence=[base.effect(gain='super dash')],
 5140        ...    revConsequece=[base.effect(lose='super dash')]
 5141        ... )
 5142        4
 5143        >>> g.nameFor(4)
 5144        '_u.3'
 5145        >>> g.domainFor(4)
 5146        'otherDomain'
 5147        >>> g.transitionTags('_u.0', 'beyond')
 5148        {'fast': 1}
 5149        >>> g.transitionAnnotations('_u.0', 'beyond')
 5150        ['comment']
 5151        >>> g.getTransitionRequirement('_u.0', 'beyond')
 5152        ReqCapability('dash')
 5153        >>> e = g.getConsequence('_u.0', 'beyond')
 5154        >>> e == [base.effect(gain='super dash')]
 5155        True
 5156        >>> g.transitionTags('_u.3', 'return')
 5157        {'slow': 1}
 5158        >>> g.transitionAnnotations('_u.3', 'return')
 5159        ['one', 'two']
 5160        >>> g.getTransitionRequirement('_u.3', 'return')
 5161        ReqCapability('super dash')
 5162        >>> e = g.getConsequence('_u.3', 'return')
 5163        >>> e == [base.effect(lose='super dash')]
 5164        True
 5165        """
 5166        # Defaults
 5167        if tags is None:
 5168            tags = {}
 5169        if annotations is None:
 5170            annotations = []
 5171        if revTags is None:
 5172            revTags = {}
 5173        if revAnnotations is None:
 5174            revAnnotations = []
 5175
 5176        # Resolve ID
 5177        fromID = self.resolveDecision(fromDecision)
 5178        if toDomain is None:
 5179            toDomain = self.domainFor(fromID)
 5180
 5181        if name in self.destinationsFrom(fromID):
 5182            raise TransitionCollisionError(
 5183                f"Cannot add a new edge {name!r}:"
 5184                f" {self.identityOf(fromDecision)} already has an"
 5185                f" outgoing edge with that name."
 5186            )
 5187
 5188        if destinationName in self.nameLookup and WARN_OF_NAME_COLLISIONS:
 5189            warnings.warn(
 5190                (
 5191                    f"Cannot add a new unexplored node"
 5192                    f" {destinationName!r}: A decision with that name"
 5193                    f" already exists.\n(Leave destinationName as None"
 5194                    f" to use an automatic name.)"
 5195                ),
 5196                DecisionCollisionWarning
 5197            )
 5198
 5199        # Create the new unexplored decision and add the edge
 5200        if destinationName is None:
 5201            toName = '_u.' + str(self.unknownCount)
 5202        else:
 5203            toName = destinationName
 5204        self.unknownCount += 1
 5205        newID = self.addDecision(toName, domain=toDomain)
 5206        self.addTransition(
 5207            fromID,
 5208            name,
 5209            newID,
 5210            tags=tags,
 5211            annotations=annotations
 5212        )
 5213        self.setTransitionRequirement(fromID, name, requires)
 5214        if consequence is not None:
 5215            self.setConsequence(fromID, name, consequence)
 5216
 5217        # Add it to a zone if requested
 5218        if (
 5219            placeInZone == base.DefaultZone
 5220        and toDomain == self.domainFor(fromID)
 5221        ):
 5222            # Add to each parent of the from decision
 5223            for parent in self.zoneParents(fromID):
 5224                self.addDecisionToZone(newID, parent)
 5225        elif placeInZone is not None:
 5226            # Otherwise add it to one specific zone, creating that zone
 5227            # at level 0 if necessary
 5228            assert isinstance(placeInZone, base.Zone)
 5229            if self.getZoneInfo(placeInZone) is None:
 5230                self.createZone(placeInZone, 0)
 5231                # Add new zone to each grandparent of the from decision
 5232                for parent in self.zoneParents(fromID):
 5233                    for grandparent in self.zoneParents(parent):
 5234                        self.addZoneToZone(placeInZone, grandparent)
 5235            self.addDecisionToZone(newID, placeInZone)
 5236
 5237        # Create the reciprocal edge
 5238        if reciprocal is not None:
 5239            self.addTransition(
 5240                newID,
 5241                reciprocal,
 5242                fromID,
 5243                tags=revTags,
 5244                annotations=revAnnotations
 5245            )
 5246            self.setTransitionRequirement(newID, reciprocal, revRequires)
 5247            if revConsequece is not None:
 5248                self.setConsequence(newID, reciprocal, revConsequece)
 5249            # Set as a reciprocal
 5250            self.setReciprocal(fromID, name, reciprocal)
 5251
 5252        # Tag the destination as 'unconfirmed'
 5253        self.tagDecision(newID, 'unconfirmed')
 5254
 5255        # Return ID of new destination
 5256        return newID
 5257
 5258    def retargetTransition(
 5259        self,
 5260        fromDecision: base.AnyDecisionSpecifier,
 5261        transition: base.Transition,
 5262        newDestination: base.AnyDecisionSpecifier,
 5263        swapReciprocal=True,
 5264        errorOnNameColision=True
 5265    ) -> Optional[base.Transition]:
 5266        """
 5267        Given a particular decision and a transition at that decision,
 5268        changes that transition so that it goes to the specified new
 5269        destination instead of wherever it was connected to before. If
 5270        the new destination is the same as the old one, no changes are
 5271        made.
 5272
 5273        If `swapReciprocal` is set to True (the default) then any
 5274        reciprocal edge at the old destination will be deleted, and a
 5275        new reciprocal edge from the new destination with equivalent
 5276        properties to the original reciprocal will be created, pointing
 5277        to the origin of the specified transition. If `swapReciprocal`
 5278        is set to False, then the reciprocal relationship with any old
 5279        reciprocal edge will be removed, but the old reciprocal edge
 5280        will not be changed.
 5281
 5282        Note that if `errorOnNameColision` is True (the default), then
 5283        if the reciprocal transition has the same name as a transition
 5284        which already exists at the new destination node, a
 5285        `TransitionCollisionError` will be thrown. However, if it is set
 5286        to False, the reciprocal transition will be renamed with a suffix
 5287        to avoid any possible name collisions. Either way, the name of
 5288        the reciprocal transition (possibly just changed) will be
 5289        returned, or None if there was no reciprocal transition.
 5290
 5291        ## Example
 5292
 5293        >>> g = DecisionGraph()
 5294        >>> for fr, to, nm in [
 5295        ...     ('A', 'B', 'up'),
 5296        ...     ('A', 'B', 'up2'),
 5297        ...     ('B', 'A', 'down'),
 5298        ...     ('B', 'B', 'self'),
 5299        ...     ('B', 'C', 'next'),
 5300        ...     ('C', 'B', 'prev')
 5301        ... ]:
 5302        ...     if g.getDecision(fr) is None:
 5303        ...        g.addDecision(fr)
 5304        ...     if g.getDecision(to) is None:
 5305        ...         g.addDecision(to)
 5306        ...     g.addTransition(fr, nm, to)
 5307        0
 5308        1
 5309        2
 5310        >>> g.setReciprocal('A', 'up', 'down')
 5311        >>> g.setReciprocal('B', 'next', 'prev')
 5312        >>> g.destination('A', 'up')
 5313        1
 5314        >>> g.destination('B', 'down')
 5315        0
 5316        >>> g.retargetTransition('A', 'up', 'C')
 5317        'down'
 5318        >>> g.destination('A', 'up')
 5319        2
 5320        >>> g.getDestination('B', 'down') is None
 5321        True
 5322        >>> g.destination('C', 'down')
 5323        0
 5324        >>> g.addTransition('A', 'next', 'B')
 5325        >>> g.addTransition('B', 'prev', 'A')
 5326        >>> g.setReciprocal('A', 'next', 'prev')
 5327        >>> # Can't swap a reciprocal in a way that would collide names
 5328        >>> g.getReciprocal('C', 'prev')
 5329        'next'
 5330        >>> g.retargetTransition('C', 'prev', 'A')
 5331        Traceback (most recent call last):
 5332        ...
 5333        exploration.core.TransitionCollisionError...
 5334        >>> g.retargetTransition('C', 'prev', 'A', swapReciprocal=False)
 5335        'next'
 5336        >>> g.destination('C', 'prev')
 5337        0
 5338        >>> g.destination('A', 'next') # not changed
 5339        1
 5340        >>> # Reciprocal relationship is severed:
 5341        >>> g.getReciprocal('C', 'prev') is None
 5342        True
 5343        >>> g.getReciprocal('B', 'next') is None
 5344        True
 5345        >>> # Swap back so we can do another demo
 5346        >>> g.retargetTransition('C', 'prev', 'B', swapReciprocal=False)
 5347        >>> # Note return value was None here because there was no reciprocal
 5348        >>> g.setReciprocal('C', 'prev', 'next')
 5349        >>> # Swap reciprocal by renaming it
 5350        >>> g.retargetTransition('C', 'prev', 'A', errorOnNameColision=False)
 5351        'next.1'
 5352        >>> g.getReciprocal('C', 'prev')
 5353        'next.1'
 5354        >>> g.destination('C', 'prev')
 5355        0
 5356        >>> g.destination('A', 'next.1')
 5357        2
 5358        >>> g.destination('A', 'next')
 5359        1
 5360        >>> # Note names are the same but these are from different nodes
 5361        >>> g.getReciprocal('A', 'next')
 5362        'prev'
 5363        >>> g.getReciprocal('A', 'next.1')
 5364        'prev'
 5365        """
 5366        fromID = self.resolveDecision(fromDecision)
 5367        newDestID = self.resolveDecision(newDestination)
 5368
 5369        # Figure out the old destination of the transition we're swapping
 5370        oldDestID = self.destination(fromID, transition)
 5371        reciprocal = self.getReciprocal(fromID, transition)
 5372
 5373        # If thew new destination is the same, we don't do anything!
 5374        if oldDestID == newDestID:
 5375            return reciprocal
 5376
 5377        # First figure out reciprocal business so we can error out
 5378        # without making changes if we need to
 5379        if swapReciprocal and reciprocal is not None:
 5380            reciprocal = self.rebaseTransition(
 5381                oldDestID,
 5382                reciprocal,
 5383                newDestID,
 5384                swapReciprocal=False,
 5385                errorOnNameColision=errorOnNameColision
 5386            )
 5387
 5388        # Handle the forward transition...
 5389        # Find the transition properties
 5390        tProps = self.getTransitionProperties(fromID, transition)
 5391
 5392        # Delete the edge
 5393        self.removeEdgeByKey(fromID, transition)
 5394
 5395        # Add the new edge
 5396        self.addTransition(fromID, transition, newDestID)
 5397
 5398        # Reapply the transition properties
 5399        self.setTransitionProperties(fromID, transition, **tProps)
 5400
 5401        # Handle the reciprocal transition if there is one...
 5402        if reciprocal is not None:
 5403            if not swapReciprocal:
 5404                # Then sever the relationship, but only if that edge
 5405                # still exists (we might be in the middle of a rebase)
 5406                check = self.getDestination(oldDestID, reciprocal)
 5407                if check is not None:
 5408                    self.setReciprocal(
 5409                        oldDestID,
 5410                        reciprocal,
 5411                        None,
 5412                        setBoth=False # Other transition was deleted already
 5413                    )
 5414            else:
 5415                # Establish new reciprocal relationship
 5416                self.setReciprocal(
 5417                    fromID,
 5418                    transition,
 5419                    reciprocal
 5420                )
 5421
 5422        return reciprocal
 5423
 5424    def rebaseTransition(
 5425        self,
 5426        fromDecision: base.AnyDecisionSpecifier,
 5427        transition: base.Transition,
 5428        newBase: base.AnyDecisionSpecifier,
 5429        swapReciprocal=True,
 5430        errorOnNameColision=True
 5431    ) -> base.Transition:
 5432        """
 5433        Given a particular destination and a transition at that
 5434        destination, changes that transition's origin to a new base
 5435        decision. If the new source is the same as the old one, no
 5436        changes are made.
 5437
 5438        If `swapReciprocal` is set to True (the default) then any
 5439        reciprocal edge at the destination will be retargeted to point
 5440        to the new source so that it can remain a reciprocal. If
 5441        `swapReciprocal` is set to False, then the reciprocal
 5442        relationship with any old reciprocal edge will be removed, but
 5443        the old reciprocal edge will not be otherwise changed.
 5444
 5445        Note that if `errorOnNameColision` is True (the default), then
 5446        if the transition has the same name as a transition which
 5447        already exists at the new source node, a
 5448        `TransitionCollisionError` will be raised. However, if it is set
 5449        to False, the transition will be renamed with a suffix to avoid
 5450        any possible name collisions. Either way, the (possibly new) name
 5451        of the transition that was rebased will be returned.
 5452
 5453        ## Example
 5454
 5455        >>> g = DecisionGraph()
 5456        >>> for fr, to, nm in [
 5457        ...     ('A', 'B', 'up'),
 5458        ...     ('A', 'B', 'up2'),
 5459        ...     ('B', 'A', 'down'),
 5460        ...     ('B', 'B', 'self'),
 5461        ...     ('B', 'C', 'next'),
 5462        ...     ('C', 'B', 'prev')
 5463        ... ]:
 5464        ...     if g.getDecision(fr) is None:
 5465        ...        g.addDecision(fr)
 5466        ...     if g.getDecision(to) is None:
 5467        ...         g.addDecision(to)
 5468        ...     g.addTransition(fr, nm, to)
 5469        0
 5470        1
 5471        2
 5472        >>> g.setReciprocal('A', 'up', 'down')
 5473        >>> g.setReciprocal('B', 'next', 'prev')
 5474        >>> g.destination('A', 'up')
 5475        1
 5476        >>> g.destination('B', 'down')
 5477        0
 5478        >>> g.rebaseTransition('B', 'down', 'C')
 5479        'down'
 5480        >>> g.destination('A', 'up')
 5481        2
 5482        >>> g.getDestination('B', 'down') is None
 5483        True
 5484        >>> g.destination('C', 'down')
 5485        0
 5486        >>> g.addTransition('A', 'next', 'B')
 5487        >>> g.addTransition('B', 'prev', 'A')
 5488        >>> g.setReciprocal('A', 'next', 'prev')
 5489        >>> # Can't rebase in a way that would collide names
 5490        >>> g.rebaseTransition('B', 'next', 'A')
 5491        Traceback (most recent call last):
 5492        ...
 5493        exploration.core.TransitionCollisionError...
 5494        >>> g.rebaseTransition('B', 'next', 'A', errorOnNameColision=False)
 5495        'next.1'
 5496        >>> g.destination('C', 'prev')
 5497        0
 5498        >>> g.destination('A', 'next') # not changed
 5499        1
 5500        >>> # Collision is avoided by renaming
 5501        >>> g.destination('A', 'next.1')
 5502        2
 5503        >>> # Swap without reciprocal
 5504        >>> g.getReciprocal('A', 'next.1')
 5505        'prev'
 5506        >>> g.getReciprocal('C', 'prev')
 5507        'next.1'
 5508        >>> g.rebaseTransition('A', 'next.1', 'B', swapReciprocal=False)
 5509        'next.1'
 5510        >>> g.getReciprocal('C', 'prev') is None
 5511        True
 5512        >>> g.destination('C', 'prev')
 5513        0
 5514        >>> g.getDestination('A', 'next.1') is None
 5515        True
 5516        >>> g.destination('A', 'next')
 5517        1
 5518        >>> g.destination('B', 'next.1')
 5519        2
 5520        >>> g.getReciprocal('B', 'next.1') is None
 5521        True
 5522        >>> # Rebase in a way that creates a self-edge
 5523        >>> g.rebaseTransition('A', 'next', 'B')
 5524        'next'
 5525        >>> g.getDestination('A', 'next') is None
 5526        True
 5527        >>> g.destination('B', 'next')
 5528        1
 5529        >>> g.destination('B', 'prev') # swapped as a reciprocal
 5530        1
 5531        >>> g.getReciprocal('B', 'next') # still reciprocals
 5532        'prev'
 5533        >>> g.getReciprocal('B', 'prev')
 5534        'next'
 5535        >>> # And rebasing of a self-edge also works
 5536        >>> g.rebaseTransition('B', 'prev', 'A')
 5537        'prev'
 5538        >>> g.destination('A', 'prev')
 5539        1
 5540        >>> g.destination('B', 'next')
 5541        0
 5542        >>> g.getReciprocal('B', 'next') # still reciprocals
 5543        'prev'
 5544        >>> g.getReciprocal('A', 'prev')
 5545        'next'
 5546        >>> # We've effectively reversed this edge/reciprocal pair
 5547        >>> # by rebasing twice
 5548        """
 5549        fromID = self.resolveDecision(fromDecision)
 5550        newBaseID = self.resolveDecision(newBase)
 5551
 5552        # If thew new base is the same, we don't do anything!
 5553        if newBaseID == fromID:
 5554            return transition
 5555
 5556        # First figure out reciprocal business so we can swap it later
 5557        # without making changes if we need to
 5558        destination = self.destination(fromID, transition)
 5559        reciprocal = self.getReciprocal(fromID, transition)
 5560        # Check for an already-deleted reciprocal
 5561        if (
 5562            reciprocal is not None
 5563        and self.getDestination(destination, reciprocal) is None
 5564        ):
 5565            reciprocal = None
 5566
 5567        # Handle the base swap...
 5568        # Find the transition properties
 5569        tProps = self.getTransitionProperties(fromID, transition)
 5570
 5571        # Check for a collision
 5572        targetDestinations = self.destinationsFrom(newBaseID)
 5573        if transition in targetDestinations:
 5574            if errorOnNameColision:
 5575                raise TransitionCollisionError(
 5576                    f"Cannot rebase transition {transition!r} from"
 5577                    f" {self.identityOf(fromDecision)}: it would be a"
 5578                    f" duplicate transition name at the new base"
 5579                    f" decision {self.identityOf(newBase)}."
 5580                )
 5581            else:
 5582                # Figure out a good fresh name
 5583                newName = utils.uniqueName(
 5584                    transition,
 5585                    targetDestinations
 5586                )
 5587        else:
 5588            newName = transition
 5589
 5590        # Delete the edge
 5591        self.removeEdgeByKey(fromID, transition)
 5592
 5593        # Add the new edge
 5594        self.addTransition(newBaseID, newName, destination)
 5595
 5596        # Reapply the transition properties
 5597        self.setTransitionProperties(newBaseID, newName, **tProps)
 5598
 5599        # Handle the reciprocal transition if there is one...
 5600        if reciprocal is not None:
 5601            if not swapReciprocal:
 5602                # Then sever the relationship
 5603                self.setReciprocal(
 5604                    destination,
 5605                    reciprocal,
 5606                    None,
 5607                    setBoth=False # Other transition was deleted already
 5608                )
 5609            else:
 5610                # Otherwise swap the reciprocal edge
 5611                self.retargetTransition(
 5612                    destination,
 5613                    reciprocal,
 5614                    newBaseID,
 5615                    swapReciprocal=False
 5616                )
 5617
 5618                # And establish a new reciprocal relationship
 5619                self.setReciprocal(
 5620                    newBaseID,
 5621                    newName,
 5622                    reciprocal
 5623                )
 5624
 5625        # Return the new name in case it was changed
 5626        return newName
 5627
 5628    # TODO: zone merging!
 5629
 5630    # TODO: Double-check that exploration vars get updated when this is
 5631    # called!
 5632    def mergeDecisions(
 5633        self,
 5634        merge: base.AnyDecisionSpecifier,
 5635        mergeInto: base.AnyDecisionSpecifier,
 5636        errorOnNameColision=True
 5637    ) -> Dict[base.Transition, base.Transition]:
 5638        """
 5639        Merges two decisions, deleting the first after transferring all
 5640        of its incoming and outgoing edges to target the second one,
 5641        whose name is retained. The second decision will be added to any
 5642        zones that the first decision was a member of. If either decision
 5643        does not exist, a `MissingDecisionError` will be raised. If
 5644        `merge` and `mergeInto` are the same, then nothing will be
 5645        changed.
 5646
 5647        Unless `errorOnNameColision` is set to False, a
 5648        `TransitionCollisionError` will be raised if the two decisions
 5649        have outgoing transitions with the same name. If
 5650        `errorOnNameColision` is set to False, then such edges will be
 5651        renamed using a suffix to avoid name collisions, with edges
 5652        connected to the second decision retaining their original names
 5653        and edges that were connected to the first decision getting
 5654        renamed.
 5655
 5656        Any mechanisms located at the first decision will be moved to the
 5657        merged decision.
 5658
 5659        The tags and annotations of the merged decision are added to the
 5660        tags and annotations of the merge target. If there are shared
 5661        tags, the values from the merge target will override those of
 5662        the merged decision. If this is undesired behavior, clear/edit
 5663        the tags/annotations of the merged decision before the merge.
 5664
 5665        The 'unconfirmed' tag is treated specially: if both decisions have
 5666        it it will be retained, but otherwise it will be dropped even if
 5667        one of the situations had it before.
 5668
 5669        The domain of the second decision is retained.
 5670
 5671        Returns a dictionary mapping each original transition name to
 5672        its new name in cases where transitions get renamed; this will
 5673        be empty when no re-naming occurs, including when
 5674        `errorOnNameColision` is True. If there were any transitions
 5675        connecting the nodes that were merged, these become self-edges
 5676        of the merged node (and may be renamed if necessary).
 5677        Note that all renamed transitions were originally based on the
 5678        first (merged) node, since transitions of the second (merge
 5679        target) node are not renamed.
 5680
 5681        ## Example
 5682
 5683        >>> g = DecisionGraph()
 5684        >>> for fr, to, nm in [
 5685        ...     ('A', 'B', 'up'),
 5686        ...     ('A', 'B', 'up2'),
 5687        ...     ('B', 'A', 'down'),
 5688        ...     ('B', 'B', 'self'),
 5689        ...     ('B', 'C', 'next'),
 5690        ...     ('C', 'B', 'prev'),
 5691        ...     ('A', 'C', 'right')
 5692        ... ]:
 5693        ...     if g.getDecision(fr) is None:
 5694        ...        g.addDecision(fr)
 5695        ...     if g.getDecision(to) is None:
 5696        ...         g.addDecision(to)
 5697        ...     g.addTransition(fr, nm, to)
 5698        0
 5699        1
 5700        2
 5701        >>> g.getDestination('A', 'up')
 5702        1
 5703        >>> g.getDestination('B', 'down')
 5704        0
 5705        >>> sorted(g)
 5706        [0, 1, 2]
 5707        >>> g.setReciprocal('A', 'up', 'down')
 5708        >>> g.setReciprocal('B', 'next', 'prev')
 5709        >>> g.mergeDecisions('C', 'B')
 5710        {}
 5711        >>> g.destinationsFrom('A')
 5712        {'up': 1, 'up2': 1, 'right': 1}
 5713        >>> g.destinationsFrom('B')
 5714        {'down': 0, 'self': 1, 'prev': 1, 'next': 1}
 5715        >>> 'C' in g
 5716        False
 5717        >>> g.mergeDecisions('A', 'A') # does nothing
 5718        {}
 5719        >>> # Can't merge non-existent decision
 5720        >>> g.mergeDecisions('A', 'Z')
 5721        Traceback (most recent call last):
 5722        ...
 5723        exploration.core.MissingDecisionError...
 5724        >>> g.mergeDecisions('Z', 'A')
 5725        Traceback (most recent call last):
 5726        ...
 5727        exploration.core.MissingDecisionError...
 5728        >>> # Can't merge decisions w/ shared edge names
 5729        >>> g.addDecision('D')
 5730        3
 5731        >>> g.addTransition('D', 'next', 'A')
 5732        >>> g.addTransition('A', 'prev', 'D')
 5733        >>> g.setReciprocal('D', 'next', 'prev')
 5734        >>> g.mergeDecisions('D', 'B') # both have a 'next' transition
 5735        Traceback (most recent call last):
 5736        ...
 5737        exploration.core.TransitionCollisionError...
 5738        >>> # Auto-rename colliding edges
 5739        >>> g.mergeDecisions('D', 'B', errorOnNameColision=False)
 5740        {'next': 'next.1'}
 5741        >>> g.destination('B', 'next') # merge target unchanged
 5742        1
 5743        >>> g.destination('B', 'next.1') # merged decision name changed
 5744        0
 5745        >>> g.destination('B', 'prev') # name unchanged (no collision)
 5746        1
 5747        >>> g.getReciprocal('B', 'next') # unchanged (from B)
 5748        'prev'
 5749        >>> g.getReciprocal('B', 'next.1') # from A
 5750        'prev'
 5751        >>> g.getReciprocal('A', 'prev') # from B
 5752        'next.1'
 5753
 5754        ## Folding four nodes into a 2-node loop
 5755
 5756        >>> g = DecisionGraph()
 5757        >>> g.addDecision('X')
 5758        0
 5759        >>> g.addDecision('Y')
 5760        1
 5761        >>> g.addTransition('X', 'next', 'Y', 'prev')
 5762        >>> g.addDecision('preX')
 5763        2
 5764        >>> g.addDecision('postY')
 5765        3
 5766        >>> g.addTransition('preX', 'next', 'X', 'prev')
 5767        >>> g.addTransition('Y', 'next', 'postY', 'prev')
 5768        >>> g.mergeDecisions('preX', 'Y', errorOnNameColision=False)
 5769        {'next': 'next.1'}
 5770        >>> g.destinationsFrom('X')
 5771        {'next': 1, 'prev': 1}
 5772        >>> g.destinationsFrom('Y')
 5773        {'prev': 0, 'next': 3, 'next.1': 0}
 5774        >>> 2 in g
 5775        False
 5776        >>> g.destinationsFrom('postY')
 5777        {'prev': 1}
 5778        >>> g.mergeDecisions('postY', 'X', errorOnNameColision=False)
 5779        {'prev': 'prev.1'}
 5780        >>> g.destinationsFrom('X')
 5781        {'next': 1, 'prev': 1, 'prev.1': 1}
 5782        >>> g.destinationsFrom('Y') # order 'cause of 'next' re-target
 5783        {'prev': 0, 'next.1': 0, 'next': 0}
 5784        >>> 2 in g
 5785        False
 5786        >>> 3 in g
 5787        False
 5788        >>> # Reciprocals are tangled...
 5789        >>> g.getReciprocal(0, 'prev')
 5790        'next.1'
 5791        >>> g.getReciprocal(0, 'prev.1')
 5792        'next'
 5793        >>> g.getReciprocal(1, 'next')
 5794        'prev.1'
 5795        >>> g.getReciprocal(1, 'next.1')
 5796        'prev'
 5797        >>> # Note: one merge cannot handle both extra transitions
 5798        >>> # because their reciprocals are crossed (e.g., prev.1 <-> next)
 5799        >>> # (It would merge both edges but the result would retain
 5800        >>> # 'next.1' instead of retaining 'next'.)
 5801        >>> g.mergeTransitions('X', 'prev.1', 'prev', mergeReciprocal=False)
 5802        >>> g.mergeTransitions('Y', 'next.1', 'next', mergeReciprocal=True)
 5803        >>> g.destinationsFrom('X')
 5804        {'next': 1, 'prev': 1}
 5805        >>> g.destinationsFrom('Y')
 5806        {'prev': 0, 'next': 0}
 5807        >>> # Reciprocals were salvaged in second merger
 5808        >>> g.getReciprocal('X', 'prev')
 5809        'next'
 5810        >>> g.getReciprocal('Y', 'next')
 5811        'prev'
 5812
 5813        ## Merging with tags/requirements/annotations/consequences
 5814
 5815        >>> g = DecisionGraph()
 5816        >>> g.addDecision('X')
 5817        0
 5818        >>> g.addDecision('Y')
 5819        1
 5820        >>> g.addDecision('Z')
 5821        2
 5822        >>> g.addTransition('X', 'next', 'Y', 'prev')
 5823        >>> g.addTransition('X', 'down', 'Z', 'up')
 5824        >>> g.tagDecision('X', 'tag0', 1)
 5825        >>> g.tagDecision('Y', 'tag1', 10)
 5826        >>> g.tagDecision('Y', 'unconfirmed')
 5827        >>> g.tagDecision('Z', 'tag1', 20)
 5828        >>> g.tagDecision('Z', 'tag2', 30)
 5829        >>> g.tagTransition('X', 'next', 'ttag1', 11)
 5830        >>> g.tagTransition('Y', 'prev', 'ttag2', 22)
 5831        >>> g.tagTransition('X', 'down', 'ttag3', 33)
 5832        >>> g.tagTransition('Z', 'up', 'ttag4', 44)
 5833        >>> g.annotateDecision('Y', 'annotation 1')
 5834        >>> g.annotateDecision('Z', 'annotation 2')
 5835        >>> g.annotateDecision('Z', 'annotation 3')
 5836        >>> g.annotateTransition('Y', 'prev', 'trans annotation 1')
 5837        >>> g.annotateTransition('Y', 'prev', 'trans annotation 2')
 5838        >>> g.annotateTransition('Z', 'up', 'trans annotation 3')
 5839        >>> g.setTransitionRequirement(
 5840        ...     'X',
 5841        ...     'next',
 5842        ...     base.ReqCapability('power')
 5843        ... )
 5844        >>> g.setTransitionRequirement(
 5845        ...     'Y',
 5846        ...     'prev',
 5847        ...     base.ReqTokens('token', 1)
 5848        ... )
 5849        >>> g.setTransitionRequirement(
 5850        ...     'X',
 5851        ...     'down',
 5852        ...     base.ReqCapability('power2')
 5853        ... )
 5854        >>> g.setTransitionRequirement(
 5855        ...     'Z',
 5856        ...     'up',
 5857        ...     base.ReqTokens('token2', 2)
 5858        ... )
 5859        >>> g.setConsequence(
 5860        ...     'Y',
 5861        ...     'prev',
 5862        ...     [base.effect(gain="power2")]
 5863        ... )
 5864        >>> g.mergeDecisions('Y', 'Z')
 5865        {}
 5866        >>> g.destination('X', 'next')
 5867        2
 5868        >>> g.destination('X', 'down')
 5869        2
 5870        >>> g.destination('Z', 'prev')
 5871        0
 5872        >>> g.destination('Z', 'up')
 5873        0
 5874        >>> g.decisionTags('X')
 5875        {'tag0': 1}
 5876        >>> g.decisionTags('Z')  # note that 'unconfirmed' is removed
 5877        {'tag1': 20, 'tag2': 30}
 5878        >>> g.transitionTags('X', 'next')
 5879        {'ttag1': 11}
 5880        >>> g.transitionTags('X', 'down')
 5881        {'ttag3': 33}
 5882        >>> g.transitionTags('Z', 'prev')
 5883        {'ttag2': 22}
 5884        >>> g.transitionTags('Z', 'up')
 5885        {'ttag4': 44}
 5886        >>> g.decisionAnnotations('Z')
 5887        ['annotation 2', 'annotation 3', 'annotation 1']
 5888        >>> g.transitionAnnotations('Z', 'prev')
 5889        ['trans annotation 1', 'trans annotation 2']
 5890        >>> g.transitionAnnotations('Z', 'up')
 5891        ['trans annotation 3']
 5892        >>> g.getTransitionRequirement('X', 'next')
 5893        ReqCapability('power')
 5894        >>> g.getTransitionRequirement('Z', 'prev')
 5895        ReqTokens('token', 1)
 5896        >>> g.getTransitionRequirement('X', 'down')
 5897        ReqCapability('power2')
 5898        >>> g.getTransitionRequirement('Z', 'up')
 5899        ReqTokens('token2', 2)
 5900        >>> g.getConsequence('Z', 'prev') == [
 5901        ...     {
 5902        ...         'type': 'gain',
 5903        ...         'applyTo': 'active',
 5904        ...         'value': 'power2',
 5905        ...         'charges': None,
 5906        ...         'delay': None,
 5907        ...         'hidden': False
 5908        ...     }
 5909        ... ]
 5910        True
 5911
 5912        ## Merging into node without tags
 5913
 5914        >>> g = DecisionGraph()
 5915        >>> g.addDecision('X')
 5916        0
 5917        >>> g.addDecision('Y')
 5918        1
 5919        >>> g.tagDecision('Y', 'unconfirmed')  # special handling
 5920        >>> g.tagDecision('Y', 'tag', 'value')
 5921        >>> g.mergeDecisions('Y', 'X')
 5922        {}
 5923        >>> g.decisionTags('X')
 5924        {'tag': 'value'}
 5925        >>> 0 in g  # Second argument remains
 5926        True
 5927        >>> 1 in g  # First argument is deleted
 5928        False
 5929        """
 5930        # Resolve IDs
 5931        mergeID = self.resolveDecision(merge)
 5932        mergeIntoID = self.resolveDecision(mergeInto)
 5933
 5934        # Create our result as an empty dictionary
 5935        result: Dict[base.Transition, base.Transition] = {}
 5936
 5937        # Short-circuit if the two decisions are the same
 5938        if mergeID == mergeIntoID:
 5939            return result
 5940
 5941        # MissingDecisionErrors from here if either doesn't exist
 5942        allNewOutgoing = set(self.destinationsFrom(mergeID))
 5943        allOldOutgoing = set(self.destinationsFrom(mergeIntoID))
 5944        # Find colliding transition names
 5945        collisions = allNewOutgoing & allOldOutgoing
 5946        if len(collisions) > 0 and errorOnNameColision:
 5947            raise TransitionCollisionError(
 5948                f"Cannot merge decision {self.identityOf(merge)} into"
 5949                f" decision {self.identityOf(mergeInto)}: the decisions"
 5950                f" share {len(collisions)} transition names:"
 5951                f" {collisions}\n(Note that errorOnNameColision was set"
 5952                f" to True, set it to False to allow the operation by"
 5953                f" renaming half of those transitions.)"
 5954            )
 5955
 5956        # Record zones that will have to change after the merge
 5957        zoneParents = self.zoneParents(mergeID)
 5958
 5959        # First, swap all incoming edges, along with their reciprocals
 5960        # This will include self-edges, which will be retargeted and
 5961        # whose reciprocals will be rebased in the process, leading to
 5962        # the possibility of a missing edge during the loop
 5963        for source, incoming in self.allEdgesTo(mergeID):
 5964            # Skip this edge if it was already swapped away because it's
 5965            # a self-loop with a reciprocal whose reciprocal was
 5966            # processed earlier in the loop
 5967            if incoming not in self.destinationsFrom(source):
 5968                continue
 5969
 5970            # Find corresponding outgoing edge
 5971            outgoing = self.getReciprocal(source, incoming)
 5972
 5973            # Swap both edges to new destination
 5974            newOutgoing = self.retargetTransition(
 5975                source,
 5976                incoming,
 5977                mergeIntoID,
 5978                swapReciprocal=True,
 5979                errorOnNameColision=False # collisions were detected above
 5980            )
 5981            # Add to our result if the name of the reciprocal was
 5982            # changed
 5983            if (
 5984                outgoing is not None
 5985            and newOutgoing is not None
 5986            and outgoing != newOutgoing
 5987            ):
 5988                result[outgoing] = newOutgoing
 5989
 5990        # Next, swap any remaining outgoing edges (which didn't have
 5991        # reciprocals, or they'd already be swapped, unless they were
 5992        # self-edges previously). Note that in this loop, there can't be
 5993        # any self-edges remaining, although there might be connections
 5994        # between the merging nodes that need to become self-edges
 5995        # because they used to be a self-edge that was half-retargeted
 5996        # by the previous loop.
 5997        # Note: a copy is used here to avoid iterating over a changing
 5998        # dictionary
 5999        for stillOutgoing in copy.copy(self.destinationsFrom(mergeID)):
 6000            newOutgoing = self.rebaseTransition(
 6001                mergeID,
 6002                stillOutgoing,
 6003                mergeIntoID,
 6004                swapReciprocal=True,
 6005                errorOnNameColision=False # collisions were detected above
 6006            )
 6007            if stillOutgoing != newOutgoing:
 6008                result[stillOutgoing] = newOutgoing
 6009
 6010        # At this point, there shouldn't be any remaining incoming or
 6011        # outgoing edges!
 6012        assert self.degree(mergeID) == 0
 6013
 6014        # Merge tags & annotations
 6015        # Note that these operations affect the underlying graph
 6016        destTags = self.decisionTags(mergeIntoID)
 6017        destUnvisited = 'unconfirmed' in destTags
 6018        sourceTags = self.decisionTags(mergeID)
 6019        sourceUnvisited = 'unconfirmed' in sourceTags
 6020        # Copy over only new tags, leaving existing tags alone
 6021        for key in sourceTags:
 6022            if key not in destTags:
 6023                destTags[key] = sourceTags[key]
 6024
 6025        if int(destUnvisited) + int(sourceUnvisited) == 1:
 6026            del destTags['unconfirmed']
 6027
 6028        self.decisionAnnotations(mergeIntoID).extend(
 6029            self.decisionAnnotations(mergeID)
 6030        )
 6031
 6032        # Transfer zones
 6033        for zone in zoneParents:
 6034            self.addDecisionToZone(mergeIntoID, zone)
 6035
 6036        # Delete the old node
 6037        self.removeDecision(mergeID)
 6038
 6039        return result
 6040
 6041    def removeDecision(self, decision: base.AnyDecisionSpecifier) -> None:
 6042        """
 6043        Deletes the specified decision from the graph, updating
 6044        attendant structures like zones. Note that the ID of the deleted
 6045        node will NOT be reused, unless it's specifically provided to
 6046        `addIdentifiedDecision`.
 6047
 6048        For example:
 6049
 6050        >>> dg = DecisionGraph()
 6051        >>> dg.addDecision('A')
 6052        0
 6053        >>> dg.addDecision('B')
 6054        1
 6055        >>> list(dg)
 6056        [0, 1]
 6057        >>> 1 in dg
 6058        True
 6059        >>> 'B' in dg.nameLookup
 6060        True
 6061        >>> dg.removeDecision('B')
 6062        >>> 1 in dg
 6063        False
 6064        >>> list(dg)
 6065        [0]
 6066        >>> 'B' in dg.nameLookup
 6067        False
 6068        >>> dg.addDecision('C')  # doesn't re-use ID
 6069        2
 6070        """
 6071        dID = self.resolveDecision(decision)
 6072
 6073        # Remove the target from all zones:
 6074        for zone in self.zones:
 6075            self.removeDecisionFromZone(dID, zone)
 6076
 6077        # Remove the node but record the current name
 6078        name = self.nodes[dID]['name']
 6079        self.remove_node(dID)
 6080
 6081        # Clean up the nameLookup entry
 6082        luInfo = self.nameLookup[name]
 6083        luInfo.remove(dID)
 6084        if len(luInfo) == 0:
 6085            self.nameLookup.pop(name)
 6086
 6087        # TODO: Clean up edges?
 6088
 6089    def renameDecision(
 6090        self,
 6091        decision: base.AnyDecisionSpecifier,
 6092        newName: base.DecisionName
 6093    ):
 6094        """
 6095        Renames a decision. The decision retains its old ID.
 6096
 6097        Generates a `DecisionCollisionWarning` if a decision using the new
 6098        name already exists and `WARN_OF_NAME_COLLISIONS` is enabled.
 6099
 6100        Example:
 6101
 6102        >>> g = DecisionGraph()
 6103        >>> g.addDecision('one')
 6104        0
 6105        >>> g.addDecision('three')
 6106        1
 6107        >>> g.addTransition('one', '>', 'three')
 6108        >>> g.addTransition('three', '<', 'one')
 6109        >>> g.tagDecision('three', 'hi')
 6110        >>> g.annotateDecision('three', 'note')
 6111        >>> g.destination('one', '>')
 6112        1
 6113        >>> g.destination('three', '<')
 6114        0
 6115        >>> g.renameDecision('three', 'two')
 6116        >>> g.resolveDecision('one')
 6117        0
 6118        >>> g.resolveDecision('two')
 6119        1
 6120        >>> g.resolveDecision('three')
 6121        Traceback (most recent call last):
 6122        ...
 6123        exploration.core.MissingDecisionError...
 6124        >>> g.destination('one', '>')
 6125        1
 6126        >>> g.nameFor(1)
 6127        'two'
 6128        >>> g.getDecision('three') is None
 6129        True
 6130        >>> g.destination('two', '<')
 6131        0
 6132        >>> g.decisionTags('two')
 6133        {'hi': 1}
 6134        >>> g.decisionAnnotations('two')
 6135        ['note']
 6136        """
 6137        dID = self.resolveDecision(decision)
 6138
 6139        if newName in self.nameLookup and WARN_OF_NAME_COLLISIONS:
 6140            warnings.warn(
 6141                (
 6142                    f"Can't rename {self.identityOf(decision)} as"
 6143                    f" {newName!r} because a decision with that name"
 6144                    f" already exists."
 6145                ),
 6146                DecisionCollisionWarning
 6147            )
 6148
 6149        # Update name in node
 6150        oldName = self.nodes[dID]['name']
 6151        self.nodes[dID]['name'] = newName
 6152
 6153        # Update nameLookup entries
 6154        oldNL = self.nameLookup[oldName]
 6155        oldNL.remove(dID)
 6156        if len(oldNL) == 0:
 6157            self.nameLookup.pop(oldName)
 6158        self.nameLookup.setdefault(newName, []).append(dID)
 6159
 6160    def renameTransition(
 6161        self,
 6162        fromDecision: base.AnyDecisionSpecifier,
 6163        oldName: base.Transition,
 6164        newName: base.Transition
 6165    ):
 6166        """
 6167        Renames a transition. The transition retains its reciprocal
 6168        association if it had one. The new name must not already exist as
 6169        a transition name at the specified decision (see
 6170        `mergeTransitions` for an alternative), or a
 6171        `TransitionCollisionError` will be raised. Renaming to the same
 6172        name does nothing.
 6173
 6174        Example:
 6175
 6176        >>> g = DecisionGraph()
 6177        >>> g.addDecision('A')
 6178        0
 6179        >>> g.addDecision('B')
 6180        1
 6181        >>> g.addTransition('A', 'right', 'B', 'left')
 6182        >>> g.getDestination('A', 'right')
 6183        1
 6184        >>> g.renameTransition('A', 'right', 'up')
 6185        >>> g.getDestination('A', 'right') is None
 6186        True
 6187        >>> g.getDestination('A', 'up')
 6188        1
 6189        >>> g.getReciprocal('A', 'up')
 6190        'left'
 6191        >>> g.renameTransition('B', 'left', 'left')
 6192        >>> g.getDestination('B', 'left')
 6193        0
 6194        >>> g.addTransition('B', 'down', 'A')
 6195        >>> g.renameTransition('B', 'left', 'down')
 6196        Traceback (most recent call last):
 6197        ...
 6198        exploration.core.TransitionCollisionError...
 6199        >>> g.renameTransition('A', 'madeup', 'any')
 6200        Traceback (most recent call last):
 6201        ...
 6202        exploration.core.MissingTransitionError...
 6203        """
 6204        if oldName == newName:
 6205            return
 6206
 6207        dID = self.resolveDecision(fromDecision)
 6208        dest = self.destination(dID, oldName)
 6209          # this will raise MissingTransitionError if necessary
 6210        if self.getDestination(dID, newName) is not None:
 6211            raise TransitionCollisionError(
 6212                f"Decision {self.shortIdentity(dID)} already has an"
 6213                f" outgoing transition named {newName!r} so you cannot"
 6214                f" rename transition {oldName!r} to that name."
 6215            )
 6216
 6217        # Add a new transition without a reciprocal or any properties
 6218        self.addTransition(dID, newName, dest)
 6219
 6220        # Merge old one into new one, setting new's reciprocal to old's
 6221        self.mergeTransitions(dID, oldName, newName, mergeReciprocal=True)
 6222
 6223    def mergeTransitions(
 6224        self,
 6225        fromDecision: base.AnyDecisionSpecifier,
 6226        merge: base.Transition,
 6227        mergeInto: base.Transition,
 6228        mergeReciprocal=True
 6229    ) -> None:
 6230        """
 6231        Given a decision and two transitions that start at that decision,
 6232        merges the first transition into the second transition, combining
 6233        their transition properties (using `mergeProperties`) and
 6234        deleting the first transition. By default any reciprocal of the
 6235        first transition is also merged into the reciprocal of the
 6236        second, although you can set `mergeReciprocal` to `False` to
 6237        disable this in which case the old reciprocal will lose its
 6238        reciprocal relationship, even if the transition that was merged
 6239        into does not have a reciprocal.
 6240
 6241        If the two names provided are the same, nothing will happen.
 6242
 6243        If the two transitions do not share the same destination, they
 6244        cannot be merged, and an `InvalidDestinationError` will result.
 6245        Use `retargetTransition` beforehand to ensure that they do if you
 6246        want to merge transitions with different destinations.
 6247
 6248        A `MissingDecisionError` or `MissingTransitionError` will result
 6249        if the decision or either transition does not exist.
 6250
 6251        If merging reciprocal properties was requested and the first
 6252        transition does not have a reciprocal, then no reciprocal
 6253        properties change. However, if the second transition does not
 6254        have a reciprocal and the first does, the first transition's
 6255        reciprocal will be set as the reciprocal of the second
 6256        transition, and that transition will not be deleted as usual.
 6257
 6258        ## Example
 6259
 6260        >>> g = DecisionGraph()
 6261        >>> g.addDecision('A')
 6262        0
 6263        >>> g.addDecision('B')
 6264        1
 6265        >>> g.addTransition('A', 'up', 'B')
 6266        >>> g.addTransition('B', 'down', 'A')
 6267        >>> g.setReciprocal('A', 'up', 'down')
 6268        >>> # Merging a transition with no reciprocal
 6269        >>> g.addTransition('A', 'up2', 'B')
 6270        >>> g.mergeTransitions('A', 'up2', 'up')
 6271        >>> g.getDestination('A', 'up2') is None
 6272        True
 6273        >>> g.getDestination('A', 'up')
 6274        1
 6275        >>> # Merging a transition with a reciprocal & tags
 6276        >>> g.addTransition('A', 'up2', 'B')
 6277        >>> g.addTransition('B', 'down2', 'A')
 6278        >>> g.setReciprocal('A', 'up2', 'down2')
 6279        >>> g.tagTransition('A', 'up2', 'one')
 6280        >>> g.tagTransition('B', 'down2', 'two')
 6281        >>> g.mergeTransitions('B', 'down2', 'down')
 6282        >>> g.getDestination('A', 'up2') is None
 6283        True
 6284        >>> g.getDestination('A', 'up')
 6285        1
 6286        >>> g.getDestination('B', 'down2') is None
 6287        True
 6288        >>> g.getDestination('B', 'down')
 6289        0
 6290        >>> # Merging requirements uses ReqAll (i.e., 'and' logic)
 6291        >>> g.addTransition('A', 'up2', 'B')
 6292        >>> g.setTransitionProperties(
 6293        ...     'A',
 6294        ...     'up2',
 6295        ...     requirement=base.ReqCapability('dash')
 6296        ... )
 6297        >>> g.setTransitionProperties('A', 'up',
 6298        ...     requirement=base.ReqCapability('slide'))
 6299        >>> g.mergeTransitions('A', 'up2', 'up')
 6300        >>> g.getDestination('A', 'up2') is None
 6301        True
 6302        >>> repr(g.getTransitionRequirement('A', 'up'))
 6303        "ReqAll([ReqCapability('dash'), ReqCapability('slide')])"
 6304        >>> # Errors if destinations differ, or if something is missing
 6305        >>> g.mergeTransitions('A', 'down', 'up')
 6306        Traceback (most recent call last):
 6307        ...
 6308        exploration.core.MissingTransitionError...
 6309        >>> g.mergeTransitions('Z', 'one', 'two')
 6310        Traceback (most recent call last):
 6311        ...
 6312        exploration.core.MissingDecisionError...
 6313        >>> g.addDecision('C')
 6314        2
 6315        >>> g.addTransition('A', 'down', 'C')
 6316        >>> g.mergeTransitions('A', 'down', 'up')
 6317        Traceback (most recent call last):
 6318        ...
 6319        exploration.core.InvalidDestinationError...
 6320        >>> # Merging a reciprocal onto an edge that doesn't have one
 6321        >>> g.addTransition('A', 'down2', 'C')
 6322        >>> g.addTransition('C', 'up2', 'A')
 6323        >>> g.setReciprocal('A', 'down2', 'up2')
 6324        >>> g.tagTransition('C', 'up2', 'narrow')
 6325        >>> g.getReciprocal('A', 'down') is None
 6326        True
 6327        >>> g.mergeTransitions('A', 'down2', 'down')
 6328        >>> g.getDestination('A', 'down2') is None
 6329        True
 6330        >>> g.getDestination('A', 'down')
 6331        2
 6332        >>> g.getDestination('C', 'up2')
 6333        0
 6334        >>> g.getReciprocal('A', 'down')
 6335        'up2'
 6336        >>> g.getReciprocal('C', 'up2')
 6337        'down'
 6338        >>> g.transitionTags('C', 'up2')
 6339        {'narrow': 1}
 6340        >>> # Merging without a reciprocal
 6341        >>> g.addTransition('C', 'up', 'A')
 6342        >>> g.mergeTransitions('C', 'up2', 'up', mergeReciprocal=False)
 6343        >>> g.getDestination('C', 'up2') is None
 6344        True
 6345        >>> g.getDestination('C', 'up')
 6346        0
 6347        >>> g.transitionTags('C', 'up') # tag gets merged
 6348        {'narrow': 1}
 6349        >>> g.getDestination('A', 'down')
 6350        2
 6351        >>> g.getReciprocal('A', 'down') is None
 6352        True
 6353        >>> g.getReciprocal('C', 'up') is None
 6354        True
 6355        >>> # Merging w/ normal reciprocals
 6356        >>> g.addDecision('D')
 6357        3
 6358        >>> g.addDecision('E')
 6359        4
 6360        >>> g.addTransition('D', 'up', 'E', 'return')
 6361        >>> g.addTransition('E', 'down', 'D')
 6362        >>> g.mergeTransitions('E', 'return', 'down')
 6363        >>> g.getDestination('D', 'up')
 6364        4
 6365        >>> g.getDestination('E', 'down')
 6366        3
 6367        >>> g.getDestination('E', 'return') is None
 6368        True
 6369        >>> g.getReciprocal('D', 'up')
 6370        'down'
 6371        >>> g.getReciprocal('E', 'down')
 6372        'up'
 6373        >>> # Merging w/ weird reciprocals
 6374        >>> g.addTransition('E', 'return', 'D')
 6375        >>> g.setReciprocal('E', 'return', 'up', setBoth=False)
 6376        >>> g.getReciprocal('D', 'up')
 6377        'down'
 6378        >>> g.getReciprocal('E', 'down')
 6379        'up'
 6380        >>> g.getReciprocal('E', 'return') # shared
 6381        'up'
 6382        >>> g.mergeTransitions('E', 'return', 'down')
 6383        >>> g.getDestination('D', 'up')
 6384        4
 6385        >>> g.getDestination('E', 'down')
 6386        3
 6387        >>> g.getDestination('E', 'return') is None
 6388        True
 6389        >>> g.getReciprocal('D', 'up')
 6390        'down'
 6391        >>> g.getReciprocal('E', 'down')
 6392        'up'
 6393        """
 6394        fromID = self.resolveDecision(fromDecision)
 6395
 6396        # Short-circuit in the no-op case
 6397        if merge == mergeInto:
 6398            return
 6399
 6400        # These lines will raise a MissingDecisionError or
 6401        # MissingTransitionError if needed
 6402        dest1 = self.destination(fromID, merge)
 6403        dest2 = self.destination(fromID, mergeInto)
 6404
 6405        if dest1 != dest2:
 6406            raise InvalidDestinationError(
 6407                f"Cannot merge transition {merge!r} into transition"
 6408                f" {mergeInto!r} from decision"
 6409                f" {self.identityOf(fromDecision)} because their"
 6410                f" destinations are different ({self.identityOf(dest1)}"
 6411                f" and {self.identityOf(dest2)}).\nNote: you can use"
 6412                f" `retargetTransition` to change the destination of a"
 6413                f" transition."
 6414            )
 6415
 6416        # Find and the transition properties
 6417        props1 = self.getTransitionProperties(fromID, merge)
 6418        props2 = self.getTransitionProperties(fromID, mergeInto)
 6419        merged = mergeProperties(props1, props2)
 6420        # Note that this doesn't change the reciprocal:
 6421        self.setTransitionProperties(fromID, mergeInto, **merged)
 6422
 6423        # Merge the reciprocal properties if requested
 6424        # Get reciprocal to merge into
 6425        reciprocal = self.getReciprocal(fromID, mergeInto)
 6426        # Get reciprocal that needs cleaning up
 6427        altReciprocal = self.getReciprocal(fromID, merge)
 6428        # If the reciprocal to be merged actually already was the
 6429        # reciprocal to merge into, there's nothing to do here
 6430        if altReciprocal != reciprocal:
 6431            if not mergeReciprocal:
 6432                # In this case, we sever the reciprocal relationship if
 6433                # there is a reciprocal
 6434                if altReciprocal is not None:
 6435                    self.setReciprocal(dest1, altReciprocal, None)
 6436                    # By default setBoth takes care of the other half
 6437            else:
 6438                # In this case, we try to merge reciprocals
 6439                # If altReciprocal is None, we don't need to do anything
 6440                if altReciprocal is not None:
 6441                    # Was there already a reciprocal or not?
 6442                    if reciprocal is None:
 6443                        # altReciprocal becomes the new reciprocal and is
 6444                        # not deleted
 6445                        self.setReciprocal(
 6446                            fromID,
 6447                            mergeInto,
 6448                            altReciprocal
 6449                        )
 6450                    else:
 6451                        # merge reciprocal properties
 6452                        props1 = self.getTransitionProperties(
 6453                            dest1,
 6454                            altReciprocal
 6455                        )
 6456                        props2 = self.getTransitionProperties(
 6457                            dest2,
 6458                            reciprocal
 6459                        )
 6460                        merged = mergeProperties(props1, props2)
 6461                        self.setTransitionProperties(
 6462                            dest1,
 6463                            reciprocal,
 6464                            **merged
 6465                        )
 6466
 6467                        # delete the old reciprocal transition
 6468                        self.remove_edge(dest1, fromID, altReciprocal)
 6469
 6470        # Delete the old transition (reciprocal deletion/severance is
 6471        # handled above if necessary)
 6472        self.remove_edge(fromID, dest1, merge)
 6473
 6474    def renameZone(self, oldName: base.Zone, newName: base.Zone):
 6475        """
 6476        Renames the specified zone. Raises a `ZoneCollisionError` if the
 6477        new name is already taken.
 6478
 6479        Example:
 6480
 6481        >>> g = DecisionGraph()
 6482        >>> g.addDecision("A")
 6483        0
 6484        >>> g.addDecision("B")
 6485        1
 6486        >>> g.createZone('Z', 0)
 6487        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 6488 annotations=[])
 6489        >>> g.createZone('ZZ', 1)
 6490        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 6491 annotations=[])
 6492        >>> g.addZoneToZone("Z", "ZZ")
 6493        >>> g.addDecisionToZone("A", "Z")
 6494        >>> g.renameZone("Z", "Q")
 6495        >>> sorted(g.zoneAncestors(0))
 6496        ['Q', 'ZZ']
 6497        >>> g.decisionsInZone('Z')
 6498        Traceback (most recent call last):
 6499        ...
 6500        exploration.core.MissingZoneError...
 6501        >>> g.decisionsInZone('Q')
 6502        {0}
 6503        """
 6504        if newName in self.zones:
 6505            raise ZoneCollisionError(
 6506               f"Cannot rename zone {oldName!r} to {newName!r} because"
 6507               f" a zone with that new name already exists."
 6508            )
 6509        # Transfer zone info & delete old entry
 6510        self.zones[newName] = self.zones[oldName]
 6511        del self.zones[oldName]
 6512
 6513        # Fix up child/contents info in ALL zones
 6514        for zoneInfo in self.zones.values():
 6515            if oldName in zoneInfo.parents:
 6516                zoneInfo.parents.remove(oldName)
 6517                zoneInfo.parents.add(newName)
 6518            if oldName in zoneInfo.contents:
 6519                zoneInfo.contents.remove(oldName)
 6520                zoneInfo.contents.add(newName)
 6521
 6522        # Fix up decision parent info
 6523        for n in self.nodes():
 6524            zones = self.nodes[n].get('zones')
 6525            if zones is not None:
 6526                if oldName in zones:
 6527                    zones.remove(oldName)
 6528                    zones.add(newName)
 6529
 6530    def isConfirmed(self, decision: base.AnyDecisionSpecifier) -> bool:
 6531        """
 6532        Returns `True` or `False` depending on whether or not the
 6533        specified decision has been confirmed. Uses the presence or
 6534        absence of the 'unconfirmed' tag to determine this.
 6535
 6536        Note: 'unconfirmed' is used instead of 'confirmed' so that large
 6537        graphs with many confirmed nodes will be smaller when saved.
 6538        """
 6539        dID = self.resolveDecision(decision)
 6540
 6541        return 'unconfirmed' not in self.nodes[dID]['tags']
 6542
 6543    def replaceUnconfirmed(
 6544        self,
 6545        fromDecision: base.AnyDecisionSpecifier,
 6546        transition: base.Transition,
 6547        connectTo: Optional[base.AnyDecisionSpecifier] = None,
 6548        reciprocal: Optional[base.Transition] = None,
 6549        requirement: Optional[base.Requirement] = None,
 6550        applyConsequence: Optional[base.Consequence] = None,
 6551        placeInZone: Optional[base.Zone] = None,
 6552        forceNew: bool = False,
 6553        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 6554        annotations: Optional[List[base.Annotation]] = None,
 6555        revRequires: Optional[base.Requirement] = None,
 6556        revConsequence: Optional[base.Consequence] = None,
 6557        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
 6558        revAnnotations: Optional[List[base.Annotation]] = None,
 6559        decisionTags: Optional[Dict[base.Tag, base.TagValue]] = None,
 6560        decisionAnnotations: Optional[List[base.Annotation]] = None
 6561    ) -> Tuple[
 6562        Dict[base.Transition, base.Transition],
 6563        Dict[base.Transition, base.Transition]
 6564    ]:
 6565        """
 6566        Given a decision and an edge name in that decision, where the
 6567        named edge leads to a decision with an unconfirmed exploration
 6568        state (see `isConfirmed`), renames the unexplored decision on
 6569        the other end of that edge using the given `connectTo` name, or
 6570        if a decision using that name already exists, merges the
 6571        unexplored decision into that decision. If `connectTo` is a
 6572        `DecisionSpecifier` whose target doesn't exist, it will be
 6573        treated as just a name, but if it's an ID and it doesn't exist,
 6574        you'll get a `MissingDecisionError`. If a `reciprocal` is provided,
 6575        a reciprocal edge will be added using that name connecting the
 6576        `connectTo` decision back to the original decision. If this
 6577        transition already exists, it must also point to a node which is
 6578        also unexplored, and which will also be merged into the
 6579        `fromDecision` node.
 6580
 6581        If `connectTo` is not given (or is set to `None` explicitly)
 6582        then the name of the unexplored decision will not be changed,
 6583        unless that name has the form `'_u.-n-'` where `-n-` is a positive
 6584        integer (i.e., the form given to automatically-named unknown
 6585        nodes). In that case, the name will be changed to `'_x.-n-'` using
 6586        the same number, or a higher number if that name is already taken.
 6587
 6588        If the destination is being renamed or if the destination's
 6589        exploration state counts as unexplored, the exploration state of
 6590        the destination will be set to 'exploring'.
 6591
 6592        If a `placeInZone` is specified, the destination will be placed
 6593        directly into that zone (even if it already existed and has zone
 6594        information), and it will be removed from any other zones it had
 6595        been a direct member of. If `placeInZone` is set to
 6596        `base.DefaultZone`, then the destination will be placed into
 6597        each zone which is a direct parent of the origin, but only if
 6598        the destination is not an already-explored existing decision AND
 6599        it is not already in any zones (in those cases no zone changes
 6600        are made). This will also remove it from any previous zones it
 6601        had been a part of. If `placeInZone` is left as `None` (the
 6602        default) no zone changes are made.
 6603
 6604        If `placeInZone` is specified and that zone didn't already exist,
 6605        it will be created as a new level-0 zone and will be added as a
 6606        sub-zone of each zone that's a direct parent of any level-0 zone
 6607        that the origin is a member of.
 6608
 6609        If `forceNew` is specified, then the destination will just be
 6610        renamed, even if another decision with the same name already
 6611        exists. It's an error to use `forceNew` with a decision ID as
 6612        the destination.
 6613
 6614        Any additional edges pointing to or from the unknown node(s)
 6615        being replaced will also be re-targeted at the now-discovered
 6616        known destination(s) if necessary. These edges will retain their
 6617        reciprocal names, or if this would cause a name clash, they will
 6618        be renamed with a suffix (see `retargetTransition`).
 6619
 6620        The return value is a pair of dictionaries mapping old names to
 6621        new ones that just includes the names which were changed. The
 6622        first dictionary contains renamed transitions that are outgoing
 6623        from the new destination node (which used to be outgoing from
 6624        the unexplored node). The second dictionary contains renamed
 6625        transitions that are outgoing from the source node (which used
 6626        to be outgoing from the unexplored node attached to the
 6627        reciprocal transition; if there was no reciprocal transition
 6628        specified then this will always be an empty dictionary).
 6629
 6630        An `ExplorationStatusError` will be raised if the destination
 6631        of the specified transition counts as visited (see
 6632        `hasBeenVisited`). An `ExplorationStatusError` will also be
 6633        raised if the `connectTo`'s `reciprocal` transition does not lead
 6634        to an unconfirmed decision (it's okay if this second transition
 6635        doesn't exist). A `TransitionCollisionError` will be raised if
 6636        the unconfirmed destination decision already has an outgoing
 6637        transition with the specified `reciprocal` which does not lead
 6638        back to the `fromDecision`.
 6639
 6640        The transition properties (requirement, consequences, tags,
 6641        and/or annotations) of the replaced transition will be copied
 6642        over to the new transition. Transition properties from the
 6643        reciprocal transition will also be copied for the newly created
 6644        reciprocal edge. Properties for any additional edges to/from the
 6645        unknown node will also be copied.
 6646
 6647        Also, any transition properties on existing forward or reciprocal
 6648        edges from the destination node with the indicated reverse name
 6649        will be merged with those from the target transition. Note that
 6650        this merging process may introduce corruption of complex
 6651        transition consequences. TODO: Fix that!
 6652
 6653        Any tags and annotations are added to copied tags/annotations,
 6654        but specified requirements, and/or consequences will replace
 6655        previous requirements/consequences, rather than being added to
 6656        them.
 6657
 6658        ## Example
 6659
 6660        >>> g = DecisionGraph()
 6661        >>> g.addDecision('A')
 6662        0
 6663        >>> g.addUnexploredEdge('A', 'up')
 6664        1
 6665        >>> g.destination('A', 'up')
 6666        1
 6667        >>> g.degree(1)
 6668        1
 6669        >>> g.replaceUnconfirmed('A', 'up', 'B', 'down')
 6670        ({}, {})
 6671        >>> g.destination('A', 'up')
 6672        1
 6673        >>> g.nameFor(1)
 6674        'B'
 6675        >>> g.destination('B', 'down')
 6676        0
 6677        >>> g.getDestination('B', 'return') is None
 6678        True
 6679        >>> '_u.0' in g.nameLookup
 6680        False
 6681        >>> g.getReciprocal('A', 'up')
 6682        'down'
 6683        >>> g.getReciprocal('B', 'down')
 6684        'up'
 6685        >>> # Two unexplored edges to the same node:
 6686        >>> g.addDecision('C')
 6687        2
 6688        >>> g.addTransition('B', 'next', 'C')
 6689        >>> g.addTransition('C', 'prev', 'B')
 6690        >>> g.setReciprocal('B', 'next', 'prev')
 6691        >>> g.addUnexploredEdge('A', 'next', 'D', 'prev')
 6692        3
 6693        >>> g.addTransition('C', 'down', 'D')
 6694        >>> g.addTransition('D', 'up', 'C')
 6695        >>> g.setReciprocal('C', 'down', 'up')
 6696        >>> g.replaceUnconfirmed('C', 'down')
 6697        ({}, {})
 6698        >>> g.destination('C', 'down')
 6699        3
 6700        >>> g.destination('A', 'next')
 6701        3
 6702        >>> g.destinationsFrom('D')
 6703        {'prev': 0, 'up': 2}
 6704        >>> g.decisionTags('D')
 6705        {}
 6706        >>> # An unexplored transition which turns out to connect to a
 6707        >>> # known decision, with name collisions
 6708        >>> g.addUnexploredEdge('D', 'next', reciprocal='prev')
 6709        4
 6710        >>> g.tagDecision('_u.2', 'wet')
 6711        >>> g.addUnexploredEdge('B', 'next', reciprocal='prev') # edge taken
 6712        Traceback (most recent call last):
 6713        ...
 6714        exploration.core.TransitionCollisionError...
 6715        >>> g.addUnexploredEdge('A', 'prev', reciprocal='next')
 6716        5
 6717        >>> g.tagDecision('_u.3', 'dry')
 6718        >>> # Add transitions that will collide when merged
 6719        >>> g.addUnexploredEdge('_u.2', 'up') # collides with A/up
 6720        6
 6721        >>> g.addUnexploredEdge('_u.3', 'prev') # collides with D/prev
 6722        7
 6723        >>> g.getReciprocal('A', 'prev')
 6724        'next'
 6725        >>> g.replaceUnconfirmed('A', 'prev', 'D', 'next') # two gone
 6726        ({'prev': 'prev.1'}, {'up': 'up.1'})
 6727        >>> g.destination('A', 'prev')
 6728        3
 6729        >>> g.destination('D', 'next')
 6730        0
 6731        >>> g.getReciprocal('A', 'prev')
 6732        'next'
 6733        >>> g.getReciprocal('D', 'next')
 6734        'prev'
 6735        >>> # Note that further unexplored structures are NOT merged
 6736        >>> # even if they match against existing structures...
 6737        >>> g.destination('A', 'up.1')
 6738        6
 6739        >>> g.destination('D', 'prev.1')
 6740        7
 6741        >>> '_u.2' in g.nameLookup
 6742        False
 6743        >>> '_u.3' in g.nameLookup
 6744        False
 6745        >>> g.decisionTags('D') # tags are merged
 6746        {'dry': 1}
 6747        >>> g.decisionTags('A')
 6748        {'wet': 1}
 6749        >>> # Auto-renaming an anonymous unexplored node
 6750        >>> g.addUnexploredEdge('B', 'out')
 6751        8
 6752        >>> g.replaceUnconfirmed('B', 'out', None, 'return')
 6753        ({}, {})
 6754        >>> '_u.6' in g
 6755        False
 6756        >>> g.destination('B', 'out')
 6757        8
 6758        >>> g.nameFor(8)
 6759        '_x.6'
 6760        >>> g.destination('_x.6', 'return')
 6761        1
 6762        >>> # Placing a node into a zone
 6763        >>> g.addUnexploredEdge('B', 'through')
 6764        9
 6765        >>> g.getDecision('E') is None
 6766        True
 6767        >>> g.replaceUnconfirmed(
 6768        ...     'B',
 6769        ...     'through',
 6770        ...     'E',
 6771        ...     'back',
 6772        ...     placeInZone='Zone'
 6773        ... )
 6774        ({}, {})
 6775        >>> g.getDecision('E')
 6776        9
 6777        >>> g.destination('B', 'through')
 6778        9
 6779        >>> g.destination('E', 'back')
 6780        1
 6781        >>> g.zoneParents(9)
 6782        {'Zone'}
 6783        >>> g.addUnexploredEdge('E', 'farther')
 6784        10
 6785        >>> g.replaceUnconfirmed(
 6786        ...     'E',
 6787        ...     'farther',
 6788        ...     'F',
 6789        ...     'closer',
 6790        ...     placeInZone=base.DefaultZone
 6791        ... )
 6792        ({}, {})
 6793        >>> g.destination('E', 'farther')
 6794        10
 6795        >>> g.destination('F', 'closer')
 6796        9
 6797        >>> g.zoneParents(10)
 6798        {'Zone'}
 6799        >>> g.addUnexploredEdge('F', 'backwards', placeInZone='Enoz')
 6800        11
 6801        >>> g.replaceUnconfirmed(
 6802        ...     'F',
 6803        ...     'backwards',
 6804        ...     'G',
 6805        ...     'forwards',
 6806        ...     placeInZone=base.DefaultZone
 6807        ... )
 6808        ({}, {})
 6809        >>> g.destination('F', 'backwards')
 6810        11
 6811        >>> g.destination('G', 'forwards')
 6812        10
 6813        >>> g.zoneParents(11)  # not changed since it already had a zone
 6814        {'Enoz'}
 6815        >>> # TODO: forceNew example
 6816        """
 6817
 6818        # Defaults
 6819        if tags is None:
 6820            tags = {}
 6821        if annotations is None:
 6822            annotations = []
 6823        if revTags is None:
 6824            revTags = {}
 6825        if revAnnotations is None:
 6826            revAnnotations = []
 6827        if decisionTags is None:
 6828            decisionTags = {}
 6829        if decisionAnnotations is None:
 6830            decisionAnnotations = []
 6831
 6832        # Resolve source
 6833        fromID = self.resolveDecision(fromDecision)
 6834
 6835        # Figure out destination decision
 6836        oldUnexplored = self.destination(fromID, transition)
 6837        if self.isConfirmed(oldUnexplored):
 6838            raise ExplorationStatusError(
 6839                f"Transition {transition!r} from"
 6840                f" {self.identityOf(fromDecision)} does not lead to an"
 6841                f" unconfirmed decision (it leads to"
 6842                f" {self.identityOf(oldUnexplored)} which is not tagged"
 6843                f" 'unconfirmed')."
 6844            )
 6845
 6846        # Resolve destination
 6847        newName: Optional[base.DecisionName] = None
 6848        connectID: Optional[base.DecisionID] = None
 6849        if forceNew:
 6850            if isinstance(connectTo, base.DecisionID):
 6851                raise TypeError(
 6852                    f"connectTo cannot be a decision ID when forceNew"
 6853                    f" is True. Got: {self.identityOf(connectTo)}"
 6854                )
 6855            elif isinstance(connectTo, base.DecisionSpecifier):
 6856                newName = connectTo.name
 6857            elif isinstance(connectTo, base.DecisionName):
 6858                newName = connectTo
 6859            elif connectTo is None:
 6860                oldName = self.nameFor(oldUnexplored)
 6861                if (
 6862                    oldName.startswith('_u.')
 6863                and oldName[3:].isdigit()
 6864                ):
 6865                    newName = utils.uniqueName('_x.' + oldName[3:], self)
 6866                else:
 6867                    newName = oldName
 6868            else:
 6869                raise TypeError(
 6870                    f"Invalid connectTo value: {connectTo!r}"
 6871                )
 6872        elif connectTo is not None:
 6873            try:
 6874                connectID = self.resolveDecision(connectTo)
 6875                # leave newName as None
 6876            except MissingDecisionError:
 6877                if isinstance(connectTo, int):
 6878                    raise
 6879                elif isinstance(connectTo, base.DecisionSpecifier):
 6880                    newName = connectTo.name
 6881                    # The domain & zone are ignored here
 6882                else:  # Must just be a string
 6883                    assert isinstance(connectTo, str)
 6884                    newName = connectTo
 6885        else:
 6886            # If connectTo name wasn't specified, use current name of
 6887            # unknown node unless it's a default name
 6888            oldName = self.nameFor(oldUnexplored)
 6889            if (
 6890                oldName.startswith('_u.')
 6891            and oldName[3:].isdigit()
 6892            ):
 6893                newName = utils.uniqueName('_x.' + oldName[3:], self)
 6894            else:
 6895                newName = oldName
 6896
 6897        # One or the other should be valid at this point
 6898        assert connectID is not None or newName is not None
 6899
 6900        # Check that the old unknown doesn't have a reciprocal edge that
 6901        # would collide with the specified return edge
 6902        if reciprocal is not None:
 6903            revFromUnknown = self.getDestination(oldUnexplored, reciprocal)
 6904            if revFromUnknown not in (None, fromID):
 6905                raise TransitionCollisionError(
 6906                    f"Transition {reciprocal!r} from"
 6907                    f" {self.identityOf(oldUnexplored)} exists and does"
 6908                    f" not lead back to {self.identityOf(fromDecision)}"
 6909                    f" (it leads to {self.identityOf(revFromUnknown)})."
 6910                )
 6911
 6912        # Remember old reciprocal edge for future merging in case
 6913        # it's not reciprocal
 6914        oldReciprocal = self.getReciprocal(fromID, transition)
 6915
 6916        # Apply any new tags or annotations, or create a new node
 6917        needsZoneInfo = False
 6918        if connectID is not None:
 6919            # Before applying tags, check if we need to error out
 6920            # because of a reciprocal edge that points to a known
 6921            # destination:
 6922            if reciprocal is not None:
 6923                otherOldUnknown: Optional[
 6924                    base.DecisionID
 6925                ] = self.getDestination(
 6926                    connectID,
 6927                    reciprocal
 6928                )
 6929                if (
 6930                    otherOldUnknown is not None
 6931                and self.isConfirmed(otherOldUnknown)
 6932                ):
 6933                    raise ExplorationStatusError(
 6934                        f"Reciprocal transition {reciprocal!r} from"
 6935                        f" {self.identityOf(connectTo)} does not lead"
 6936                        f" to an unconfirmed decision (it leads to"
 6937                        f" {self.identityOf(otherOldUnknown)})."
 6938                    )
 6939            self.tagDecision(connectID, decisionTags)
 6940            self.annotateDecision(connectID, decisionAnnotations)
 6941            # Still needs zone info if the place we're connecting to was
 6942            # unconfirmed up until now, since unconfirmed nodes don't
 6943            # normally get zone info when they're created.
 6944            if not self.isConfirmed(connectID):
 6945                needsZoneInfo = True
 6946
 6947            # First, merge the old unknown with the connectTo node...
 6948            destRenames = self.mergeDecisions(
 6949                oldUnexplored,
 6950                connectID,
 6951                errorOnNameColision=False
 6952            )
 6953        else:
 6954            needsZoneInfo = True
 6955            if len(self.zoneParents(oldUnexplored)) > 0:
 6956                needsZoneInfo = False
 6957            assert newName is not None
 6958            self.renameDecision(oldUnexplored, newName)
 6959            connectID = oldUnexplored
 6960            # In this case there can't be an other old unknown
 6961            otherOldUnknown = None
 6962            destRenames = {}  # empty
 6963
 6964        # Check for domain mismatch to stifle zone updates:
 6965        fromDomain = self.domainFor(fromID)
 6966        if connectID is None:
 6967            destDomain = self.domainFor(oldUnexplored)
 6968        else:
 6969            destDomain = self.domainFor(connectID)
 6970
 6971        # Stifle zone updates if there's a mismatch
 6972        if fromDomain != destDomain:
 6973            needsZoneInfo = False
 6974
 6975        # Records renames that happen at the source (from node)
 6976        sourceRenames = {}  # empty for now
 6977
 6978        assert connectID is not None
 6979
 6980        # Apply the new zone if there is one
 6981        if placeInZone is not None:
 6982            if placeInZone == base.DefaultZone:
 6983                # When using DefaultZone, changes are only made for new
 6984                # destinations which don't already have any zones and
 6985                # which are in the same domain as the departing node:
 6986                # they get placed into each zone parent of the source
 6987                # decision.
 6988                if needsZoneInfo:
 6989                    # Remove destination from all current parents
 6990                    removeFrom = set(self.zoneParents(connectID))  # copy
 6991                    for parent in removeFrom:
 6992                        self.removeDecisionFromZone(connectID, parent)
 6993                    # Add it to parents of origin
 6994                    for parent in self.zoneParents(fromID):
 6995                        self.addDecisionToZone(connectID, parent)
 6996            else:
 6997                placeInZone = cast(base.Zone, placeInZone)
 6998                # Create the zone if it doesn't already exist
 6999                if self.getZoneInfo(placeInZone) is None:
 7000                    self.createZone(placeInZone, 0)
 7001                    # Add it to each grandparent of the from decision
 7002                    for parent in self.zoneParents(fromID):
 7003                        for grandparent in self.zoneParents(parent):
 7004                            self.addZoneToZone(placeInZone, grandparent)
 7005                # Remove destination from all current parents
 7006                for parent in set(self.zoneParents(connectID)):
 7007                    self.removeDecisionFromZone(connectID, parent)
 7008                # Add it to the specified zone
 7009                self.addDecisionToZone(connectID, placeInZone)
 7010
 7011        # Next, if there is a reciprocal name specified, we do more...
 7012        if reciprocal is not None:
 7013            # Figure out what kind of merging needs to happen
 7014            if otherOldUnknown is None:
 7015                if revFromUnknown is None:
 7016                    # Just create the desired reciprocal transition, which
 7017                    # we know does not already exist
 7018                    self.addTransition(connectID, reciprocal, fromID)
 7019                    otherOldReciprocal = None
 7020                else:
 7021                    # Reciprocal exists, as revFromUnknown
 7022                    otherOldReciprocal = None
 7023            else:
 7024                otherOldReciprocal = self.getReciprocal(
 7025                    connectID,
 7026                    reciprocal
 7027                )
 7028                # we need to merge otherOldUnknown into our fromDecision
 7029                sourceRenames = self.mergeDecisions(
 7030                    otherOldUnknown,
 7031                    fromID,
 7032                    errorOnNameColision=False
 7033                )
 7034                # Unvisited tag after merge only if both were
 7035
 7036            # No matter what happened we ensure the reciprocal
 7037            # relationship is set up:
 7038            self.setReciprocal(fromID, transition, reciprocal)
 7039
 7040            # Now we might need to merge some transitions:
 7041            # - Any reciprocal of the target transition should be merged
 7042            #   with reciprocal (if it was already reciprocal, that's a
 7043            #   no-op).
 7044            # - Any reciprocal of the reciprocal transition from the target
 7045            #   node (leading to otherOldUnknown) should be merged with
 7046            #   the target transition, even if it shared a name and was
 7047            #   renamed as a result.
 7048            # - If reciprocal was renamed during the initial merge, those
 7049            #   transitions should be merged.
 7050
 7051            # Merge old reciprocal into reciprocal
 7052            if oldReciprocal is not None:
 7053                oldRev = destRenames.get(oldReciprocal, oldReciprocal)
 7054                if self.getDestination(connectID, oldRev) is not None:
 7055                    # Note that we don't want to auto-merge the reciprocal,
 7056                    # which is the target transition
 7057                    self.mergeTransitions(
 7058                        connectID,
 7059                        oldRev,
 7060                        reciprocal,
 7061                        mergeReciprocal=False
 7062                    )
 7063                    # Remove it from the renames map
 7064                    if oldReciprocal in destRenames:
 7065                        del destRenames[oldReciprocal]
 7066
 7067            # Merge reciprocal reciprocal from otherOldUnknown
 7068            if otherOldReciprocal is not None:
 7069                otherOldRev = sourceRenames.get(
 7070                    otherOldReciprocal,
 7071                    otherOldReciprocal
 7072                )
 7073                # Note that the reciprocal is reciprocal, which we don't
 7074                # need to merge
 7075                self.mergeTransitions(
 7076                    fromID,
 7077                    otherOldRev,
 7078                    transition,
 7079                    mergeReciprocal=False
 7080                )
 7081                # Remove it from the renames map
 7082                if otherOldReciprocal in sourceRenames:
 7083                    del sourceRenames[otherOldReciprocal]
 7084
 7085            # Merge any renamed reciprocal onto reciprocal
 7086            if reciprocal in destRenames:
 7087                extraRev = destRenames[reciprocal]
 7088                self.mergeTransitions(
 7089                    connectID,
 7090                    extraRev,
 7091                    reciprocal,
 7092                    mergeReciprocal=False
 7093                )
 7094                # Remove it from the renames map
 7095                del destRenames[reciprocal]
 7096
 7097        # Accumulate new tags & annotations for the transitions
 7098        self.tagTransition(fromID, transition, tags)
 7099        self.annotateTransition(fromID, transition, annotations)
 7100
 7101        if reciprocal is not None:
 7102            self.tagTransition(connectID, reciprocal, revTags)
 7103            self.annotateTransition(connectID, reciprocal, revAnnotations)
 7104
 7105        # Override copied requirement/consequences for the transitions
 7106        if requirement is not None:
 7107            self.setTransitionRequirement(
 7108                fromID,
 7109                transition,
 7110                requirement
 7111            )
 7112        if applyConsequence is not None:
 7113            self.setConsequence(
 7114                fromID,
 7115                transition,
 7116                applyConsequence
 7117            )
 7118
 7119        if reciprocal is not None:
 7120            if revRequires is not None:
 7121                self.setTransitionRequirement(
 7122                    connectID,
 7123                    reciprocal,
 7124                    revRequires
 7125                )
 7126            if revConsequence is not None:
 7127                self.setConsequence(
 7128                    connectID,
 7129                    reciprocal,
 7130                    revConsequence
 7131                )
 7132
 7133        # Remove 'unconfirmed' tag if it was present
 7134        self.untagDecision(connectID, 'unconfirmed')
 7135
 7136        # Final checks
 7137        assert self.getDestination(fromDecision, transition) == connectID
 7138        useConnect: base.AnyDecisionSpecifier
 7139        useRev: Optional[str]
 7140        if connectTo is None:
 7141            useConnect = connectID
 7142        else:
 7143            useConnect = connectTo
 7144        if reciprocal is None:
 7145            useRev = self.getReciprocal(fromDecision, transition)
 7146        else:
 7147            useRev = reciprocal
 7148        if useRev is not None:
 7149            try:
 7150                assert self.getDestination(useConnect, useRev) == fromID
 7151            except AmbiguousDecisionSpecifierError:
 7152                assert self.getDestination(connectID, useRev) == fromID
 7153
 7154        # Return our final rename dictionaries
 7155        return (destRenames, sourceRenames)
 7156
 7157    def endingID(self, name: base.DecisionName) -> base.DecisionID:
 7158        """
 7159        Returns the decision ID for the ending with the specified name.
 7160        Endings are disconnected decisions in the `ENDINGS_DOMAIN`; they
 7161        don't normally include any zone information. If no ending with
 7162        the specified name already existed, then a new ending with that
 7163        name will be created and its Decision ID will be returned.
 7164
 7165        If a new decision is created, it will be tagged as unconfirmed.
 7166
 7167        Note that endings mostly aren't special: they're normal
 7168        decisions in a separate singular-focalized domain. However, some
 7169        parts of the exploration and journal machinery treat them
 7170        differently (in particular, taking certain actions via
 7171        `advanceSituation` while any decision in the `ENDINGS_DOMAIN` is
 7172        active is an error.
 7173        """
 7174        # Create our new ending decision if we need to
 7175        try:
 7176            endID = self.resolveDecision(
 7177                base.DecisionSpecifier(ENDINGS_DOMAIN, None, name)
 7178            )
 7179        except MissingDecisionError:
 7180            # Create a new decision for the ending
 7181            endID = self.addDecision(name, domain=ENDINGS_DOMAIN)
 7182            # Tag it as unconfirmed
 7183            self.tagDecision(endID, 'unconfirmed')
 7184
 7185        return endID
 7186
 7187    def triggerGroupID(self, name: base.DecisionName) -> base.DecisionID:
 7188        """
 7189        Given the name of a trigger group, returns the ID of the special
 7190        node representing that trigger group in the `TRIGGERS_DOMAIN`.
 7191        If the specified group didn't already exist, it will be created.
 7192
 7193        Trigger group decisions are not special: they just exist in a
 7194        separate spreading-focalized domain and have a few API methods to
 7195        access them, but all the normal decision-related API methods
 7196        still work. Their intended use is for sets of global triggers,
 7197        by attaching actions with the 'trigger' tag to them and then
 7198        activating or deactivating them as needed.
 7199        """
 7200        result = self.getDecision(
 7201            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
 7202        )
 7203        if result is None:
 7204            return self.addDecision(name, domain=TRIGGERS_DOMAIN)
 7205        else:
 7206            return result
 7207
 7208    @staticmethod
 7209    def example(which: Literal['simple', 'abc']) -> 'DecisionGraph':
 7210        """
 7211        Returns one of a number of example decision graphs, depending on
 7212        the string given. It returns a fresh copy each time. The graphs
 7213        are:
 7214
 7215        - 'simple': Three nodes named 'A', 'B', and 'C' with IDs 0, 1,
 7216            and 2, each connected to the next in the sequence by a
 7217            'next' transition with reciprocal 'prev'. In other words, a
 7218            simple little triangle. There are no tags, annotations,
 7219            requirements, consequences, mechanisms, or equivalences.
 7220        - 'abc': A more complicated 3-node setup that introduces a
 7221            little bit of everything. In this graph, we have the same
 7222            three nodes, but different transitions:
 7223
 7224                * From A you can go 'left' to B with reciprocal 'right'.
 7225                * From A you can also go 'up_left' to B with reciprocal
 7226                    'up_right'. These transitions both require the
 7227                    'grate' mechanism (which is at decision A) to be in
 7228                    state 'open'.
 7229                * From A you can go 'down' to C with reciprocal 'up'.
 7230
 7231            (In this graph, B and C are not directly connected to each
 7232            other.)
 7233
 7234            The graph has two level-0 zones 'zoneA' and 'zoneB', along
 7235            with a level-1 zone 'upZone'. Decisions A and C are in
 7236            zoneA while B is in zoneB; zoneA is in upZone, but zoneB is
 7237            not.
 7238
 7239            The decision A has annotation:
 7240
 7241                'This is a multi-word "annotation."'
 7242
 7243            The transition 'down' from A has annotation:
 7244
 7245                "Transition 'annotation.'"
 7246
 7247            Decision B has tags 'b' with value 1 and 'tag2' with value
 7248            '"value"'.
 7249
 7250            Decision C has tag 'aw"ful' with value "ha'ha'".
 7251
 7252            Transition 'up' from C has tag 'fast' with value 1.
 7253
 7254            At decision C there are actions 'grab_helmet' and
 7255            'pull_lever'.
 7256
 7257            The 'grab_helmet' transition requires that you don't have
 7258            the 'helmet' capability, and gives you that capability,
 7259            deactivating with delay 3.
 7260
 7261            The 'pull_lever' transition requires that you do have the
 7262            'helmet' capability, and takes away that capability, but it
 7263            also gives you 1 'token' token, and if you have 2 tokens
 7264            (before getting the one extra), it sets the 'grate' mechanism
 7265            (which is a decision A) to state 'open' and deactivates.
 7266
 7267            The graph has an equivalence: having the 'helmet' capability
 7268            satisfies requirements for the 'grate' mechanism to be in the
 7269            'open' state.
 7270        """
 7271        result = DecisionGraph()
 7272        if which == 'simple':
 7273            result.addDecision('A')  # id 0
 7274            result.addDecision('B')  # id 1
 7275            result.addDecision('C')  # id 2
 7276            result.addTransition('A', 'next', 'B', 'prev')
 7277            result.addTransition('B', 'next', 'C', 'prev')
 7278            result.addTransition('C', 'next', 'A', 'prev')
 7279        elif which == 'abc':
 7280            result.addDecision('A')  # id 0
 7281            result.addDecision('B')  # id 1
 7282            result.addDecision('C')  # id 2
 7283            result.createZone('zoneA', 0)
 7284            result.createZone('zoneB', 0)
 7285            result.createZone('upZone', 1)
 7286            result.addZoneToZone('zoneA', 'upZone')
 7287            result.addDecisionToZone('A', 'zoneA')
 7288            result.addDecisionToZone('B', 'zoneB')
 7289            result.addDecisionToZone('C', 'zoneA')
 7290            result.addTransition('A', 'left', 'B', 'right')
 7291            result.addTransition('A', 'up_left', 'B', 'up_right')
 7292            result.addTransition('A', 'down', 'C', 'up')
 7293            result.setTransitionRequirement(
 7294                'A',
 7295                'up_left',
 7296                base.ReqMechanism('grate', 'open')
 7297            )
 7298            result.setTransitionRequirement(
 7299                'B',
 7300                'up_right',
 7301                base.ReqMechanism('grate', 'open')
 7302            )
 7303            result.annotateDecision('A', 'This is a multi-word "annotation."')
 7304            result.annotateTransition('A', 'down', "Transition 'annotation.'")
 7305            result.tagDecision('B', 'b')
 7306            result.tagDecision('B', 'tag2', '"value"')
 7307            result.tagDecision('C', 'aw"ful', "ha'ha")
 7308            result.tagTransition('C', 'up', 'fast')
 7309            result.addMechanism('grate', 'A')
 7310            result.addAction(
 7311                'C',
 7312                'grab_helmet',
 7313                base.ReqNot(base.ReqCapability('helmet')),
 7314                [
 7315                    base.effect(gain='helmet'),
 7316                    base.effect(deactivate=True, delay=3)
 7317                ]
 7318            )
 7319            result.addAction(
 7320                'C',
 7321                'pull_lever',
 7322                base.ReqCapability('helmet'),
 7323                [
 7324                    base.effect(lose='helmet'),
 7325                    base.effect(gain=('token', 1)),
 7326                    base.condition(
 7327                        base.ReqTokens('token', 2),
 7328                        [
 7329                            base.effect(set=('grate', 'open')),
 7330                            base.effect(deactivate=True)
 7331                        ]
 7332                    )
 7333                ]
 7334            )
 7335            result.addEquivalence(
 7336                base.ReqCapability('helmet'),
 7337                (0, 'open')
 7338            )
 7339        else:
 7340            raise ValueError(f"Invalid example name: {which!r}")
 7341
 7342        return result
 7343
 7344
 7345#---------------------------#
 7346# DiscreteExploration class #
 7347#---------------------------#
 7348
 7349def emptySituation() -> base.Situation:
 7350    """
 7351    Creates and returns an empty situation: A situation that has an
 7352    empty `DecisionGraph`, an empty `State`, a 'pending' decision type
 7353    with `None` as the action taken, no tags, and no annotations.
 7354    """
 7355    return base.Situation(
 7356        graph=DecisionGraph(),
 7357        state=base.emptyState(),
 7358        type='pending',
 7359        action=None,
 7360        saves={},
 7361        tags={},
 7362        annotations=[]
 7363    )
 7364
 7365    pass
 7366
 7367
 7368class DiscreteExploration:
 7369    """
 7370    A list of `Situations` each of which contains a `DecisionGraph`
 7371    representing exploration over time, with `States` containing
 7372    `FocalContext` information for each step and 'taken' values for the
 7373    transition selected (at a particular decision) in that step. Each
 7374    decision graph represents a new state of the world (and/or new
 7375    knowledge about a persisting state of the world), and the 'taken'
 7376    transition in one situation transition indicates which option was
 7377    selected, or what event happened to cause update(s). Depending on the
 7378    resolution, it could represent a close record of every decision made
 7379    or a more coarse set of snapshots from gameplay with more time in
 7380    between.
 7381
 7382    The steps of the exploration can also be tagged and annotated (see
 7383    `tagStep` and `annotateStep`).
 7384
 7385    It also holds a `layouts` field that includes zero or more
 7386    `base.Layout`s by name.
 7387
 7388    When a new `DiscreteExploration` is created, it starts out with an
 7389    empty `Situation` that contains an empty `DecisionGraph`. Use the
 7390    `start` method to name the starting decision point and set things up
 7391    for other methods.
 7392
 7393    Tracking of player goals and destinations is also planned (see the
 7394    `quest`, `progress`, `complete`, `destination`, and `arrive` methods).
 7395    TODO: That
 7396    """
 7397    def __init__(self) -> None:
 7398        self.situations: List[base.Situation] = [
 7399            base.Situation(
 7400                graph=DecisionGraph(),
 7401                state=base.emptyState(),
 7402                type='pending',
 7403                action=None,
 7404                saves={},
 7405                tags={},
 7406                annotations=[]
 7407            )
 7408        ]
 7409        self.layouts: Dict[str, base.Layout] = {}
 7410
 7411    # Note: not hashable
 7412
 7413    def __eq__(self, other):
 7414        """
 7415        Equality checker. `DiscreteExploration`s can only be equal to
 7416        other `DiscreteExploration`s, not to other kinds of things.
 7417        """
 7418        if not isinstance(other, DiscreteExploration):
 7419            return False
 7420        else:
 7421            return self.situations == other.situations
 7422
 7423    @staticmethod
 7424    def fromGraph(
 7425        graph: DecisionGraph,
 7426        state: Optional[base.State] = None
 7427    ) -> 'DiscreteExploration':
 7428        """
 7429        Creates an exploration which has just a single step whose graph
 7430        is the entire specified graph, with the specified decision as
 7431        the primary decision (if any). The graph is copied, so that
 7432        changes to the exploration will not modify it. A starting state
 7433        may also be specified if desired, although if not an empty state
 7434        will be used (a provided starting state is NOT copied, but used
 7435        directly).
 7436
 7437        Example:
 7438
 7439        >>> g = DecisionGraph()
 7440        >>> g.addDecision('Room1')
 7441        0
 7442        >>> g.addDecision('Room2')
 7443        1
 7444        >>> g.addTransition('Room1', 'door', 'Room2', 'door')
 7445        >>> e = DiscreteExploration.fromGraph(g)
 7446        >>> len(e)
 7447        1
 7448        >>> e.getSituation().graph == g
 7449        True
 7450        >>> e.getActiveDecisions()
 7451        set()
 7452        >>> e.primaryDecision() is None
 7453        True
 7454        >>> e.observe('Room1', 'hatch')
 7455        2
 7456        >>> e.getSituation().graph == g
 7457        False
 7458        >>> e.getSituation().graph.destinationsFrom('Room1')
 7459        {'door': 1, 'hatch': 2}
 7460        >>> g.destinationsFrom('Room1')
 7461        {'door': 1}
 7462        """
 7463        result = DiscreteExploration()
 7464        result.situations[0] = base.Situation(
 7465            graph=copy.deepcopy(graph),
 7466            state=base.emptyState() if state is None else state,
 7467            type='pending',
 7468            action=None,
 7469            saves={},
 7470            tags={},
 7471            annotations=[]
 7472        )
 7473        return result
 7474
 7475    def __len__(self) -> int:
 7476        """
 7477        The 'length' of an exploration is the number of steps.
 7478        """
 7479        return len(self.situations)
 7480
 7481    def __getitem__(self, i: int) -> base.Situation:
 7482        """
 7483        Indexing an exploration returns the situation at that step.
 7484        """
 7485        return self.situations[i]
 7486
 7487    def __iter__(self) -> Iterator[base.Situation]:
 7488        """
 7489        Iterating over an exploration yields each `Situation` in order.
 7490        """
 7491        for i in range(len(self)):
 7492            yield self[i]
 7493
 7494    def getSituation(self, step: int = -1) -> base.Situation:
 7495        """
 7496        Returns a `base.Situation` named tuple detailing the state of
 7497        the exploration at a given step (or at the current step if no
 7498        argument is given). Note that this method works the same
 7499        way as indexing the exploration: see `__getitem__`.
 7500
 7501        Raises an `IndexError` if asked for a step that's out-of-range.
 7502        """
 7503        return self[step]
 7504
 7505    def primaryDecision(self, step: int = -1) -> Optional[base.DecisionID]:
 7506        """
 7507        Returns the current primary `base.DecisionID`, or the primary
 7508        decision from a specific step if one is specified. This may be
 7509        `None` for some steps, but mostly it's the destination of the
 7510        transition taken in the previous step.
 7511        """
 7512        return self[step].state['primaryDecision']
 7513
 7514    def effectiveCapabilities(
 7515        self,
 7516        step: int = -1
 7517    ) -> base.CapabilitySet:
 7518        """
 7519        Returns the effective capability set for the specified step
 7520        (default is the last/current step). See
 7521        `base.effectiveCapabilities`.
 7522        """
 7523        return base.effectiveCapabilitySet(self.getSituation(step).state)
 7524
 7525    def getCommonContext(
 7526        self,
 7527        step: Optional[int] = None
 7528    ) -> base.FocalContext:
 7529        """
 7530        Returns the common `FocalContext` at the specified step, or at
 7531        the current step if no argument is given. Raises an `IndexError`
 7532        if an invalid step is specified.
 7533        """
 7534        if step is None:
 7535            step = -1
 7536        state = self.getSituation(step).state
 7537        return state['common']
 7538
 7539    def getActiveContext(
 7540        self,
 7541        step: Optional[int] = None
 7542    ) -> base.FocalContext:
 7543        """
 7544        Returns the active `FocalContext` at the specified step, or at
 7545        the current step if no argument is provided. Raises an
 7546        `IndexError` if an invalid step is specified.
 7547        """
 7548        if step is None:
 7549            step = -1
 7550        state = self.getSituation(step).state
 7551        return state['contexts'][state['activeContext']]
 7552
 7553    def addFocalContext(self, name: base.FocalContextName) -> None:
 7554        """
 7555        Adds a new empty focal context to our set of focal contexts (see
 7556        `emptyFocalContext`). Use `setActiveContext` to swap to it.
 7557        Raises a `FocalContextCollisionError` if the name is already in
 7558        use.
 7559        """
 7560        contextMap = self.getSituation().state['contexts']
 7561        if name in contextMap:
 7562            raise FocalContextCollisionError(
 7563                f"Cannot add focal context {name!r}: a focal context"
 7564                f" with that name already exists."
 7565            )
 7566        contextMap[name] = base.emptyFocalContext()
 7567
 7568    def setActiveContext(self, which: base.FocalContextName) -> None:
 7569        """
 7570        Sets the active context to the named focal context, creating it
 7571        if it did not already exist (makes changes to the current
 7572        situation only). Does not add an exploration step (use
 7573        `advanceSituation` with a 'swap' action for that).
 7574        """
 7575        state = self.getSituation().state
 7576        contextMap = state['contexts']
 7577        if which not in contextMap:
 7578            self.addFocalContext(which)
 7579        state['activeContext'] = which
 7580
 7581    def createDomain(
 7582        self,
 7583        name: base.Domain,
 7584        focalization: base.DomainFocalization = 'singular',
 7585        makeActive: bool = False,
 7586        inCommon: Union[bool, Literal["both"]] = "both"
 7587    ) -> None:
 7588        """
 7589        Creates a new domain with the given focalization type, in either
 7590        the common context (`inCommon` = `True`) the active context
 7591        (`inCommon` = `False`) or both (the default; `inCommon` = 'both').
 7592        The domain's focalization will be set to the given
 7593        `focalization` value (default 'singular') and it will have no
 7594        active decisions. Raises a `DomainCollisionError` if a domain
 7595        with the specified name already exists.
 7596
 7597        Creates the domain in the current situation.
 7598
 7599        If `makeActive` is set to `True` (default is `False`) then the
 7600        domain will be made active in whichever context(s) it's created
 7601        in.
 7602        """
 7603        now = self.getSituation()
 7604        state = now.state
 7605        modify = []
 7606        if inCommon in (True, "both"):
 7607            modify.append(('common', state['common']))
 7608        if inCommon in (False, "both"):
 7609            acName = state['activeContext']
 7610            modify.append(
 7611                ('current ({repr(acName)})', state['contexts'][acName])
 7612            )
 7613
 7614        for (fcType, fc) in modify:
 7615            if name in fc['focalization']:
 7616                raise DomainCollisionError(
 7617                    f"Cannot create domain {repr(name)} because a"
 7618                    f" domain with that name already exists in the"
 7619                    f" {fcType} focal context."
 7620                )
 7621            fc['focalization'][name] = focalization
 7622            if makeActive:
 7623                fc['activeDomains'].add(name)
 7624            if focalization == "spreading":
 7625                fc['activeDecisions'][name] = set()
 7626            elif focalization == "plural":
 7627                fc['activeDecisions'][name] = {}
 7628            else:
 7629                fc['activeDecisions'][name] = None
 7630
 7631    def activateDomain(
 7632        self,
 7633        domain: base.Domain,
 7634        activate: bool = True,
 7635        inContext: base.ContextSpecifier = "active"
 7636    ) -> None:
 7637        """
 7638        Sets the given domain as active (or inactive if 'activate' is
 7639        given as `False`) in the specified context (default "active").
 7640
 7641        Modifies the current situation.
 7642        """
 7643        fc: base.FocalContext
 7644        if inContext == "active":
 7645            fc = self.getActiveContext()
 7646        elif inContext == "common":
 7647            fc = self.getCommonContext()
 7648
 7649        if activate:
 7650            fc['activeDomains'].add(domain)
 7651        else:
 7652            try:
 7653                fc['activeDomains'].remove(domain)
 7654            except KeyError:
 7655                pass
 7656
 7657    def createTriggerGroup(
 7658        self,
 7659        name: base.DecisionName
 7660    ) -> base.DecisionID:
 7661        """
 7662        Creates a new trigger group with the given name, returning the
 7663        decision ID for that trigger group. If this is the first trigger
 7664        group being created, also creates the `TRIGGERS_DOMAIN` domain
 7665        as a spreading-focalized domain that's active in the common
 7666        context (but does NOT set the created trigger group as an active
 7667        decision in that domain).
 7668
 7669        You can use 'goto' effects to activate trigger domains via
 7670        consequences, and 'retreat' effects to deactivate them.
 7671
 7672        Creating a second trigger group with the same name as another
 7673        results in a `ValueError`.
 7674
 7675        TODO: Retreat effects
 7676        """
 7677        ctx = self.getCommonContext()
 7678        if TRIGGERS_DOMAIN not in ctx['focalization']:
 7679            self.createDomain(
 7680                TRIGGERS_DOMAIN,
 7681                focalization='spreading',
 7682                makeActive=True,
 7683                inCommon=True
 7684            )
 7685
 7686        graph = self.getSituation().graph
 7687        if graph.getDecision(
 7688            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
 7689        ) is not None:
 7690            raise ValueError(
 7691                f"Cannot create trigger group {name!r}: a trigger group"
 7692                f" with that name already exists."
 7693            )
 7694
 7695        return self.getSituation().graph.triggerGroupID(name)
 7696
 7697    def toggleTriggerGroup(
 7698        self,
 7699        name: base.DecisionName,
 7700        setActive: Union[bool, None] = None
 7701    ):
 7702        """
 7703        Toggles whether the specified trigger group (a decision in the
 7704        `TRIGGERS_DOMAIN`) is active or not. Pass `True` or `False` as
 7705        the `setActive` argument (instead of the default `None`) to set
 7706        the state directly instead of toggling it.
 7707
 7708        Note that trigger groups are decisions in a spreading-focalized
 7709        domain, so they can be activated or deactivated by the 'goto'
 7710        and 'retreat' effects as well.
 7711
 7712        This does not affect whether the `TRIGGERS_DOMAIN` itself is
 7713        active (normally it would always be active).
 7714
 7715        Raises a `MissingDecisionError` if the specified trigger group
 7716        does not exist yet, including when the entire `TRIGGERS_DOMAIN`
 7717        does not exist. Raises a `KeyError` if the target group exists
 7718        but the `TRIGGERS_DOMAIN` has not been set up properly.
 7719        """
 7720        ctx = self.getCommonContext()
 7721        tID = self.getSituation().graph.resolveDecision(
 7722            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
 7723        )
 7724        activeGroups = ctx['activeDecisions'][TRIGGERS_DOMAIN]
 7725        assert isinstance(activeGroups, set)
 7726        if tID in activeGroups:
 7727            if setActive is not True:
 7728                activeGroups.remove(tID)
 7729        else:
 7730            if setActive is not False:
 7731                activeGroups.add(tID)
 7732
 7733    def getActiveDecisions(
 7734        self,
 7735        step: Optional[int] = None,
 7736        inCommon: Union[bool, Literal["both"]] = "both"
 7737    ) -> Set[base.DecisionID]:
 7738        """
 7739        Returns the set of active decisions at the given step index, or
 7740        at the current step if no step is specified. Raises an
 7741        `IndexError` if the step index is out of bounds (see `__len__`).
 7742        May return an empty set if no decisions are active.
 7743
 7744        If `inCommon` is set to "both" (the default) then decisions
 7745        active in either the common or active context are returned. Set
 7746        it to `True` or `False` to return only decisions active in the
 7747        common (when `True`) or  active (when `False`) context.
 7748        """
 7749        if step is None:
 7750            step = -1
 7751        state = self.getSituation(step).state
 7752        if inCommon == "both":
 7753            return base.combinedDecisionSet(state)
 7754        elif inCommon is True:
 7755            return base.activeDecisionSet(state['common'])
 7756        elif inCommon is False:
 7757            return base.activeDecisionSet(
 7758                state['contexts'][state['activeContext']]
 7759            )
 7760        else:
 7761            raise ValueError(
 7762                f"Invalid inCommon value {repr(inCommon)} (must be"
 7763                f" 'both', True, or False)."
 7764            )
 7765
 7766    def setActiveDecisionsAtStep(
 7767        self,
 7768        step: int,
 7769        domain: base.Domain,
 7770        activate: Union[
 7771            base.DecisionID,
 7772            Dict[base.FocalPointName, Optional[base.DecisionID]],
 7773            Set[base.DecisionID]
 7774        ],
 7775        inCommon: bool = False
 7776    ) -> None:
 7777        """
 7778        Changes the activation status of decisions in the active
 7779        `FocalContext` at the specified step, for the specified domain
 7780        (see `currentActiveContext`). Does this without adding an
 7781        exploration step, which is unusual: normally you should use
 7782        another method like `warp` to update active decisions.
 7783
 7784        Note that this does not change which domains are active, and
 7785        setting active decisions in inactive domains does not make those
 7786        decisions active overall.
 7787
 7788        Which decisions to activate or deactivate are specified as
 7789        either a single `DecisionID`, a list of them, or a set of them,
 7790        depending on the `DomainFocalization` setting in the selected
 7791        `FocalContext` for the specified domain. A `TypeError` will be
 7792        raised if the wrong kind of decision information is provided. If
 7793        the focalization context does not have any focalization value for
 7794        the domain in question, it will be set based on the kind of
 7795        active decision information specified.
 7796
 7797        A `MissingDecisionError` will be raised if a decision is
 7798        included which is not part of the current `DecisionGraph`.
 7799        The provided information will overwrite the previous active
 7800        decision information.
 7801
 7802        If `inCommon` is set to `True`, then decisions are activated or
 7803        deactivated in the common context, instead of in the active
 7804        context.
 7805
 7806        Example:
 7807
 7808        >>> e = DiscreteExploration()
 7809        >>> e.getActiveDecisions()
 7810        set()
 7811        >>> graph = e.getSituation().graph
 7812        >>> graph.addDecision('A')
 7813        0
 7814        >>> graph.addDecision('B')
 7815        1
 7816        >>> graph.addDecision('C')
 7817        2
 7818        >>> e.setActiveDecisionsAtStep(0, 'main', 0)
 7819        >>> e.getActiveDecisions()
 7820        {0}
 7821        >>> e.setActiveDecisionsAtStep(0, 'main', 1)
 7822        >>> e.getActiveDecisions()
 7823        {1}
 7824        >>> graph = e.getSituation().graph
 7825        >>> graph.addDecision('One', domain='numbers')
 7826        3
 7827        >>> graph.addDecision('Two', domain='numbers')
 7828        4
 7829        >>> graph.addDecision('Three', domain='numbers')
 7830        5
 7831        >>> graph.addDecision('Bear', domain='animals')
 7832        6
 7833        >>> graph.addDecision('Spider', domain='animals')
 7834        7
 7835        >>> graph.addDecision('Eel', domain='animals')
 7836        8
 7837        >>> ac = e.getActiveContext()
 7838        >>> ac['focalization']['numbers'] = 'plural'
 7839        >>> ac['focalization']['animals'] = 'spreading'
 7840        >>> ac['activeDecisions']['numbers'] = {'a': None, 'b': None}
 7841        >>> ac['activeDecisions']['animals'] = set()
 7842        >>> cc = e.getCommonContext()
 7843        >>> cc['focalization']['numbers'] = 'plural'
 7844        >>> cc['focalization']['animals'] = 'spreading'
 7845        >>> cc['activeDecisions']['numbers'] = {'z': None}
 7846        >>> cc['activeDecisions']['animals'] = set()
 7847        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 3, 'b': 3})
 7848        >>> e.getActiveDecisions()
 7849        {1}
 7850        >>> e.activateDomain('numbers')
 7851        >>> e.getActiveDecisions()
 7852        {1, 3}
 7853        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 4, 'b': None})
 7854        >>> e.getActiveDecisions()
 7855        {1, 4}
 7856        >>> # Wrong domain for the decision ID:
 7857        >>> e.setActiveDecisionsAtStep(0, 'main', 3)
 7858        Traceback (most recent call last):
 7859        ...
 7860        ValueError...
 7861        >>> # Wrong domain for one of the decision IDs:
 7862        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 2, 'b': None})
 7863        Traceback (most recent call last):
 7864        ...
 7865        ValueError...
 7866        >>> # Wrong kind of decision information provided.
 7867        >>> e.setActiveDecisionsAtStep(0, 'numbers', 3)
 7868        Traceback (most recent call last):
 7869        ...
 7870        TypeError...
 7871        >>> e.getActiveDecisions()
 7872        {1, 4}
 7873        >>> e.setActiveDecisionsAtStep(0, 'animals', {6, 7})
 7874        >>> e.getActiveDecisions()
 7875        {1, 4}
 7876        >>> e.activateDomain('animals')
 7877        >>> e.getActiveDecisions()
 7878        {1, 4, 6, 7}
 7879        >>> e.setActiveDecisionsAtStep(0, 'animals', {8})
 7880        >>> e.getActiveDecisions()
 7881        {8, 1, 4}
 7882        >>> e.setActiveDecisionsAtStep(1, 'main', 2)  # invalid step
 7883        Traceback (most recent call last):
 7884        ...
 7885        IndexError...
 7886        >>> e.setActiveDecisionsAtStep(0, 'novel', 0)  # domain mismatch
 7887        Traceback (most recent call last):
 7888        ...
 7889        ValueError...
 7890
 7891        Example of active/common contexts:
 7892
 7893        >>> e = DiscreteExploration()
 7894        >>> graph = e.getSituation().graph
 7895        >>> graph.addDecision('A')
 7896        0
 7897        >>> graph.addDecision('B')
 7898        1
 7899        >>> e.activateDomain('main', inContext="common")
 7900        >>> e.setActiveDecisionsAtStep(0, 'main', 0, inCommon=True)
 7901        >>> e.getActiveDecisions()
 7902        {0}
 7903        >>> e.setActiveDecisionsAtStep(0, 'main', None)
 7904        >>> e.getActiveDecisions()
 7905        {0}
 7906        >>> # (Still active since it's active in the common context)
 7907        >>> e.setActiveDecisionsAtStep(0, 'main', 1)
 7908        >>> e.getActiveDecisions()
 7909        {0, 1}
 7910        >>> e.setActiveDecisionsAtStep(0, 'main', 1, inCommon=True)
 7911        >>> e.getActiveDecisions()
 7912        {1}
 7913        >>> e.setActiveDecisionsAtStep(0, 'main', None, inCommon=True)
 7914        >>> e.getActiveDecisions()
 7915        {1}
 7916        >>> # (Still active since it's active in the active context)
 7917        >>> e.setActiveDecisionsAtStep(0, 'main', None)
 7918        >>> e.getActiveDecisions()
 7919        set()
 7920        """
 7921        now = self.getSituation(step)
 7922        graph = now.graph
 7923        if inCommon:
 7924            context = self.getCommonContext(step)
 7925        else:
 7926            context = self.getActiveContext(step)
 7927
 7928        defaultFocalization: base.DomainFocalization = 'singular'
 7929        if isinstance(activate, base.DecisionID):
 7930            defaultFocalization = 'singular'
 7931        elif isinstance(activate, dict):
 7932            defaultFocalization = 'plural'
 7933        elif isinstance(activate, set):
 7934            defaultFocalization = 'spreading'
 7935        elif domain not in context['focalization']:
 7936            raise TypeError(
 7937                f"Domain {domain!r} has no focalization in the"
 7938                f" {'common' if inCommon else 'active'} context,"
 7939                f" and the specified position doesn't imply one."
 7940            )
 7941
 7942        focalization = base.getDomainFocalization(
 7943            context,
 7944            domain,
 7945            defaultFocalization
 7946        )
 7947
 7948        # Check domain & existence of decision(s) in question
 7949        if activate is None:
 7950            pass
 7951        elif isinstance(activate, base.DecisionID):
 7952            if activate not in graph:
 7953                raise MissingDecisionError(
 7954                    f"There is no decision {activate} at step {step}."
 7955                )
 7956            if graph.domainFor(activate) != domain:
 7957                raise ValueError(
 7958                    f"Can't set active decisions in domain {domain!r}"
 7959                    f" to decision {graph.identityOf(activate)} because"
 7960                    f" that decision is in actually in domain"
 7961                    f" {graph.domainFor(activate)!r}."
 7962                )
 7963        elif isinstance(activate, dict):
 7964            for fpName, pos in activate.items():
 7965                if pos is None:
 7966                    continue
 7967                if pos not in graph:
 7968                    raise MissingDecisionError(
 7969                        f"There is no decision {pos} at step {step}."
 7970                    )
 7971                if graph.domainFor(pos) != domain:
 7972                    raise ValueError(
 7973                        f"Can't set active decision for focal point"
 7974                        f" {fpName!r} in domain {domain!r}"
 7975                        f" to decision {graph.identityOf(pos)} because"
 7976                        f" that decision is in actually in domain"
 7977                        f" {graph.domainFor(pos)!r}."
 7978                    )
 7979        elif isinstance(activate, set):
 7980            for pos in activate:
 7981                if pos not in graph:
 7982                    raise MissingDecisionError(
 7983                        f"There is no decision {pos} at step {step}."
 7984                    )
 7985                if graph.domainFor(pos) != domain:
 7986                    raise ValueError(
 7987                        f"Can't set {graph.identityOf(pos)} as an"
 7988                        f" active decision in domain {domain!r} to"
 7989                        f" decision because that decision is in"
 7990                        f" actually in domain {graph.domainFor(pos)!r}."
 7991                    )
 7992        else:
 7993            raise TypeError(
 7994                f"Domain {domain!r} has no focalization in the"
 7995                f" {'common' if inCommon else 'active'} context,"
 7996                f" and the specified position doesn't imply one:"
 7997                f"\n{activate!r}"
 7998            )
 7999
 8000        if focalization == 'singular':
 8001            if activate is None or isinstance(activate, base.DecisionID):
 8002                if activate is not None:
 8003                    targetDomain = graph.domainFor(activate)
 8004                    if activate not in graph:
 8005                        raise MissingDecisionError(
 8006                            f"There is no decision {activate} in the"
 8007                            f" graph at step {step}."
 8008                        )
 8009                    elif targetDomain != domain:
 8010                        raise ValueError(
 8011                            f"At step {step}, decision {activate} cannot"
 8012                            f" be the active decision for domain"
 8013                            f" {repr(domain)} because it is in a"
 8014                            f" different domain ({repr(targetDomain)})."
 8015                        )
 8016                context['activeDecisions'][domain] = activate
 8017            else:
 8018                raise TypeError(
 8019                    f"{'Common' if inCommon else 'Active'} focal"
 8020                    f" context at step {step} has {repr(focalization)}"
 8021                    f" focalization for domain {repr(domain)}, so the"
 8022                    f" active decision must be a single decision or"
 8023                    f" None.\n(You provided: {repr(activate)})"
 8024                )
 8025        elif focalization == 'plural':
 8026            if (
 8027                isinstance(activate, dict)
 8028            and all(
 8029                    isinstance(k, base.FocalPointName)
 8030                    for k in activate.keys()
 8031                )
 8032            and all(
 8033                    v is None or isinstance(v, base.DecisionID)
 8034                    for v in activate.values()
 8035                )
 8036            ):
 8037                for v in activate.values():
 8038                    if v is not None:
 8039                        targetDomain = graph.domainFor(v)
 8040                        if v not in graph:
 8041                            raise MissingDecisionError(
 8042                                f"There is no decision {v} in the graph"
 8043                                f" at step {step}."
 8044                            )
 8045                        elif targetDomain != domain:
 8046                            raise ValueError(
 8047                                f"At step {step}, decision {activate}"
 8048                                f" cannot be an active decision for"
 8049                                f" domain {repr(domain)} because it is"
 8050                                f" in a different domain"
 8051                                f" ({repr(targetDomain)})."
 8052                            )
 8053                context['activeDecisions'][domain] = activate
 8054            else:
 8055                raise TypeError(
 8056                    f"{'Common' if inCommon else 'Active'} focal"
 8057                    f" context at step {step} has {repr(focalization)}"
 8058                    f" focalization for domain {repr(domain)}, so the"
 8059                    f" active decision must be a dictionary mapping"
 8060                    f" focal point names to decision IDs (or Nones)."
 8061                    f"\n(You provided: {repr(activate)})"
 8062                )
 8063        elif focalization == 'spreading':
 8064            if (
 8065                isinstance(activate, set)
 8066            and all(isinstance(x, base.DecisionID) for x in activate)
 8067            ):
 8068                for x in activate:
 8069                    targetDomain = graph.domainFor(x)
 8070                    if x not in graph:
 8071                        raise MissingDecisionError(
 8072                            f"There is no decision {x} in the graph"
 8073                            f" at step {step}."
 8074                        )
 8075                    elif targetDomain != domain:
 8076                        raise ValueError(
 8077                            f"At step {step}, decision {activate}"
 8078                            f" cannot be an active decision for"
 8079                            f" domain {repr(domain)} because it is"
 8080                            f" in a different domain"
 8081                            f" ({repr(targetDomain)})."
 8082                        )
 8083                context['activeDecisions'][domain] = activate
 8084            else:
 8085                raise TypeError(
 8086                    f"{'Common' if inCommon else 'Active'} focal"
 8087                    f" context at step {step} has {repr(focalization)}"
 8088                    f" focalization for domain {repr(domain)}, so the"
 8089                    f" active decision must be a set of decision IDs"
 8090                    f"\n(You provided: {repr(activate)})"
 8091                )
 8092        else:
 8093            raise RuntimeError(
 8094                f"Invalid focalization value {repr(focalization)} for"
 8095                f" domain {repr(domain)} at step {step}."
 8096            )
 8097
 8098    def movementAtStep(self, step: int = -1) -> Tuple[
 8099        Union[base.DecisionID, Set[base.DecisionID], None],
 8100        Optional[base.Transition],
 8101        Union[base.DecisionID, Set[base.DecisionID], None]
 8102    ]:
 8103        """
 8104        Given a step number, returns information about the starting
 8105        decision, transition taken, and destination decision for that
 8106        step. Not all steps have all of those, so some items may be
 8107        `None`.
 8108
 8109        For steps where there is no action, where a decision is still
 8110        pending, or where the action type is 'focus', 'swap', 'focalize',
 8111        or 'revertTo', the result will be `(None, None, None)`, unless a
 8112        primary decision is available in which case the first item in the
 8113        tuple will be that decision. For 'start' actions, the starting
 8114        position and transition will be `None` (again unless the step had
 8115        a primary decision) but the destination will be the ID of the
 8116        node started at. For 'revertTo' actions, the destination will be
 8117        the primary decision of the state reverted to, if available.
 8118
 8119        Also, if the action taken has multiple potential or actual start
 8120        or end points, these may be sets of decision IDs instead of
 8121        single IDs.
 8122
 8123        Note that the primary decision of the starting state is usually
 8124        used as the from-decision, but in some cases an action dictates
 8125        taking a transition from a different decision, and this function
 8126        will return that decision as the from-decision.
 8127
 8128        TODO: Examples!
 8129
 8130        TODO: Account for bounce/follow/goto effects!!!
 8131        """
 8132        now = self.getSituation(step)
 8133        action = now.action
 8134        graph = now.graph
 8135        primary = now.state['primaryDecision']
 8136
 8137        if action is None:
 8138            return (primary, None, None)
 8139
 8140        aType = action[0]
 8141        fromID: Optional[base.DecisionID]
 8142        destID: Optional[base.DecisionID]
 8143        transition: base.Transition
 8144        outcomes: List[bool]
 8145
 8146        if aType in ('noAction', 'focus', 'swap', 'focalize'):
 8147            return (primary, None, None)
 8148        elif aType == 'start':
 8149            assert len(action) == 7
 8150            where = cast(
 8151                Union[
 8152                    base.DecisionID,
 8153                    Dict[base.FocalPointName, base.DecisionID],
 8154                    Set[base.DecisionID]
 8155                ],
 8156                action[1]
 8157            )
 8158            if isinstance(where, dict):
 8159                where = set(where.values())
 8160            return (primary, None, where)
 8161        elif aType in ('take', 'explore'):
 8162            if (
 8163                (len(action) == 4 or len(action) == 7)
 8164            and isinstance(action[2], base.DecisionID)
 8165            ):
 8166                fromID = action[2]
 8167                assert isinstance(action[3], tuple)
 8168                transition, outcomes = action[3]
 8169                if (
 8170                    action[0] == "explore"
 8171                and isinstance(action[4], base.DecisionID)
 8172                ):
 8173                    destID = action[4]
 8174                else:
 8175                    destID = graph.getDestination(fromID, transition)
 8176                return (fromID, transition, destID)
 8177            elif (
 8178                (len(action) == 3 or len(action) == 6)
 8179            and isinstance(action[1], tuple)
 8180            and isinstance(action[2], base.Transition)
 8181            and len(action[1]) == 3
 8182            and action[1][0] in get_args(base.ContextSpecifier)
 8183            and isinstance(action[1][1], base.Domain)
 8184            and isinstance(action[1][2], base.FocalPointName)
 8185            ):
 8186                fromID = base.resolvePosition(now.state, action[1])
 8187                if fromID is None:
 8188                    raise InvalidActionError(
 8189                        f"{aType!r} action at step {step} has position"
 8190                        f" {action[1]!r} which cannot be resolved to a"
 8191                        f" decision."
 8192                    )
 8193                transition, outcomes = action[2]
 8194                if (
 8195                    action[0] == "explore"
 8196                and isinstance(action[3], base.DecisionID)
 8197                ):
 8198                    destID = action[3]
 8199                else:
 8200                    destID = graph.getDestination(fromID, transition)
 8201                return (fromID, transition, destID)
 8202            else:
 8203                raise InvalidActionError(
 8204                    f"Malformed {aType!r} action:\n{repr(action)}"
 8205                )
 8206        elif aType == 'warp':
 8207            if len(action) != 3:
 8208                raise InvalidActionError(
 8209                    f"Malformed 'warp' action:\n{repr(action)}"
 8210                )
 8211            dest = action[2]
 8212            assert isinstance(dest, base.DecisionID)
 8213            if action[1] in get_args(base.ContextSpecifier):
 8214                # Unspecified starting point; find active decisions in
 8215                # same domain if primary is None
 8216                if primary is not None:
 8217                    return (primary, None, dest)
 8218                else:
 8219                    toDomain = now.graph.domainFor(dest)
 8220                    # TODO: Could check destination focalization here...
 8221                    active = self.getActiveDecisions(step)
 8222                    sameDomain = set(
 8223                        dID
 8224                        for dID in active
 8225                        if now.graph.domainFor(dID) == toDomain
 8226                    )
 8227                    if len(sameDomain) == 1:
 8228                        return (
 8229                            list(sameDomain)[0],
 8230                            None,
 8231                            dest
 8232                        )
 8233                    else:
 8234                        return (
 8235                            sameDomain,
 8236                            None,
 8237                            dest
 8238                        )
 8239            else:
 8240                if (
 8241                    not isinstance(action[1], tuple)
 8242                or not len(action[1]) == 3
 8243                or not action[1][0] in get_args(base.ContextSpecifier)
 8244                or not isinstance(action[1][1], base.Domain)
 8245                or not isinstance(action[1][2], base.FocalPointName)
 8246                ):
 8247                    raise InvalidActionError(
 8248                        f"Malformed 'warp' action:\n{repr(action)}"
 8249                    )
 8250                return (
 8251                    base.resolvePosition(now.state, action[1]),
 8252                    None,
 8253                    dest
 8254                )
 8255        elif aType == 'revertTo':
 8256            assert len(action) == 3  # type, save slot, & aspects
 8257            if primary is not None:
 8258                cameFrom = primary
 8259            nextSituation = self.getSituation(step + 1)
 8260            wentTo = nextSituation.state['primaryDecision']
 8261            return (primary, None, wentTo)
 8262        else:
 8263            raise InvalidActionError(
 8264                f"Action taken had invalid action type {repr(aType)}:"
 8265                f"\n{repr(action)}"
 8266            )
 8267
 8268    def latestStepWithDecision(
 8269        self,
 8270        dID: base.DecisionID,
 8271        startFrom: int = -1
 8272    ) -> int:
 8273        """
 8274        Scans backwards through exploration steps until it finds a graph
 8275        that contains a decision with the specified ID, and returns the
 8276        step number of that step. Instead of starting from the last step,
 8277        you can tell it to start from a different step (either positive
 8278        or negative index) via `startFrom`. Raises a
 8279        `MissingDecisionError` if there is no such step.
 8280        """
 8281        if startFrom < 0:
 8282            startFrom = len(self) + startFrom
 8283        for step in range(startFrom, -1, -1):
 8284            graph = self.getSituation(step).graph
 8285            try:
 8286                return step
 8287            except MissingDecisionError:
 8288                continue
 8289        raise MissingDecisionError(
 8290            f"Decision {dID!r} does not exist at any step of the"
 8291            f" exploration."
 8292        )
 8293
 8294    def latestDecisionInfo(self, dID: base.DecisionID) -> DecisionInfo:
 8295        """
 8296        Looks up decision info for the given decision in the latest step
 8297        in which that decision exists (which will usually be the final
 8298        exploration step, unless the decision was merged or otherwise
 8299        removed along the way). This will raise a `MissingDecisionError`
 8300        only if there is no step at which the specified decision exists.
 8301        """
 8302        for step in range(len(self) - 1, -1, -1):
 8303            graph = self.getSituation(step).graph
 8304            try:
 8305                return graph.decisionInfo(dID)
 8306            except MissingDecisionError:
 8307                continue
 8308        raise MissingDecisionError(
 8309            f"Decision {dID!r} does not exist at any step of the"
 8310            f" exploration."
 8311        )
 8312
 8313    def latestTransitionProperties(
 8314        self,
 8315        dID: base.DecisionID,
 8316        transition: base.Transition
 8317    ) -> TransitionProperties:
 8318        """
 8319        Looks up transition properties for the transition with the given
 8320        name outgoing from the decision with the given ID, in the latest
 8321        step in which a transiiton with that name from that decision
 8322        exists (which will usually be the final exploration step, unless
 8323        transitions get removed/renamed along the way). Note that because
 8324        a transition can be deleted and later added back (unlike
 8325        decisions where an ID will not be re-used), it's possible there
 8326        are two or more different transitions that meet the
 8327        specifications at different points in time, and this will always
 8328        return the properties of the last of them. This will raise a
 8329        `MissingDecisionError` if there is no step at which the specified
 8330        decision exists, and a `MissingTransitionError` if the target
 8331        decision exists at some step but never has a transition with the
 8332        specified name.
 8333        """
 8334        sawDecision: Optional[int] = None
 8335        for step in range(len(self) - 1, -1, -1):
 8336            graph = self.getSituation(step).graph
 8337            try:
 8338                return graph.getTransitionProperties(dID, transition)
 8339            except (MissingDecisionError, MissingTransitionError) as e:
 8340                if (
 8341                    sawDecision is None
 8342                and isinstance(e, MissingTransitionError)
 8343                ):
 8344                    sawDecision = step
 8345                continue
 8346        if sawDecision is None:
 8347            raise MissingDecisionError(
 8348                f"Decision {dID!r} does not exist at any step of the"
 8349                f" exploration."
 8350            )
 8351        else:
 8352            raise MissingTransitionError(
 8353                f"Decision {dID!r} does exist (last seen at step"
 8354                f" {sawDecision}) but it never has an outgoing"
 8355                f" transition named {transition!r}."
 8356            )
 8357
 8358    def tagStep(
 8359        self,
 8360        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
 8361        tagValue: Union[
 8362            base.TagValue,
 8363            type[base.NoTagValue]
 8364        ] = base.NoTagValue,
 8365        step: int = -1
 8366    ) -> None:
 8367        """
 8368        Adds a tag (or multiple tags) to the current step, or to a
 8369        specific step if `n` is given as an integer rather than the
 8370        default `None`. A tag value should be supplied when a tag is
 8371        given (unless you want to use the default of `1`), but it's a
 8372        `ValueError` to supply a tag value when a dictionary of tags to
 8373        update is provided.
 8374        """
 8375        if isinstance(tagOrTags, base.Tag):
 8376            if tagValue is base.NoTagValue:
 8377                tagValue = 1
 8378
 8379            # Not sure why this is necessary...
 8380            tagValue = cast(base.TagValue, tagValue)
 8381
 8382            self.getSituation(step).tags.update({tagOrTags: tagValue})
 8383        else:
 8384            self.getSituation(step).tags.update(tagOrTags)
 8385
 8386    def annotateStep(
 8387        self,
 8388        annotationOrAnnotations: Union[
 8389            base.Annotation,
 8390            Sequence[base.Annotation]
 8391        ],
 8392        step: Optional[int] = None
 8393    ) -> None:
 8394        """
 8395        Adds an annotation to the current exploration step, or to a
 8396        specific step if `n` is given as an integer rather than the
 8397        default `None`.
 8398        """
 8399        if step is None:
 8400            step = -1
 8401        if isinstance(annotationOrAnnotations, base.Annotation):
 8402            self.getSituation(step).annotations.append(
 8403                annotationOrAnnotations
 8404            )
 8405        else:
 8406            self.getSituation(step).annotations.extend(
 8407                annotationOrAnnotations
 8408            )
 8409
 8410    def hasCapability(
 8411        self,
 8412        capability: base.Capability,
 8413        step: Optional[int] = None,
 8414        inCommon: Union[bool, Literal['both']] = "both"
 8415    ) -> bool:
 8416        """
 8417        Returns True if the player currently had the specified
 8418        capability, at the specified exploration step, and False
 8419        otherwise. Checks the current state if no step is given. Does
 8420        NOT return true if the game state means that the player has an
 8421        equivalent for that capability (see
 8422        `hasCapabilityOrEquivalent`).
 8423
 8424        Normally, `inCommon` is set to 'both' by default and so if
 8425        either the common `FocalContext` or the active one has the
 8426        capability, this will return `True`. `inCommon` may instead be
 8427        set to `True` or `False` to ask about just the common (or
 8428        active) focal context.
 8429        """
 8430        state = self.getSituation().state
 8431        commonCapabilities = state['common']['capabilities']\
 8432            ['capabilities']  # noqa
 8433        activeCapabilities = state['contexts'][state['activeContext']]\
 8434            ['capabilities']['capabilities']  # noqa
 8435
 8436        if inCommon == 'both':
 8437            return (
 8438                capability in commonCapabilities
 8439             or capability in activeCapabilities
 8440            )
 8441        elif inCommon is True:
 8442            return capability in commonCapabilities
 8443        elif inCommon is False:
 8444            return capability in activeCapabilities
 8445        else:
 8446            raise ValueError(
 8447                f"Invalid inCommon value (must be False, True, or"
 8448                f" 'both'; got {repr(inCommon)})."
 8449            )
 8450
 8451    def hasCapabilityOrEquivalent(
 8452        self,
 8453        capability: base.Capability,
 8454        step: Optional[int] = None,
 8455        location: Optional[Set[base.DecisionID]] = None
 8456    ) -> bool:
 8457        """
 8458        Works like `hasCapability`, but also returns `True` if the
 8459        player counts as having the specified capability via an equivalence
 8460        that's part of the current graph. As with `hasCapability`, the
 8461        optional `step` argument is used to specify which step to check,
 8462        with the current step being used as the default.
 8463
 8464        The `location` set can specify where to start looking for
 8465        mechanisms; if left unspecified active decisions for that step
 8466        will be used.
 8467        """
 8468        if step is None:
 8469            step = -1
 8470        if location is None:
 8471            location = self.getActiveDecisions(step)
 8472        situation = self.getSituation(step)
 8473        return base.hasCapabilityOrEquivalent(
 8474            capability,
 8475            base.RequirementContext(
 8476                state=situation.state,
 8477                graph=situation.graph,
 8478                searchFrom=location
 8479            )
 8480        )
 8481
 8482    def gainCapabilityNow(
 8483        self,
 8484        capability: base.Capability,
 8485        inCommon: bool = False
 8486    ) -> None:
 8487        """
 8488        Modifies the current game state to add the specified `Capability`
 8489        to the player's capabilities. No changes are made to the current
 8490        graph.
 8491
 8492        If `inCommon` is set to `True` (default is `False`) then the
 8493        capability will be added to the common `FocalContext` and will
 8494        therefore persist even when a focal context switch happens.
 8495        Normally, it will be added to the currently-active focal
 8496        context.
 8497        """
 8498        state = self.getSituation().state
 8499        if inCommon:
 8500            context = state['common']
 8501        else:
 8502            context = state['contexts'][state['activeContext']]
 8503        context['capabilities']['capabilities'].add(capability)
 8504
 8505    def loseCapabilityNow(
 8506        self,
 8507        capability: base.Capability,
 8508        inCommon: Union[bool, Literal['both']] = "both"
 8509    ) -> None:
 8510        """
 8511        Modifies the current game state to remove the specified `Capability`
 8512        from the player's capabilities. Does nothing if the player
 8513        doesn't already have that capability.
 8514
 8515        By default, this removes the capability from both the common
 8516        capabilities set and the active `FocalContext`'s capabilities
 8517        set, so that afterwards the player will definitely not have that
 8518        capability. However, if you set `inCommon` to either `True` or
 8519        `False`, it will remove the capability from just the common
 8520        capabilities set (if `True`) or just the active capabilities set
 8521        (if `False`). In these cases, removing the capability from just
 8522        one capability set will not actually remove it in terms of the
 8523        `hasCapability` result if it had been present in the other set.
 8524        Set `inCommon` to "both" to use the default behavior explicitly.
 8525        """
 8526        now = self.getSituation()
 8527        if inCommon in ("both", True):
 8528            context = now.state['common']
 8529            try:
 8530                context['capabilities']['capabilities'].remove(capability)
 8531            except KeyError:
 8532                pass
 8533        elif inCommon in ("both", False):
 8534            context = now.state['contexts'][now.state['activeContext']]
 8535            try:
 8536                context['capabilities']['capabilities'].remove(capability)
 8537            except KeyError:
 8538                pass
 8539        else:
 8540            raise ValueError(
 8541                f"Invalid inCommon value (must be False, True, or"
 8542                f" 'both'; got {repr(inCommon)})."
 8543            )
 8544
 8545    def tokenCountNow(self, tokenType: base.Token) -> Optional[int]:
 8546        """
 8547        Returns the number of tokens the player currently has of a given
 8548        type. Returns `None` if the player has never acquired or lost
 8549        tokens of that type.
 8550
 8551        This method adds together tokens from the common and active
 8552        focal contexts.
 8553        """
 8554        state = self.getSituation().state
 8555        commonContext = state['common']
 8556        activeContext = state['contexts'][state['activeContext']]
 8557        base = commonContext['capabilities']['tokens'].get(tokenType)
 8558        if base is None:
 8559            return activeContext['capabilities']['tokens'].get(tokenType)
 8560        else:
 8561            return base + activeContext['capabilities']['tokens'].get(
 8562                tokenType,
 8563                0
 8564            )
 8565
 8566    def adjustTokensNow(
 8567        self,
 8568        tokenType: base.Token,
 8569        amount: int,
 8570        inCommon: bool = False
 8571    ) -> None:
 8572        """
 8573        Modifies the current game state to add the specified number of
 8574        `Token`s of the given type to the player's tokens. No changes are
 8575        made to the current graph. Reduce the number of tokens by
 8576        supplying a negative amount; note that negative token amounts
 8577        are possible.
 8578
 8579        By default, the number of tokens for the current active
 8580        `FocalContext` will be adjusted. However, if `inCommon` is set
 8581        to `True`, then the number of tokens for the common context will
 8582        be adjusted instead.
 8583        """
 8584        # TODO: Custom token caps!
 8585        state = self.getSituation().state
 8586        if inCommon:
 8587            context = state['common']
 8588        else:
 8589            context = state['contexts'][state['activeContext']]
 8590        tokens = context['capabilities']['tokens']
 8591        tokens[tokenType] = tokens.get(tokenType, 0) + amount
 8592
 8593    def setTokensNow(
 8594        self,
 8595        tokenType: base.Token,
 8596        amount: int,
 8597        inCommon: bool = False
 8598    ) -> None:
 8599        """
 8600        Modifies the current game state to set number of `Token`s of the
 8601        given type to a specific amount, regardless of the old value. No
 8602        changes are made to the current graph.
 8603
 8604        By default this sets the number of tokens for the active
 8605        `FocalContext`. But if you set `inCommon` to `True`, it will
 8606        set the number of tokens in the common context instead.
 8607        """
 8608        # TODO: Custom token caps!
 8609        state = self.getSituation().state
 8610        if inCommon:
 8611            context = state['common']
 8612        else:
 8613            context = state['contexts'][state['activeContext']]
 8614        context['capabilities']['tokens'][tokenType] = amount
 8615
 8616    def lookupMechanism(
 8617        self,
 8618        mechanism: base.MechanismName,
 8619        step: Optional[int] = None,
 8620        where: Union[
 8621            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]],
 8622            Collection[base.AnyDecisionSpecifier],
 8623            None
 8624        ] = None
 8625    ) -> base.MechanismID:
 8626        """
 8627        Looks up a mechanism ID by name, in the graph for the specified
 8628        step. The `where` argument specifies where to start looking,
 8629        which helps disambiguate. It can be a tuple with a decision
 8630        specifier and `None` to start from a single decision, or with a
 8631        decision specifier and a transition name to start from either
 8632        end of that transition. It can also be `None` to look at global
 8633        mechanisms and then all decisions directly, although this
 8634        increases the chance of a `AmbiguousMechanismError`. Finally, it
 8635        can be some other non-tuple collection of decision specifiers to
 8636        start from that set.
 8637
 8638        If no step is specified, uses the current step.
 8639        """
 8640        if step is None:
 8641            step = -1
 8642        situation = self.getSituation(step)
 8643        graph = situation.graph
 8644        searchFrom: Collection[base.AnyDecisionSpecifier]
 8645        if where is None:
 8646            searchFrom = set()
 8647        elif isinstance(where, tuple):
 8648            if len(where) != 2:
 8649                raise ValueError(
 8650                    f"Mechanism lookup location was a tuple with an"
 8651                    f" invalid length (must be length-2 if it's a"
 8652                    f" tuple):\n  {repr(where)}"
 8653                )
 8654            where = cast(
 8655                Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]],
 8656                where
 8657            )
 8658            if where[1] is None:
 8659                searchFrom = {graph.resolveDecision(where[0])}
 8660            else:
 8661                searchFrom = graph.bothEnds(where[0], where[1])
 8662        else:  # must be a collection of specifiers
 8663            searchFrom = cast(Collection[base.AnyDecisionSpecifier], where)
 8664        return graph.lookupMechanism(searchFrom, mechanism)
 8665
 8666    def mechanismState(
 8667        self,
 8668        mechanism: base.AnyMechanismSpecifier,
 8669        where: Optional[Set[base.DecisionID]] = None,
 8670        step: int = -1
 8671    ) -> Optional[base.MechanismState]:
 8672        """
 8673        Returns the current state for the specified mechanism (or the
 8674        state at the specified step if a step index is given). `where`
 8675        may be provided as a set of decision IDs to indicate where to
 8676        search for the named mechanism, or a mechanism ID may be provided
 8677        in the first place. Mechanism states are properties of a `State`
 8678        but are not associated with focal contexts.
 8679        """
 8680        situation = self.getSituation(step)
 8681        mID = situation.graph.resolveMechanism(mechanism, startFrom=where)
 8682        return situation.state['mechanisms'].get(
 8683            mID,
 8684            base.DEFAULT_MECHANISM_STATE
 8685        )
 8686
 8687    def setMechanismStateNow(
 8688        self,
 8689        mechanism: base.AnyMechanismSpecifier,
 8690        toState: base.MechanismState,
 8691        where: Optional[Set[base.DecisionID]] = None
 8692    ) -> None:
 8693        """
 8694        Sets the state of the specified mechanism to the specified
 8695        state. Mechanisms can only be in one state at once, so this
 8696        removes any previous states for that mechanism (note that via
 8697        equivalences multiple mechanism states can count as active).
 8698
 8699        The mechanism can be any kind of mechanism specifier (see
 8700        `base.AnyMechanismSpecifier`). If it's not a mechanism ID and
 8701        doesn't have its own position information, the 'where' argument
 8702        can be used to hint where to search for the mechanism.
 8703        """
 8704        now = self.getSituation()
 8705        mID = now.graph.resolveMechanism(mechanism, startFrom=where)
 8706        now.state['mechanisms'][mID] = toState
 8707
 8708    def skillLevel(
 8709        self,
 8710        skill: base.Skill,
 8711        step: Optional[int] = None
 8712    ) -> Optional[base.Level]:
 8713        """
 8714        Returns the skill level the player had in a given skill at a
 8715        given step, or for the current step if no step is specified.
 8716        Returns `None` if the player had never acquired or lost levels
 8717        in that skill before the specified step (skill level would count
 8718        as 0 in that case).
 8719
 8720        This method adds together levels from the common and active
 8721        focal contexts.
 8722        """
 8723        if step is None:
 8724            step = -1
 8725        state = self.getSituation(step).state
 8726        commonContext = state['common']
 8727        activeContext = state['contexts'][state['activeContext']]
 8728        base = commonContext['capabilities']['skills'].get(skill)
 8729        if base is None:
 8730            return activeContext['capabilities']['skills'].get(skill)
 8731        else:
 8732            return base + activeContext['capabilities']['skills'].get(
 8733                skill,
 8734                0
 8735            )
 8736
 8737    def adjustSkillLevelNow(
 8738        self,
 8739        skill: base.Skill,
 8740        levels: base.Level,
 8741        inCommon: bool = False
 8742    ) -> None:
 8743        """
 8744        Modifies the current game state to add the specified number of
 8745        `Level`s of the given skill. No changes are made to the current
 8746        graph. Reduce the skill level by supplying negative levels; note
 8747        that negative skill levels are possible.
 8748
 8749        By default, the skill level for the current active
 8750        `FocalContext` will be adjusted. However, if `inCommon` is set
 8751        to `True`, then the skill level for the common context will be
 8752        adjusted instead.
 8753        """
 8754        # TODO: Custom level caps?
 8755        state = self.getSituation().state
 8756        if inCommon:
 8757            context = state['common']
 8758        else:
 8759            context = state['contexts'][state['activeContext']]
 8760        skills = context['capabilities']['skills']
 8761        skills[skill] = skills.get(skill, 0) + levels
 8762
 8763    def setSkillLevelNow(
 8764        self,
 8765        skill: base.Skill,
 8766        level: base.Level,
 8767        inCommon: bool = False
 8768    ) -> None:
 8769        """
 8770        Modifies the current game state to set `Skill` `Level` for the
 8771        given skill, regardless of the old value. No changes are made to
 8772        the current graph.
 8773
 8774        By default this sets the skill level for the active
 8775        `FocalContext`. But if you set `inCommon` to `True`, it will set
 8776        the skill level in the common context instead.
 8777        """
 8778        # TODO: Custom level caps?
 8779        state = self.getSituation().state
 8780        if inCommon:
 8781            context = state['common']
 8782        else:
 8783            context = state['contexts'][state['activeContext']]
 8784        skills = context['capabilities']['skills']
 8785        skills[skill] = level
 8786
 8787    def updateRequirementNow(
 8788        self,
 8789        decision: base.AnyDecisionSpecifier,
 8790        transition: base.Transition,
 8791        requirement: Optional[base.Requirement]
 8792    ) -> None:
 8793        """
 8794        Updates the requirement for a specific transition in a specific
 8795        decision. Use `None` to remove the requirement for that edge.
 8796        """
 8797        if requirement is None:
 8798            requirement = base.ReqNothing()
 8799        self.getSituation().graph.setTransitionRequirement(
 8800            decision,
 8801            transition,
 8802            requirement
 8803        )
 8804
 8805    def isTraversable(
 8806        self,
 8807        decision: base.AnyDecisionSpecifier,
 8808        transition: base.Transition,
 8809        step: int = -1
 8810    ) -> bool:
 8811        """
 8812        Returns True if the specified transition from the specified
 8813        decision had its requirement satisfied by the game state at the
 8814        specified step (or at the current step if no step is specified).
 8815        Raises an `IndexError` if the specified step doesn't exist, and
 8816        a `KeyError` if the decision or transition specified does not
 8817        exist in the `DecisionGraph` at that step.
 8818        """
 8819        situation = self.getSituation(step)
 8820        req = situation.graph.getTransitionRequirement(decision, transition)
 8821        ctx = base.contextForTransition(situation, decision, transition)
 8822        fromID = situation.graph.resolveDecision(decision)
 8823        return (
 8824            req.satisfied(ctx)
 8825        and (fromID, transition) not in situation.state['deactivated']
 8826        )
 8827
 8828    def applyTransitionEffect(
 8829        self,
 8830        whichEffect: base.EffectSpecifier,
 8831        moveWhich: Optional[base.FocalPointName] = None
 8832    ) -> Optional[base.DecisionID]:
 8833        """
 8834        Applies an effect attached to a transition, taking charges and
 8835        delay into account based on the current `Situation`.
 8836        Modifies the effect's trigger count (but may not actually
 8837        trigger the effect if the charges and/or delay values indicate
 8838        not to; see `base.doTriggerEffect`).
 8839
 8840        If a specific focal point in a plural-focalized domain is
 8841        triggering the effect, the focal point name should be specified
 8842        via `moveWhich` so that goto `Effect`s can know which focal
 8843        point to move when it's not explicitly specified in the effect.
 8844        TODO: Test this!
 8845
 8846        Returns None most of the time, but if a 'goto', 'bounce', or
 8847        'follow' effect was applied, it returns the decision ID for that
 8848        effect's destination, which would override a transition's normal
 8849        destination. If it returns a destination ID, then the exploration
 8850        state will already have been updated to set the position there,
 8851        and further position updates are not needed.
 8852
 8853        Note that transition effects which update active decisions will
 8854        also update the exploration status of those decisions to
 8855        'exploring' if they had been in an unvisited status (see
 8856        `updatePosition` and `hasBeenVisited`).
 8857
 8858        Note: callers should immediately update situation-based variables
 8859        that might have been changes by a 'revert' effect.
 8860        """
 8861        now = self.getSituation()
 8862        effect, triggerCount = base.doTriggerEffect(
 8863            now.state,
 8864            now.graph,
 8865            whichEffect
 8866        )
 8867        if triggerCount is not None:
 8868            return self.applyExtraneousEffect(
 8869                effect,
 8870                where=whichEffect[:2],
 8871                moveWhich=moveWhich
 8872            )
 8873        else:
 8874            return None
 8875
 8876    def applyExtraneousEffect(
 8877        self,
 8878        effect: base.Effect,
 8879        where: Optional[
 8880            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]]
 8881        ] = None,
 8882        moveWhich: Optional[base.FocalPointName] = None,
 8883        challengePolicy: base.ChallengePolicy = "specified"
 8884    ) -> Optional[base.DecisionID]:
 8885        """
 8886        Applies a single extraneous effect to the state & graph,
 8887        *without* accounting for charges or delay values, since the
 8888        effect is not part of the graph (use `applyTransitionEffect` to
 8889        apply effects that are attached to transitions, which is almost
 8890        always the function you should be using). An associated
 8891        transition for the extraneous effect can be supplied using the
 8892        `where` argument, and effects like 'deactivate' and 'edit' will
 8893        affect it (but the effect's charges and delay values will still
 8894        be ignored).
 8895
 8896        If the effect would change the destination of a transition, the
 8897        altered destination ID is returned: 'bounce' effects return the
 8898        provided decision part of `where`, 'goto' effects return their
 8899        target, and 'follow' effects return the destination followed to
 8900        (possibly via chained follows in the extreme case). In all other
 8901        cases, `None` is returned indicating no change to a normal
 8902        destination.
 8903
 8904        If a specific focal point in a plural-focalized domain is
 8905        triggering the effect, the focal point name should be specified
 8906        via `moveWhich` so that goto `Effect`s can know which focal
 8907        point to move when it's not explicitly specified in the effect.
 8908        TODO: Test this!
 8909
 8910        Note that transition effects which update active decisions will
 8911        also update the exploration status of those decisions to
 8912        'exploring' if they had been in an unvisited status and will
 8913        remove any 'unconfirmed' tag they might still have (see
 8914        `updatePosition` and `hasBeenVisited`).
 8915
 8916        The given `challengePolicy` is applied when traversing further
 8917        transitions due to 'follow' effects.
 8918
 8919        Note: Anyone calling `applyExtraneousEffect` should update any
 8920        situation-based variables immediately after the call, as a
 8921        'revert' effect may have changed the current graph and/or state.
 8922        """
 8923        typ = effect['type']
 8924        value = effect['value']
 8925        applyTo = effect['applyTo']
 8926        inCommon = applyTo == 'common'
 8927
 8928        now = self.getSituation()
 8929
 8930        if where is not None:
 8931            if where[1] is not None:
 8932                searchFrom = now.graph.bothEnds(where[0], where[1])
 8933            else:
 8934                searchFrom = {now.graph.resolveDecision(where[0])}
 8935        else:
 8936            searchFrom = None
 8937
 8938        # Note: Delay and charges are ignored!
 8939
 8940        # If it's a simple effect, we can use
 8941        # `base.applySimpleEffectToState` to apply it:
 8942        if base.isSimple(effect):
 8943            # TODO: NOT THIS, since it applies only simple effects of
 8944            # followed transitions!!!
 8945            return base.applySimpleEffectToState(
 8946                now.state,
 8947                now.graph,
 8948                effect,
 8949                where,
 8950                moveWhich,
 8951                challengePolicy
 8952            )
 8953        else:
 8954            # TODO: HERE
 8955            if typ == "edit":
 8956                value = cast(List[List[commands.Command]], value)
 8957                # If there are no blocks, do nothing
 8958                if len(value) > 0:
 8959                    # Apply the first block of commands and then rotate the list
 8960                    scope: commands.Scope = {}
 8961                    if where is not None:
 8962                        here: base.DecisionID = now.graph.resolveDecision(
 8963                            where[0]
 8964                        )
 8965                        outwards: Optional[base.Transition] = where[1]
 8966                        scope['@'] = here
 8967                        scope['@t'] = outwards
 8968                        if outwards is not None:
 8969                            reciprocal = now.graph.getReciprocal(
 8970                                here,
 8971                                outwards
 8972                            )
 8973                            destination = now.graph.getDestination(
 8974                                here,
 8975                                outwards
 8976                            )
 8977                        else:
 8978                            reciprocal = None
 8979                            destination = None
 8980                        scope['@r'] = reciprocal
 8981                        scope['@d'] = destination
 8982                    self.runCommandBlock(value[0], scope)
 8983                    value.append(value.pop(0))
 8984
 8985            elif typ == "follow":
 8986                # TODO: Maybe this should remain a non-complex effect?
 8987                if applyTo == "both":
 8988                    raise ValueError(
 8989                        "Can't follow a transition in both common & active"
 8990                        " focal contexts."
 8991                    )
 8992
 8993                if where is None:
 8994                    raise ValueError(
 8995                        f"Can't follow transition {value!r} because there"
 8996                        f" is no position information when applying the"
 8997                        f" effect."
 8998                    )
 8999
 9000                if where[1] is not None:
 9001                    followFrom = now.graph.getDestination(where[0], where[1])
 9002                    if followFrom is None:
 9003                        raise ValueError(
 9004                            f"Can't follow transition {value!r} because the"
 9005                            f" position information specifies transition"
 9006                            f" {where[1]!r} from decision"
 9007                            f" {now.graph.identityOf(where[0])} but that"
 9008                            f" transition does not exist."
 9009                        )
 9010
 9011                else:
 9012                    followFrom = now.graph.resolveDecision(where[0])
 9013
 9014                following = cast(base.Transition, value)
 9015
 9016                followTo = now.graph.getDestination(followFrom, following)
 9017
 9018                if followTo is None:
 9019                    raise ValueError(
 9020                        f"Can't follow transition {following!r} because"
 9021                        f" that transition doesn't exist at the specified"
 9022                        f" destination {now.graph.identityOf(followFrom)}."
 9023                    )
 9024
 9025                if self.isTraversable(followFrom, following):  # skip if not
 9026                    # Perform initial position update before following new
 9027                    # transition:
 9028                    base.updatePosition(
 9029                        now.state,
 9030                        now.graph,
 9031                        followFrom,
 9032                        applyTo,
 9033                        moveWhich
 9034                    )
 9035
 9036                    # Apply consequences of followed transition
 9037                    fullFollowTo = self.applyTransitionConsequence(
 9038                        followFrom,
 9039                        following,
 9040                        moveWhich,
 9041                        challengePolicy
 9042                    )
 9043
 9044                    # Now update to end of followed transition
 9045                    if fullFollowTo is None:
 9046                        base.updatePosition(
 9047                            now.state,
 9048                            now.graph,
 9049                            followTo,
 9050                            applyTo,
 9051                            moveWhich
 9052                        )
 9053                        fullFollowTo = followTo
 9054
 9055                    # Skip the normal update: we've taken care of that
 9056                    # plus more
 9057                    return fullFollowTo
 9058                else:
 9059                    # Normal position updates still applies since follow
 9060                    # transition wasn't possible
 9061                    return None
 9062
 9063            elif typ == "save":
 9064                assert isinstance(value, base.SaveSlot)
 9065                now.saves[value] = copy.deepcopy((now.graph, now.state))
 9066
 9067            else:
 9068                raise ValueError(f"Invalid effect type {typ!r}.")
 9069
 9070        return None  # default return value if we didn't return above
 9071
 9072    def applyExtraneousConsequence(
 9073        self,
 9074        consequence: base.Consequence,
 9075        where: Optional[
 9076            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]]
 9077        ] = None,
 9078        moveWhich: Optional[base.FocalPointName] = None
 9079    ) -> Optional[base.DecisionID]:
 9080        """
 9081        Applies an extraneous consequence not associated with a
 9082        transition. Unlike `applyTransitionConsequence`, the provided
 9083        `base.Consequence` must already have observed outcomes (see
 9084        `base.observeChallengeOutcomes`). Returns the decision ID for a
 9085        decision implied by a goto, follow, or bounce effect, or `None`
 9086        if no effect implies a destination.
 9087
 9088        The `where` and `moveWhich` optional arguments specify which
 9089        decision and/or transition to use as the application position,
 9090        and/or which focal point to move. This affects mechanism lookup
 9091        as well as the end position when 'follow' effects are used.
 9092        Specifically:
 9093
 9094        - A 'follow' trigger will search for transitions to follow from
 9095            the destination of the specified transition, or if only a
 9096            decision was supplied, from that decision.
 9097        - Mechanism lookups will start with both ends of the specified
 9098            transition as their search field (or with just the specified
 9099            decision if no transition is included).
 9100
 9101        'bounce' effects will cause an error unless position information
 9102        is provided, and will set the position to the base decision
 9103        provided in `where`.
 9104
 9105        Note: callers should update any situation-based variables
 9106        immediately after calling this as a 'revert' effect could change
 9107        the current graph and/or state and other changes could get lost
 9108        if they get applied to a stale graph/state.
 9109
 9110        # TODO: Examples for goto and follow effects.
 9111        """
 9112        now = self.getSituation()
 9113        searchFrom = set()
 9114        if where is not None:
 9115            if where[1] is not None:
 9116                searchFrom = now.graph.bothEnds(where[0], where[1])
 9117            else:
 9118                searchFrom = {now.graph.resolveDecision(where[0])}
 9119
 9120        context = base.RequirementContext(
 9121            state=now.state,
 9122            graph=now.graph,
 9123            searchFrom=searchFrom
 9124        )
 9125
 9126        effectIndices = base.observedEffects(context, consequence)
 9127        destID = None
 9128        for index in effectIndices:
 9129            effect = base.consequencePart(consequence, index)
 9130            if not isinstance(effect, dict) or 'value' not in effect:
 9131                raise RuntimeError(
 9132                    f"Invalid effect index {index}: Consequence part at"
 9133                    f" that index is not an Effect. Got:\n{effect}"
 9134                )
 9135            effect = cast(base.Effect, effect)
 9136            destID = self.applyExtraneousEffect(
 9137                effect,
 9138                where,
 9139                moveWhich
 9140            )
 9141            # technically this variable is not used later in this
 9142            # function, but the `applyExtraneousEffect` call means it
 9143            # needs an update, so we're doing that in case someone later
 9144            # adds code to this function that uses 'now' after this
 9145            # point.
 9146            now = self.getSituation()
 9147
 9148        return destID
 9149
 9150    def applyTransitionConsequence(
 9151        self,
 9152        decision: base.AnyDecisionSpecifier,
 9153        transition: base.AnyTransition,
 9154        moveWhich: Optional[base.FocalPointName] = None,
 9155        policy: base.ChallengePolicy = "specified",
 9156        fromIndex: Optional[int] = None,
 9157        toIndex: Optional[int] = None
 9158    ) -> Optional[base.DecisionID]:
 9159        """
 9160        Applies the effects of the specified transition to the current
 9161        graph and state, possibly overriding observed outcomes using
 9162        outcomes specified as part of a `base.TransitionWithOutcomes`.
 9163
 9164        The `where` and `moveWhich` function serve the same purpose as
 9165        for `applyExtraneousEffect`. If `where` is `None`, then the
 9166        effects will be applied as extraneous effects, meaning that
 9167        their delay and charges values will be ignored and their trigger
 9168        count will not be tracked. If `where` is supplied
 9169
 9170        Returns either None to indicate that the position update for the
 9171        transition should apply as usual, or a decision ID indicating
 9172        another destination which has already been applied by a
 9173        transition effect.
 9174
 9175        If `fromIndex` and/or `toIndex` are specified, then only effects
 9176        which have indices between those two (inclusive) will be
 9177        applied, and other effects will neither apply nor be updated in
 9178        any way. Note that `onlyPart` does not override the challenge
 9179        policy: if the effects in the specified part are not applied due
 9180        to a challenge outcome, they still won't happen, including
 9181        challenge outcomes outside of that part. Also, outcomes for
 9182        challenges of the entire consequence are re-observed if the
 9183        challenge policy implies it.
 9184
 9185        Note: Anyone calling this should update any situation-based
 9186        variables immediately after the call, as a 'revert' effect may
 9187        have changed the current graph and/or state.
 9188        """
 9189        now = self.getSituation()
 9190        dID = now.graph.resolveDecision(decision)
 9191
 9192        transitionName, outcomes = base.nameAndOutcomes(transition)
 9193
 9194        searchFrom = set()
 9195        searchFrom = now.graph.bothEnds(dID, transitionName)
 9196
 9197        context = base.RequirementContext(
 9198            state=now.state,
 9199            graph=now.graph,
 9200            searchFrom=searchFrom
 9201        )
 9202
 9203        consequence = now.graph.getConsequence(dID, transitionName)
 9204
 9205        # Make sure that challenge outcomes are known
 9206        if policy != "specified":
 9207            base.resetChallengeOutcomes(consequence)
 9208        useUp = outcomes[:]
 9209        base.observeChallengeOutcomes(
 9210            context,
 9211            consequence,
 9212            location=searchFrom,
 9213            policy=policy,
 9214            knownOutcomes=useUp
 9215        )
 9216        if len(useUp) > 0:
 9217            raise ValueError(
 9218                f"More outcomes specified than challenges observed in"
 9219                f" consequence:\n{consequence}"
 9220                f"\nRemaining outcomes:\n{useUp}"
 9221            )
 9222
 9223        # Figure out which effects apply, and apply each of them
 9224        effectIndices = base.observedEffects(context, consequence)
 9225        if fromIndex is None:
 9226            fromIndex = 0
 9227
 9228        altDest = None
 9229        for index in effectIndices:
 9230            if (
 9231                index >= fromIndex
 9232            and (toIndex is None or index <= toIndex)
 9233            ):
 9234                thisDest = self.applyTransitionEffect(
 9235                    (dID, transitionName, index),
 9236                    moveWhich
 9237                )
 9238                if thisDest is not None:
 9239                    altDest = thisDest
 9240                # TODO: What if this updates state with 'revert' to a
 9241                # graph that doesn't contain the same effects?
 9242                # TODO: Update 'now' and 'context'?!
 9243        return altDest
 9244
 9245    def allDecisions(self) -> List[base.DecisionID]:
 9246        """
 9247        Returns the list of all decisions which existed at any point
 9248        within the exploration. Example:
 9249
 9250        >>> ex = DiscreteExploration()
 9251        >>> ex.start('A')
 9252        0
 9253        >>> ex.observe('A', 'right')
 9254        1
 9255        >>> ex.explore('right', 'B', 'left')
 9256        1
 9257        >>> ex.observe('B', 'right')
 9258        2
 9259        >>> ex.allDecisions()  # 'A', 'B', and the unnamed 'right of B'
 9260        [0, 1, 2]
 9261        """
 9262        seen = set()
 9263        result = []
 9264        for situation in self:
 9265            for decision in situation.graph:
 9266                if decision not in seen:
 9267                    result.append(decision)
 9268                    seen.add(decision)
 9269
 9270        return result
 9271
 9272    def allExploredDecisions(self) -> List[base.DecisionID]:
 9273        """
 9274        Returns the list of all decisions which existed at any point
 9275        within the exploration, excluding decisions whose highest
 9276        exploration status was `noticed` or lower. May still include
 9277        decisions which don't exist in the final situation's graph due to
 9278        things like decision merging. Example:
 9279
 9280        >>> ex = DiscreteExploration()
 9281        >>> idA = ex.start('A')
 9282        >>> idB = ex.observe('A', 'right')
 9283        >>> ex.explore('right', 'B', 'left') == idB
 9284        True
 9285        >>> idU = ex.observe('B', 'right')
 9286        >>> graph = ex.getSituation().graph
 9287        >>> idC = graph.addDecision('C')  # add isolated decision;
 9288        >>>                               # doesn't set status
 9289        >>> ex.hasBeenVisited('C')
 9290        False
 9291        >>> ex.allExploredDecisions() == [idA, idB]
 9292        True
 9293        >>> ex.setExplorationStatus('C', 'exploring')
 9294        >>> ex.allExploredDecisions() == [idA, idB, idC]
 9295        True
 9296        >>> ex.setExplorationStatus('A', 'explored')
 9297        >>> ex.allExploredDecisions() == [idA, idB, idC]
 9298        True
 9299        >>> ex.setExplorationStatus('A', 'unknown')
 9300        >>> # remains visisted in an earlier step
 9301        >>> ex.allExploredDecisions() == [idA, idB, idC]
 9302        True
 9303        >>> ex.setExplorationStatus('C', 'unknown')  # not explored earlier
 9304        >>> ex.allExploredDecisions() == [idA, idB]
 9305        True
 9306        """
 9307        seen = set()
 9308        result = []
 9309        for situation in self:
 9310            graph = situation.graph
 9311            for decision in graph:
 9312                if (
 9313                    decision not in seen
 9314                and base.hasBeenVisited(situation.state, decision)
 9315                ):
 9316                    result.append(decision)
 9317                    seen.add(decision)
 9318
 9319        return result
 9320
 9321    def allVisitedDecisions(self) -> List[base.DecisionID]:
 9322        """
 9323        Returns the list of all decisions which existed at any point
 9324        within the exploration and which were visited at least once.
 9325        Orders them in the same order they were visited in.
 9326
 9327        Usually all of these decisions will be present in the final
 9328        situation's graph, but sometimes merging or other factors means
 9329        there might be some that won't be. Being present on the game
 9330        state's 'active' list in a step for its domain is what counts as
 9331        "being visited," which means that nodes which were passed through
 9332        directly via a 'follow' effect won't be counted, for example.
 9333
 9334        This should usually correspond with the absence of the
 9335        'unconfirmed' tag.
 9336
 9337        Example:
 9338
 9339        >>> ex = DiscreteExploration()
 9340        >>> ex.start('A')
 9341        0
 9342        >>> ex.observe('A', 'right')
 9343        1
 9344        >>> ex.explore('right', 'B', 'left')
 9345        1
 9346        >>> ex.observe('B', 'right')
 9347        2
 9348        >>> ex.getSituation().graph.addDecision('C')  # add isolated decision
 9349        3
 9350        >>> av = ex.allVisitedDecisions()
 9351        >>> av
 9352        [0, 1]
 9353        >>> all(  # no decisions in the 'visited' list are tagged
 9354        ...     'unconfirmed' not in ex.getSituation().graph.decisionTags(d)
 9355        ...     for d in av
 9356        ... )
 9357        True
 9358        >>> graph = ex.getSituation().graph
 9359        >>> 'unconfirmed' in graph.decisionTags(0)
 9360        False
 9361        >>> 'unconfirmed' in graph.decisionTags(1)
 9362        False
 9363        >>> 'unconfirmed' in graph.decisionTags(2)
 9364        True
 9365        >>> 'unconfirmed' in graph.decisionTags(3)  # not tagged; not explored
 9366        False
 9367        """
 9368        seen = set()
 9369        result = []
 9370        for step in range(len(self)):
 9371            active = self.getActiveDecisions(step)
 9372            for dID in active:
 9373                if dID not in seen:
 9374                    result.append(dID)
 9375                    seen.add(dID)
 9376
 9377        return result
 9378
 9379    def allTransitions(self) -> List[
 9380        Tuple[base.DecisionID, base.Transition, base.DecisionID]
 9381    ]:
 9382        """
 9383        Returns the list of all transitions which existed at any point
 9384        within the exploration, as 3-tuples with source decision ID,
 9385        transition name, and destination decision ID. Note that since
 9386        transitions can be deleted or re-targeted, and a transition name
 9387        can be re-used after being deleted, things can get messy in the
 9388        edges cases (see `allFinalTransitions`). When the same transition
 9389        name is used in different steps with different decision targets,
 9390        we end up including each possible source-transition-destination
 9391        triple. Example:
 9392
 9393        >>> ex = DiscreteExploration()
 9394        >>> ex.start('A')
 9395        0
 9396        >>> ex.observe('A', 'right', None, 'return')
 9397        1
 9398        >>> ex.explore('right', 'B', 'left')
 9399        1
 9400        >>> ex.observe('B', 'right')
 9401        2
 9402        >>> ex.wait()  # leave behind a step where 'B' has a 'right'
 9403        >>> ex.primaryDecision(0)
 9404        >>> ex.primaryDecision(1)
 9405        0
 9406        >>> ex.primaryDecision(2)
 9407        1
 9408        >>> ex.primaryDecision(3)
 9409        1
 9410        >>> len(ex)
 9411        4
 9412        >>> ex[3].graph.removeDecision(2)  # delete 'right of B'
 9413        >>> ex.observe('B', 'down')
 9414        3
 9415        >>> # Decisions are: 'A', 'B', and the unnamed 'right of B'
 9416        >>> # (now-deleted), and the unnamed 'down from B'
 9417        >>> ex.allDecisions()
 9418        [0, 1, 2, 3]
 9419        >>> for tr in ex.allTransitions():
 9420        ...     print(tr)
 9421        ...
 9422        (0, 'right', 1)
 9423        (1, 'return', 0)
 9424        (1, 'left', 0)
 9425        (1, 'right', 2)
 9426        (1, 'down', 3)
 9427        >>> # Note transitions from now-deleted nodes, and 'return'
 9428        >>> # transitions for unexplored nodes before they get explored
 9429        """
 9430        seen = set()
 9431        result = []
 9432        for situation in self:
 9433            graph = situation.graph
 9434            for (src, dst, transition) in graph.allEdges():  # type:ignore
 9435                trans = (src, transition, dst)
 9436                if trans not in seen:
 9437                    result.append(trans)
 9438                    seen.add(trans)
 9439
 9440        return result
 9441
 9442    def allFinalTransitions(self) -> List[
 9443        Tuple[base.DecisionID, base.Transition]
 9444    ]:
 9445        """
 9446        Returns the list of all transitions which exist in the final
 9447        situation's graph, as 2-tuples of source decision ID and
 9448        transition name. Compare `allTransitions` which tracks all
 9449        transitions that existed at any point in the exploration.
 9450        
 9451        Example:
 9452
 9453        >>> ex = DiscreteExploration()
 9454        >>> ex.start('A')
 9455        0
 9456        >>> ex.observe('A', 'right', None, 'return')
 9457        1
 9458        >>> ex.explore('right', 'B', 'left')
 9459        1
 9460        >>> ex.observe('B', 'right')
 9461        2
 9462        >>> ex.wait()  # leave behind a step where 'B' has a 'right'
 9463        >>> ex.primaryDecision(0)
 9464        >>> ex.primaryDecision(1)
 9465        0
 9466        >>> ex.primaryDecision(2)
 9467        1
 9468        >>> ex.primaryDecision(3)
 9469        1
 9470        >>> len(ex)
 9471        4
 9472        >>> ex[3].graph.removeDecision(2)  # delete 'right of B'
 9473        >>> ex.observe('B', 'down')
 9474        3
 9475        >>> # Decisions are: 'A', 'B', and the unnamed 'right of B'
 9476        >>> # (now-deleted), and the unnamed 'down from B'
 9477        >>> ex.allDecisions()
 9478        [0, 1, 2, 3]
 9479        >>> for tr in ex.allFinalTransitions():
 9480        ...     print(tr)
 9481        ...
 9482        (0, 'right')
 9483        (1, 'left')
 9484        (1, 'down')
 9485        >>> # Note only transitions present in final graph
 9486        """
 9487        if len(self) == 0:
 9488            return []
 9489        graph = self[-1].graph;
 9490        result = []
 9491        seen = set()
 9492        for (src, dst, transition) in graph.allEdges():  # type:ignore
 9493            trans = (src, transition)
 9494            if trans not in seen:
 9495                result.append(trans)
 9496                seen.add(trans)
 9497
 9498        return result
 9499
 9500    def start(
 9501        self,
 9502        decision: base.AnyDecisionSpecifier,
 9503        startCapabilities: Optional[base.CapabilitySet] = None,
 9504        setMechanismStates: Optional[
 9505            Dict[base.MechanismID, base.MechanismState]
 9506        ] = None,
 9507        setCustomState: Optional[dict] = None,
 9508        decisionType: base.DecisionType = "imposed"
 9509    ) -> base.DecisionID:
 9510        """
 9511        Sets the initial position information for a newly-relevant
 9512        domain for the current focal context. Creates a new decision
 9513        if the decision is specified by name or `DecisionSpecifier` and
 9514        that decision doesn't already exist. Returns the decision ID for
 9515        the newly-placed decision (or for the specified decision if it
 9516        already existed).
 9517
 9518        Raises a `BadStart` error if the current focal context already
 9519        has position information for the specified domain.
 9520
 9521        - The given `startCapabilities` replaces any existing
 9522            capabilities for the current focal context, although you can
 9523            leave it as the default `None` to avoid that and retain any
 9524            capabilities that have been set up already.
 9525        - The given `setMechanismStates` and `setCustomState`
 9526            dictionaries override all previous mechanism states & custom
 9527            states in the new situation. Leave these as the default
 9528            `None` to maintain those states.
 9529        - If created, the decision will be placed in the DEFAULT_DOMAIN
 9530            domain unless it's specified as a `base.DecisionSpecifier`
 9531            with a domain part, in which case that domain is used.
 9532        - If specified as a `base.DecisionSpecifier` with a zone part
 9533            and a new decision needs to be created, the decision will be
 9534            added to that zone, creating it at level 0 if necessary,
 9535            although otherwise no zone information will be changed.
 9536        - Resets the decision type to "pending" and the action taken to
 9537            `None`. Sets the decision type of the previous situation to
 9538            'imposed' (or the specified `decisionType`) and sets an
 9539            appropriate 'start' action for that situation.
 9540        - Tags the step with 'start'.
 9541        - Even in a plural- or spreading-focalized domain, you still need
 9542            to pick one decision to start at.
 9543        """
 9544        now = self.getSituation()
 9545
 9546        startID = now.graph.getDecision(decision)
 9547        zone = None
 9548        domain = base.DEFAULT_DOMAIN
 9549        if startID is None:
 9550            if isinstance(decision, base.DecisionID):
 9551                raise MissingDecisionError(
 9552                    f"Cannot start at decision {decision} because no"
 9553                    f" decision with that ID exists. Supply a name or"
 9554                    f" DecisionSpecifier if you need the start decision"
 9555                    f" to be created automatically."
 9556                )
 9557            elif isinstance(decision, base.DecisionName):
 9558                decision = base.DecisionSpecifier(
 9559                    domain=None,
 9560                    zone=None,
 9561                    name=decision
 9562                )
 9563            startID = now.graph.addDecision(
 9564                decision.name,
 9565                domain=decision.domain
 9566            )
 9567            zone = decision.zone
 9568            if decision.domain is not None:
 9569                domain = decision.domain
 9570
 9571        if zone is not None:
 9572            if now.graph.getZoneInfo(zone) is None:
 9573                now.graph.createZone(zone, 0)
 9574            now.graph.addDecisionToZone(startID, zone)
 9575
 9576        action: base.ExplorationAction = (
 9577            'start',
 9578            startID,
 9579            startID,
 9580            domain,
 9581            startCapabilities,
 9582            setMechanismStates,
 9583            setCustomState
 9584        )
 9585
 9586        self.advanceSituation(action, decisionType)
 9587
 9588        return startID
 9589
 9590    def hasBeenVisited(
 9591        self,
 9592        decision: base.AnyDecisionSpecifier,
 9593        step: int = -1
 9594    ):
 9595        """
 9596        Returns whether or not the specified decision has been visited in
 9597        or prior to the specified step (default current step).
 9598        """
 9599        situation = self.getSituation(step)
 9600        return base.hasBeenVisited(
 9601            situation.state,
 9602            situation.graph.resolveDecision(decision)
 9603        )
 9604
 9605    def setExplorationStatus(
 9606        self,
 9607        decision: base.AnyDecisionSpecifier,
 9608        status: base.ExplorationStatus,
 9609        upgradeOnly: bool = False
 9610    ):
 9611        """
 9612        Updates the current exploration status of a specific decision in
 9613        the current situation. If `upgradeOnly` is true (default is
 9614        `False` then the update will only apply if the new exploration
 9615        status counts as 'more-explored' than the old one (see
 9616        `base.moreExplored`).
 9617        """
 9618        now = self.getSituation()
 9619        base.setExplorationStatus(
 9620            now.state,
 9621            now.graph.resolveDecision(decision),
 9622            status,
 9623            upgradeOnly
 9624        )
 9625
 9626    def getExplorationStatus(
 9627        self,
 9628        decision: base.AnyDecisionSpecifier,
 9629        step: int = -1
 9630    ):
 9631        """
 9632        Returns the exploration status of the specified decision at the
 9633        specified step (default is last step). Decisions whose
 9634        exploration status has never been set will have a default status
 9635        of 'unknown'.
 9636        """
 9637        situation = self.getSituation(step)
 9638        dID = situation.graph.resolveDecision(decision)
 9639        return base.explorationStatusOf(
 9640            situation.state,
 9641            dID,
 9642            default='unknown'
 9643        )
 9644
 9645    def deduceTransitionDetailsAtStep(
 9646        self,
 9647        step: int,
 9648        transition: base.Transition,
 9649        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
 9650        whichFocus: Optional[base.FocalPointSpecifier] = None,
 9651        inCommon: Union[bool, Literal["auto"]] = "auto"
 9652    ) -> Tuple[
 9653        base.ContextSpecifier,
 9654        base.DecisionID,
 9655        base.DecisionID,
 9656        Optional[base.FocalPointSpecifier]
 9657    ]:
 9658        """
 9659        Given just a transition name which the player intends to take in
 9660        a specific step, deduces the `ContextSpecifier` for which
 9661        context should be updated, the source and destination
 9662        `DecisionID`s for the transition, and if the destination
 9663        decision's domain is plural-focalized, the `FocalPointName`
 9664        specifying which focal point should be moved.
 9665
 9666        Because many of those things are ambiguous, you may get an
 9667        `AmbiguousTransitionError` when things are underspecified, and
 9668        there are options for specifying some of the extra information
 9669        directly:
 9670
 9671        - `fromDecision` may be used to specify the source decision.
 9672        - `whichFocus` may be used to specify the focal point (within a
 9673            particular context/domain) being updated. When focal point
 9674            ambiguity remains and this is unspecified, the
 9675            alphabetically-earliest relevant focal point will be used
 9676            (either among all focal points which activate the source
 9677            decision, if there are any, or among all focal points for
 9678            the entire domain of the destination decision).
 9679        - `inCommon` (a `ContextSpecifier`) may be used to specify which
 9680            context to update. The default of "auto" will cause the
 9681            active context to be selected unless it does not activate
 9682            the source decision, in which case the common context will
 9683            be selected.
 9684
 9685        A `MissingDecisionError` will be raised if there are no current
 9686        active decisions (e.g., before `start` has been called), and a
 9687        `MissingTransitionError` will be raised if the listed transition
 9688        does not exist from any active decision (or from the specified
 9689        decision if `fromDecision` is used).
 9690        """
 9691        now = self.getSituation(step)
 9692        active = self.getActiveDecisions(step)
 9693        if len(active) == 0:
 9694            raise MissingDecisionError(
 9695                f"There are no active decisions from which transition"
 9696                f" {repr(transition)} could be taken at step {step}."
 9697            )
 9698
 9699        # All source/destination decision pairs for transitions with the
 9700        # given transition name.
 9701        allDecisionPairs: Dict[base.DecisionID, base.DecisionID] = {}
 9702
 9703        # TODO: When should we be trimming the active decisions to match
 9704        # any alterations to the graph?
 9705        for dID in active:
 9706            outgoing = now.graph.destinationsFrom(dID)
 9707            if transition in outgoing:
 9708                allDecisionPairs[dID] = outgoing[transition]
 9709
 9710        if len(allDecisionPairs) == 0:
 9711            raise MissingTransitionError(
 9712                f"No transitions named {repr(transition)} are outgoing"
 9713                f" from active decisions at step {step}."
 9714                f"\nActive decisions are:"
 9715                f"\n{now.graph.namesListing(active)}"
 9716            )
 9717
 9718        if (
 9719            fromDecision is not None
 9720        and fromDecision not in allDecisionPairs
 9721        ):
 9722            raise MissingTransitionError(
 9723                f"{fromDecision} was specified as the source decision"
 9724                f" for traversing transition {repr(transition)} but"
 9725                f" there is no transition of that name from that"
 9726                f" decision at step {step}."
 9727                f"\nValid source decisions are:"
 9728                f"\n{now.graph.namesListing(allDecisionPairs)}"
 9729            )
 9730        elif fromDecision is not None:
 9731            fromID = now.graph.resolveDecision(fromDecision)
 9732            destID = allDecisionPairs[fromID]
 9733            fromDomain = now.graph.domainFor(fromID)
 9734        elif len(allDecisionPairs) == 1:
 9735            fromID, destID = list(allDecisionPairs.items())[0]
 9736            fromDomain = now.graph.domainFor(fromID)
 9737        else:
 9738            fromID = None
 9739            destID = None
 9740            fromDomain = None
 9741            # Still ambiguous; resolve this below
 9742
 9743        # Use whichFocus if provided
 9744        if whichFocus is not None:
 9745            # Type/value check for whichFocus
 9746            if (
 9747                not isinstance(whichFocus, tuple)
 9748             or len(whichFocus) != 3
 9749             or whichFocus[0] not in ("active", "common")
 9750             or not isinstance(whichFocus[1], base.Domain)
 9751             or not isinstance(whichFocus[2], base.FocalPointName)
 9752            ):
 9753                raise ValueError(
 9754                    f"Invalid whichFocus value {repr(whichFocus)}."
 9755                    f"\nMust be a length-3 tuple with 'active' or 'common'"
 9756                    f" as the first element, a Domain as the second"
 9757                    f" element, and a FocalPointName as the third"
 9758                    f" element."
 9759                )
 9760
 9761            # Resolve focal point specified
 9762            fromID = base.resolvePosition(
 9763                now.state,
 9764                whichFocus
 9765            )
 9766            if fromID is None:
 9767                raise MissingTransitionError(
 9768                    f"Focal point {repr(whichFocus)} was specified as"
 9769                    f" the transition source, but that focal point does"
 9770                    f" not have a position."
 9771                )
 9772            else:
 9773                destID = now.graph.destination(fromID, transition)
 9774                fromDomain = now.graph.domainFor(fromID)
 9775
 9776        elif fromID is None:  # whichFocus is None, so it can't disambiguate
 9777            raise AmbiguousTransitionError(
 9778                f"Transition {repr(transition)} was selected for"
 9779                f" disambiguation, but there are multiple transitions"
 9780                f" with that name from currently-active decisions, and"
 9781                f" neither fromDecision nor whichFocus adequately"
 9782                f" disambiguates the specific transition taken."
 9783                f"\nValid source decisions at step {step} are:"
 9784                f"\n{now.graph.namesListing(allDecisionPairs)}"
 9785            )
 9786
 9787        # At this point, fromID, destID, and fromDomain have
 9788        # been resolved.
 9789        if fromID is None or destID is None or fromDomain is None:
 9790            raise RuntimeError(
 9791                f"One of fromID, destID, or fromDomain was None after"
 9792                f" disambiguation was finished:"
 9793                f"\nfromID: {fromID}, destID: {destID}, fromDomain:"
 9794                f" {repr(fromDomain)}"
 9795            )
 9796
 9797        # Now figure out which context activated the source so we know
 9798        # which focal point we're moving:
 9799        context = self.getActiveContext()
 9800        active = base.activeDecisionSet(context)
 9801        using: base.ContextSpecifier = "active"
 9802        if fromID not in active:
 9803            context = self.getCommonContext(step)
 9804            using = "common"
 9805
 9806        destDomain = now.graph.domainFor(destID)
 9807        if (
 9808            whichFocus is None
 9809        and base.getDomainFocalization(context, destDomain) == 'plural'
 9810        ):
 9811            # Need to figure out which focal point is moving; use the
 9812            # alphabetically earliest one that's positioned at the
 9813            # fromID, or just the earliest one overall if none of them
 9814            # are there.
 9815            contextFocalPoints: Dict[
 9816                base.FocalPointName,
 9817                Optional[base.DecisionID]
 9818            ] = cast(
 9819                Dict[base.FocalPointName, Optional[base.DecisionID]],
 9820                context['activeDecisions'][destDomain]
 9821            )
 9822            if not isinstance(contextFocalPoints, dict):
 9823                raise RuntimeError(
 9824                    f"Active decisions specifier for domain"
 9825                    f" {repr(destDomain)} with plural focalization has"
 9826                    f" a non-dictionary value."
 9827                )
 9828
 9829            if fromDomain == destDomain:
 9830                focalCandidates = [
 9831                    fp
 9832                    for fp, pos in contextFocalPoints.items()
 9833                    if pos == fromID
 9834                ]
 9835            else:
 9836                focalCandidates = list(contextFocalPoints)
 9837
 9838            whichFocus = (using, destDomain, min(focalCandidates))
 9839
 9840        # Now whichFocus has been set if it wasn't already specified;
 9841        # might still be None if it's not relevant.
 9842        return (using, fromID, destID, whichFocus)
 9843
 9844    def advanceSituation(
 9845        self,
 9846        action: base.ExplorationAction,
 9847        decisionType: base.DecisionType = "active",
 9848        challengePolicy: base.ChallengePolicy = "specified"
 9849    ) -> Tuple[base.Situation, Set[base.DecisionID]]:
 9850        """
 9851        Given an `ExplorationAction`, sets that as the action taken in
 9852        the current situation, and adds a new situation with the results
 9853        of that action. A `DoubleActionError` will be raised if the
 9854        current situation already has an action specified, and/or has a
 9855        decision type other than 'pending'. By default the type of the
 9856        decision will be 'active' but another `DecisionType` can be
 9857        specified via the `decisionType` parameter.
 9858
 9859        If the action specified is `('noAction',)`, then the new
 9860        situation will be a copy of the old one; this represents waiting
 9861        or being at an ending (a decision type other than 'pending'
 9862        should be used).
 9863
 9864        Although `None` can appear as the action entry in situations
 9865        with pending decisions, you cannot call `advanceSituation` with
 9866        `None` as the action.
 9867
 9868        If the action includes taking a transition whose requirements
 9869        are not satisfied, the transition will still be taken (and any
 9870        consequences applied) but a `TransitionBlockedWarning` will be
 9871        issued.
 9872
 9873        A `ChallengePolicy` may be specified, the default is 'specified'
 9874        which requires that outcomes are pre-specified. If any other
 9875        policy is set, the challenge outcomes will be reset before
 9876        re-resolving them according to the provided policy.
 9877
 9878        The new situation will have decision type 'pending' and `None`
 9879        as the action.
 9880
 9881        The new situation created as a result of the action is returned,
 9882        along with the set of destination decision IDs, including
 9883        possibly a modified destination via 'bounce', 'goto', and/or
 9884        'follow' effects. For actions that don't have a destination, the
 9885        second part of the returned tuple will be an empty set. Multiple
 9886        IDs may be in the set when using a start action in a plural- or
 9887        spreading-focalized domain, for example.
 9888
 9889        If the action updates active decisions (including via transition
 9890        effects) this will also update the exploration status of those
 9891        decisions to 'exploring' if they had been in an unvisited
 9892        status (see `updatePosition` and `hasBeenVisited`). This
 9893        includes decisions traveled through but not ultimately arrived
 9894        at via 'follow' effects. These will also lose any 'unconfirmed'
 9895        tags they might have had.
 9896
 9897        If any decisions are active in the `ENDINGS_DOMAIN`, attempting
 9898        to 'warp', 'explore', 'take', or 'start' will raise an
 9899        `InvalidActionError`.
 9900        """
 9901        now = self.getSituation()
 9902        if now.type != 'pending' or now.action is not None:
 9903            raise DoubleActionError(
 9904                f"Attempted to take action {repr(action)} at step"
 9905                f" {len(self) - 1}, but an action and/or decision type"
 9906                f" had already been specified:"
 9907                f"\nAction: {repr(now.action)}"
 9908                f"\nType: {repr(now.type)}"
 9909            )
 9910
 9911        # Update the now situation to add in the decision type and
 9912        # action taken:
 9913        revised = base.Situation(
 9914            now.graph,
 9915            now.state,
 9916            decisionType,
 9917            action,
 9918            now.saves,
 9919            now.tags,
 9920            now.annotations
 9921        )
 9922        self.situations[-1] = revised
 9923
 9924        # Separate update process when reverting (this branch returns)
 9925        if (
 9926            action is not None
 9927        and isinstance(action, tuple)
 9928        and len(action) == 3
 9929        and action[0] == 'revertTo'
 9930        and isinstance(action[1], base.SaveSlot)
 9931        and isinstance(action[2], set)
 9932        and all(isinstance(x, str) for x in action[2])
 9933        ):
 9934            _, slot, aspects = action
 9935            if slot not in now.saves:
 9936                raise KeyError(
 9937                    f"Cannot load save slot {slot!r} because no save"
 9938                    f" data has been established for that slot."
 9939                )
 9940            load = now.saves[slot]
 9941            rGraph, rState = base.revertedState(
 9942                (now.graph, now.state),
 9943                load,
 9944                aspects
 9945            )
 9946            reverted = base.Situation(
 9947                graph=rGraph,
 9948                state=rState,
 9949                type='pending',
 9950                action=None,
 9951                saves=copy.deepcopy(now.saves),
 9952                tags={},
 9953                annotations=[]
 9954            )
 9955            self.situations.append(reverted)
 9956            # Apply any active triggers (edits reverted)
 9957            self.applyActiveTriggers()
 9958            # Figure out destinations set to return
 9959            newDestinations = set()
 9960            newPr = rState['primaryDecision']
 9961            if newPr is not None:
 9962                newDestinations.add(newPr)
 9963            return (reverted, newDestinations)
 9964
 9965        # TODO: These deep copies are expensive time-wise. Can we avoid
 9966        # them? Probably not.
 9967        newGraph = copy.deepcopy(now.graph)
 9968        newState = copy.deepcopy(now.state)
 9969        newSaves = copy.copy(now.saves)  # a shallow copy
 9970        newTags: Dict[base.Tag, base.TagValue] = {}
 9971        newAnnotations: List[base.Annotation] = []
 9972        updated = base.Situation(
 9973            graph=newGraph,
 9974            state=newState,
 9975            type='pending',
 9976            action=None,
 9977            saves=newSaves,
 9978            tags=newTags,
 9979            annotations=newAnnotations
 9980        )
 9981
 9982        targetContext: base.FocalContext
 9983
 9984        # Now that action effects have been imprinted into the updated
 9985        # situation, append it to our situations list
 9986        self.situations.append(updated)
 9987
 9988        # Figure out effects of the action:
 9989        if action is None:
 9990            raise InvalidActionError(
 9991                "None cannot be used as an action when advancing the"
 9992                " situation."
 9993            )
 9994
 9995        aLen = len(action)
 9996
 9997        destIDs = set()
 9998
 9999        if (
10000            action[0] in ('start', 'take', 'explore', 'warp')
10001        and any(
10002                newGraph.domainFor(d) == ENDINGS_DOMAIN
10003                for d in self.getActiveDecisions()
10004            )
10005        ):
10006            activeEndings = [
10007                d
10008                for d in self.getActiveDecisions()
10009                if newGraph.domainFor(d) == ENDINGS_DOMAIN
10010            ]
10011            raise InvalidActionError(
10012                f"Attempted to {action[0]!r} while an ending was"
10013                f" active. Active endings are:"
10014                f"\n{newGraph.namesListing(activeEndings)}"
10015            )
10016
10017        if action == ('noAction',):
10018            # No updates needed
10019            pass
10020
10021        elif (
10022            not isinstance(action, tuple)
10023         or (action[0] not in get_args(base.ExplorationActionType))
10024         or not (2 <= aLen <= 7)
10025        ):
10026            raise InvalidActionError(
10027                f"Invalid ExplorationAction tuple (must be a tuple that"
10028                f" starts with an ExplorationActionType and has 2-6"
10029                f" entries if it's not ('noAction',)):"
10030                f"\n{repr(action)}"
10031            )
10032
10033        elif action[0] == 'start':
10034            (
10035                _,
10036                positionSpecifier,
10037                primary,
10038                domain,
10039                capabilities,
10040                mechanismStates,
10041                customState
10042            ) = cast(
10043                Tuple[
10044                    Literal['start'],
10045                    Union[
10046                        base.DecisionID,
10047                        Dict[base.FocalPointName, base.DecisionID],
10048                        Set[base.DecisionID]
10049                    ],
10050                    Optional[base.DecisionID],
10051                    base.Domain,
10052                    Optional[base.CapabilitySet],
10053                    Optional[Dict[base.MechanismID, base.MechanismState]],
10054                    Optional[dict]
10055                ],
10056                action
10057            )
10058            targetContext = newState['contexts'][
10059                newState['activeContext']
10060            ]
10061
10062            targetFocalization = base.getDomainFocalization(
10063                targetContext,
10064                domain
10065            )  # sets up 'singular' as default if
10066
10067            # Check if there are any already-active decisions.
10068            if targetContext['activeDecisions'][domain] is not None:
10069                raise BadStart(
10070                    f"Cannot start in domain {repr(domain)} because"
10071                    f" that domain already has a position. 'start' may"
10072                    f" only be used with domains that don't yet have"
10073                    f" any position information."
10074                )
10075
10076            # Make the domain active
10077            if domain not in targetContext['activeDomains']:
10078                targetContext['activeDomains'].add(domain)
10079
10080            # Check position info matches focalization type and update
10081            # exploration statuses
10082            if isinstance(positionSpecifier, base.DecisionID):
10083                if targetFocalization != 'singular':
10084                    raise BadStart(
10085                        f"Invalid position specifier"
10086                        f" {repr(positionSpecifier)} (type"
10087                        f" {type(positionSpecifier)}). Domain"
10088                        f" {repr(domain)} has {targetFocalization}"
10089                        f" focalization."
10090                    )
10091                base.setExplorationStatus(
10092                    updated.state,
10093                    updated.graph.resolveDecision(positionSpecifier),
10094                    'exploring',
10095                    upgradeOnly=True
10096                )
10097                destIDs.add(positionSpecifier)
10098            elif isinstance(positionSpecifier, dict):
10099                if targetFocalization != 'plural':
10100                    raise BadStart(
10101                        f"Invalid position specifier"
10102                        f" {repr(positionSpecifier)} (type"
10103                        f" {type(positionSpecifier)}). Domain"
10104                        f" {repr(domain)} has {targetFocalization}"
10105                        f" focalization."
10106                    )
10107                destIDs |= set(positionSpecifier.values())
10108            elif isinstance(positionSpecifier, set):
10109                if targetFocalization != 'spreading':
10110                    raise BadStart(
10111                        f"Invalid position specifier"
10112                        f" {repr(positionSpecifier)} (type"
10113                        f" {type(positionSpecifier)}). Domain"
10114                        f" {repr(domain)} has {targetFocalization}"
10115                        f" focalization."
10116                    )
10117                destIDs |= positionSpecifier
10118            else:
10119                raise TypeError(
10120                    f"Invalid position specifier"
10121                    f" {repr(positionSpecifier)} (type"
10122                    f" {type(positionSpecifier)}). It must be a"
10123                    f" DecisionID, a dictionary from FocalPointNames to"
10124                    f" DecisionIDs, or a set of DecisionIDs, according"
10125                    f" to the focalization of the relevant domain."
10126                )
10127
10128            # Put specified position(s) in place
10129            # TODO: This cast is really silly...
10130            targetContext['activeDecisions'][domain] = cast(
10131                Union[
10132                    None,
10133                    base.DecisionID,
10134                    Dict[base.FocalPointName, Optional[base.DecisionID]],
10135                    Set[base.DecisionID]
10136                ],
10137                positionSpecifier
10138            )
10139
10140            # Set primary decision
10141            newState['primaryDecision'] = primary
10142
10143            # Set capabilities
10144            if capabilities is not None:
10145                targetContext['capabilities'] = capabilities
10146
10147            # Set mechanism states
10148            if mechanismStates is not None:
10149                newState['mechanisms'] = mechanismStates
10150
10151            # Set custom state
10152            if customState is not None:
10153                newState['custom'] = customState
10154
10155        elif action[0] in ('explore', 'take', 'warp'):  # similar handling
10156            assert (
10157                len(action) == 3
10158             or len(action) == 4
10159             or len(action) == 6
10160             or len(action) == 7
10161            )
10162            # Set up necessary variables
10163            cSpec: base.ContextSpecifier = "active"
10164            fromID: Optional[base.DecisionID] = None
10165            takeTransition: Optional[base.Transition] = None
10166            outcomes: List[bool] = []
10167            destID: base.DecisionID  # No starting value as it's not optional
10168            moveInDomain: Optional[base.Domain] = None
10169            moveWhich: Optional[base.FocalPointName] = None
10170
10171            # Figure out target context
10172            if isinstance(action[1], str):
10173                if action[1] not in get_args(base.ContextSpecifier):
10174                    raise InvalidActionError(
10175                        f"Action specifies {repr(action[1])} context,"
10176                        f" but that's not a valid context specifier."
10177                        f" The valid options are:"
10178                        f"\n{repr(get_args(base.ContextSpecifier))}"
10179                    )
10180                else:
10181                    cSpec = cast(base.ContextSpecifier, action[1])
10182            else:  # Must be a `FocalPointSpecifier`
10183                cSpec, moveInDomain, moveWhich = cast(
10184                    base.FocalPointSpecifier,
10185                    action[1]
10186                )
10187                assert moveInDomain is not None
10188
10189            # Grab target context to work in
10190            if cSpec == 'common':
10191                targetContext = newState['common']
10192            else:
10193                targetContext = newState['contexts'][
10194                    newState['activeContext']
10195                ]
10196
10197            # Check focalization of the target domain
10198            if moveInDomain is not None:
10199                fType = base.getDomainFocalization(
10200                    targetContext,
10201                    moveInDomain
10202                )
10203                if (
10204                    (
10205                        isinstance(action[1], str)
10206                    and fType == 'plural'
10207                    ) or (
10208                        not isinstance(action[1], str)
10209                    and fType != 'plural'
10210                    )
10211                ):
10212                    raise ImpossibleActionError(
10213                        f"Invalid ExplorationAction (moves in"
10214                        f" plural-focalized domains must include a"
10215                        f" FocalPointSpecifier, while moves in"
10216                        f" non-plural-focalized domains must not."
10217                        f" Domain {repr(moveInDomain)} is"
10218                        f" {fType}-focalized):"
10219                        f"\n{repr(action)}"
10220                    )
10221
10222            if action[0] == "warp":
10223                # It's a warp, so destination is specified directly
10224                if not isinstance(action[2], base.DecisionID):
10225                    raise TypeError(
10226                        f"Invalid ExplorationAction tuple (third part"
10227                        f" must be a decision ID for 'warp' actions):"
10228                        f"\n{repr(action)}"
10229                    )
10230                else:
10231                    destID = cast(base.DecisionID, action[2])
10232
10233            elif aLen == 4 or aLen == 7:
10234                # direct 'take' or 'explore'
10235                fromID = cast(base.DecisionID, action[2])
10236                takeTransition, outcomes = cast(
10237                    base.TransitionWithOutcomes,
10238                    action[3]  # type: ignore [misc]
10239                )
10240                if (
10241                    not isinstance(fromID, base.DecisionID)
10242                 or not isinstance(takeTransition, base.Transition)
10243                ):
10244                    raise InvalidActionError(
10245                        f"Invalid ExplorationAction tuple (for 'take' or"
10246                        f" 'explore', if the length is 4/7, parts 2-4"
10247                        f" must be a context specifier, a decision ID, and a"
10248                        f" transition name. Got:"
10249                        f"\n{repr(action)}"
10250                    )
10251
10252                try:
10253                    destID = newGraph.destination(fromID, takeTransition)
10254                except MissingDecisionError:
10255                    raise ImpossibleActionError(
10256                        f"Invalid ExplorationAction: move from decision"
10257                        f" {fromID} is invalid because there is no"
10258                        f" decision with that ID in the current"
10259                        f" graph."
10260                        f"\nValid decisions are:"
10261                        f"\n{newGraph.namesListing(newGraph)}"
10262                    )
10263                except MissingTransitionError:
10264                    valid = newGraph.destinationsFrom(fromID)
10265                    listing = newGraph.destinationsListing(valid)
10266                    raise ImpossibleActionError(
10267                        f"Invalid ExplorationAction: move from decision"
10268                        f" {newGraph.identityOf(fromID)}"
10269                        f" along transition {repr(takeTransition)} is"
10270                        f" invalid because there is no such transition"
10271                        f" at that decision."
10272                        f"\nValid transitions there are:"
10273                        f"\n{listing}"
10274                    )
10275                targetActive = targetContext['activeDecisions']
10276                if moveInDomain is not None:
10277                    activeInDomain = targetActive[moveInDomain]
10278                    if (
10279                        (
10280                            isinstance(activeInDomain, base.DecisionID)
10281                        and fromID != activeInDomain
10282                        )
10283                     or (
10284                            isinstance(activeInDomain, set)
10285                        and fromID not in activeInDomain
10286                        )
10287                     or (
10288                            isinstance(activeInDomain, dict)
10289                        and fromID not in activeInDomain.values()
10290                        )
10291                    ):
10292                        raise ImpossibleActionError(
10293                            f"Invalid ExplorationAction: move from"
10294                            f" decision {fromID} is invalid because"
10295                            f" that decision is not active in domain"
10296                            f" {repr(moveInDomain)} in the current"
10297                            f" graph."
10298                            f"\nValid decisions are:"
10299                            f"\n{newGraph.namesListing(newGraph)}"
10300                        )
10301
10302            elif aLen == 3 or aLen == 6:
10303                # 'take' or 'explore' focal point
10304                # We know that moveInDomain is not None here.
10305                assert moveInDomain is not None
10306                if not isinstance(action[2], base.Transition):
10307                    raise InvalidActionError(
10308                        f"Invalid ExplorationAction tuple (for 'take'"
10309                        f" actions if the second part is a"
10310                        f" FocalPointSpecifier the third part must be a"
10311                        f" transition name):"
10312                        f"\n{repr(action)}"
10313                    )
10314
10315                takeTransition, outcomes = cast(
10316                    base.TransitionWithOutcomes,
10317                    action[2]
10318                )
10319                targetActive = targetContext['activeDecisions']
10320                activeInDomain = cast(
10321                    Dict[base.FocalPointName, Optional[base.DecisionID]],
10322                    targetActive[moveInDomain]
10323                )
10324                if (
10325                    moveInDomain is not None
10326                and (
10327                        not isinstance(activeInDomain, dict)
10328                     or moveWhich not in activeInDomain
10329                    )
10330                ):
10331                    raise ImpossibleActionError(
10332                        f"Invalid ExplorationAction: move of focal"
10333                        f" point {repr(moveWhich)} in domain"
10334                        f" {repr(moveInDomain)} is invalid because"
10335                        f" that domain does not have a focal point"
10336                        f" with that name."
10337                    )
10338                fromID = activeInDomain[moveWhich]
10339                if fromID is None:
10340                    raise ImpossibleActionError(
10341                        f"Invalid ExplorationAction: move of focal"
10342                        f" point {repr(moveWhich)} in domain"
10343                        f" {repr(moveInDomain)} is invalid because"
10344                        f" that focal point does not have a position"
10345                        f" at this step."
10346                    )
10347                try:
10348                    destID = newGraph.destination(fromID, takeTransition)
10349                except MissingDecisionError:
10350                    raise ImpossibleActionError(
10351                        f"Invalid exploration state: focal point"
10352                        f" {repr(moveWhich)} in domain"
10353                        f" {repr(moveInDomain)} specifies decision"
10354                        f" {fromID} as the current position, but"
10355                        f" that decision does not exist!"
10356                    )
10357                except MissingTransitionError:
10358                    valid = newGraph.destinationsFrom(fromID)
10359                    listing = newGraph.destinationsListing(valid)
10360                    raise ImpossibleActionError(
10361                        f"Invalid ExplorationAction: move of focal"
10362                        f" point {repr(moveWhich)} in domain"
10363                        f" {repr(moveInDomain)} along transition"
10364                        f" {repr(takeTransition)} is invalid because"
10365                        f" that focal point is at decision"
10366                        f" {newGraph.identityOf(fromID)} and that"
10367                        f" decision does not have an outgoing"
10368                        f" transition with that name.\nValid"
10369                        f" transitions from that decision are:"
10370                        f"\n{listing}"
10371                    )
10372
10373            else:
10374                raise InvalidActionError(
10375                    f"Invalid ExplorationAction: unrecognized"
10376                    f" 'explore', 'take' or 'warp' format:"
10377                    f"\n{action}"
10378                )
10379
10380            # If we're exploring, update information for the destination
10381            if action[0] == 'explore':
10382                zone = cast(Optional[base.Zone], action[-1])
10383                recipName = cast(Optional[base.Transition], action[-2])
10384                destOrName = cast(
10385                    Union[base.DecisionName, base.DecisionID, None],
10386                    action[-3]
10387                )
10388                if isinstance(destOrName, base.DecisionID):
10389                    destID = destOrName
10390
10391                if fromID is None or takeTransition is None:
10392                    raise ImpossibleActionError(
10393                        f"Invalid ExplorationAction: exploration"
10394                        f" has unclear origin decision or transition."
10395                        f" Got:\n{action}"
10396                    )
10397
10398                currentDest = newGraph.destination(fromID, takeTransition)
10399                if not newGraph.isConfirmed(currentDest):
10400                    newGraph.replaceUnconfirmed(
10401                        fromID,
10402                        takeTransition,
10403                        destOrName,
10404                        recipName,
10405                        placeInZone=zone,
10406                        forceNew=not isinstance(destOrName, base.DecisionID)
10407                    )
10408                else:
10409                    # Otherwise, since the destination already existed
10410                    # and was hooked up at the right decision, no graph
10411                    # edits need to be made, unless we need to rename
10412                    # the reciprocal.
10413                    # TODO: Do we care about zones here?
10414                    if recipName is not None:
10415                        oldReciprocal = newGraph.getReciprocal(
10416                            fromID,
10417                            takeTransition
10418                        )
10419                        if (
10420                            oldReciprocal is not None
10421                        and oldReciprocal != recipName
10422                        ):
10423                            newGraph.addTransition(
10424                                destID,
10425                                recipName,
10426                                fromID,
10427                                None
10428                            )
10429                            newGraph.setReciprocal(
10430                                destID,
10431                                recipName,
10432                                takeTransition,
10433                                setBoth=True
10434                            )
10435                            newGraph.mergeTransitions(
10436                                destID,
10437                                oldReciprocal,
10438                                recipName
10439                            )
10440
10441            # If we are moving along a transition, check requirements
10442            # and apply transition effects *before* updating our
10443            # position, and check that they don't cancel the normal
10444            # position update
10445            finalDest = None
10446            if takeTransition is not None:
10447                assert fromID is not None  # both or neither
10448                if not self.isTraversable(fromID, takeTransition):
10449                    if (fromID, takeTransition) in now.state['deactivated']:
10450                        warnings.warn(
10451                            (
10452                                f"The transition {takeTransition!r}"
10453                                f" from decision"
10454                                f" {now.graph.identityOf(fromID)} was"
10455                                f" already deactivated before step"
10456                                f" {len(self) - 1}."
10457                            ),
10458                            TransitionBlockedWarning
10459                        )
10460                    else:
10461                        req = now.graph.getTransitionRequirement(
10462                            fromID,
10463                            takeTransition
10464                        )
10465                        warnings.warn(
10466                            (
10467                                f"The requirements for transition"
10468                                f" {takeTransition!r} from decision"
10469                                f" {now.graph.identityOf(fromID)} are"
10470                                f" not met at step {len(self) - 1}:"
10471                                f"\n{req}"
10472                            ),
10473                            TransitionBlockedWarning
10474                        )
10475
10476                # Apply transition consequences to our new state and
10477                # figure out if we need to skip our normal update or not
10478                finalDest = self.applyTransitionConsequence(
10479                    fromID,
10480                    (takeTransition, outcomes),
10481                    moveWhich,
10482                    challengePolicy
10483                )
10484
10485            # Check moveInDomain
10486            destDomain = newGraph.domainFor(destID)
10487            if moveInDomain is not None and moveInDomain != destDomain:
10488                raise ImpossibleActionError(
10489                    f"Invalid ExplorationAction: move specified"
10490                    f" domain {repr(moveInDomain)} as the domain of"
10491                    f" the focal point to move, but the destination"
10492                    f" of the move is {now.graph.identityOf(destID)}"
10493                    f" which is in domain {repr(destDomain)}, so focal"
10494                    f" point {repr(moveWhich)} cannot be moved there."
10495                )
10496
10497            # Now that we know where we're going, update position
10498            # information (assuming it wasn't already set):
10499            if finalDest is None:
10500                finalDest = destID
10501                base.updatePosition(
10502                    updated.state,
10503                    updated.graph,
10504                    destID,
10505                    cSpec,
10506                    moveWhich
10507                )
10508
10509            destIDs.add(finalDest)
10510
10511        elif action[0] == "focus":
10512            # Figure out target context
10513            action = cast(
10514                Tuple[
10515                    Literal['focus'],
10516                    base.ContextSpecifier,
10517                    Set[base.Domain],
10518                    Set[base.Domain]
10519                ],
10520                action
10521            )
10522            contextSpecifier: base.ContextSpecifier = action[1]
10523            if contextSpecifier == 'common':
10524                targetContext = newState['common']
10525            else:
10526                targetContext = newState['contexts'][
10527                    newState['activeContext']
10528                ]
10529
10530            # Just need to swap out active domains
10531            goingOut, comingIn = cast(
10532                Tuple[Set[base.Domain], Set[base.Domain]],
10533                action[2:]
10534            )
10535            if (
10536                not isinstance(goingOut, set)
10537             or not isinstance(comingIn, set)
10538             or not all(isinstance(d, base.Domain) for d in goingOut)
10539             or not all(isinstance(d, base.Domain) for d in comingIn)
10540            ):
10541                raise InvalidActionError(
10542                    f"Invalid ExplorationAction tuple (must have 4"
10543                    f" parts if the first part is 'focus' and"
10544                    f" the third and fourth parts must be sets of"
10545                    f" domains):"
10546                    f"\n{repr(action)}"
10547                )
10548            activeSet = targetContext['activeDomains']
10549            for dom in goingOut:
10550                try:
10551                    activeSet.remove(dom)
10552                except KeyError:
10553                    warnings.warn(
10554                        (
10555                            f"Domain {repr(dom)} was deactivated at"
10556                            f" step {len(self)} but it was already"
10557                            f" inactive at that point."
10558                        ),
10559                        InactiveDomainWarning
10560                    )
10561            # TODO: Also warn for doubly-activated domains?
10562            activeSet |= comingIn
10563
10564            # destIDs remains empty in this case
10565
10566        elif action[0] == 'swap':  # update which `FocalContext` is active
10567            newContext = cast(base.FocalContextName, action[1])
10568            if newContext not in newState['contexts']:
10569                raise MissingFocalContextError(
10570                    f"'swap' action with target {repr(newContext)} is"
10571                    f" invalid because no context with that name"
10572                    f" exists."
10573                )
10574            newState['activeContext'] = newContext
10575
10576            # destIDs remains empty in this case
10577
10578        elif action[0] == 'focalize':  # create new `FocalContext`
10579            newContext = cast(base.FocalContextName, action[1])
10580            if newContext in newState['contexts']:
10581                raise FocalContextCollisionError(
10582                    f"'focalize' action with target {repr(newContext)}"
10583                    f" is invalid because a context with that name"
10584                    f" already exists."
10585                )
10586            newState['contexts'][newContext] = base.emptyFocalContext()
10587            newState['activeContext'] = newContext
10588
10589            # destIDs remains empty in this case
10590
10591        # revertTo is handled above
10592        else:
10593            raise InvalidActionError(
10594                f"Invalid ExplorationAction tuple (first item must be"
10595                f" an ExplorationActionType, and tuple must be length-1"
10596                f" if the action type is 'noAction'):"
10597                f"\n{repr(action)}"
10598            )
10599
10600        # Apply any active triggers
10601        followTo = self.applyActiveTriggers()
10602        if followTo is not None:
10603            destIDs.add(followTo)
10604            # TODO: Re-work to work with multiple position updates in
10605            # different focal contexts, domains, and/or for different
10606            # focal points in plural-focalized domains.
10607
10608        return (updated, destIDs)
10609
10610    def applyActiveTriggers(self) -> Optional[base.DecisionID]:
10611        """
10612        Finds all actions with the 'trigger' tag attached to currently
10613        active decisions, and applies their effects if their requirements
10614        are met (ordered by decision-ID with ties broken alphabetically
10615        by action name).
10616
10617        'bounce', 'goto' and 'follow' effects may apply. However, any
10618        new triggers that would be activated because of decisions
10619        reached by such effects will not apply. Note that 'bounce'
10620        effects update position to the decision where the action was
10621        attached, which is usually a no-op. This function returns the
10622        decision ID of the decision reached by the last decision-moving
10623        effect applied, or `None` if no such effects triggered.
10624
10625        TODO: What about situations where positions are updated in
10626        multiple domains or multiple foal points in a plural domain are
10627        independently updated?
10628
10629        TODO: Tests for this!
10630        """
10631        active = self.getActiveDecisions()
10632        now = self.getSituation()
10633        graph = now.graph
10634        finalFollow = None
10635        for decision in sorted(active):
10636            for action in sorted(graph.decisionActions(decision)):
10637                if (
10638                    'trigger' in graph.transitionTags(decision, action)
10639                and self.isTraversable(decision, action)
10640                ):
10641                    followTo = self.applyTransitionConsequence(
10642                        decision,
10643                        action
10644                    )
10645                    if followTo is not None:
10646                        # TODO: How will triggers interact with
10647                        # plural-focalized domains? Probably need to fix
10648                        # this to detect moveWhich based on which focal
10649                        # points are at the decision where the transition
10650                        # is, and then apply this to each of them?
10651                        base.updatePosition(now.state, now.graph, followTo)
10652                        finalFollow = followTo
10653
10654        return finalFollow
10655
10656    def explore(
10657        self,
10658        transition: base.AnyTransition,
10659        destination: Union[base.DecisionName, base.DecisionID, None],
10660        reciprocal: Optional[base.Transition] = None,
10661        zone: Optional[base.Zone] = base.DefaultZone,
10662        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
10663        whichFocus: Optional[base.FocalPointSpecifier] = None,
10664        inCommon: Union[bool, Literal["auto"]] = "auto",
10665        decisionType: base.DecisionType = "active",
10666        challengePolicy: base.ChallengePolicy = "specified"
10667    ) -> base.DecisionID:
10668        """
10669        Adds a new situation to the exploration representing the
10670        traversal of the specified transition (possibly with outcomes
10671        specified for challenges among that transitions consequences).
10672        Uses `deduceTransitionDetailsAtStep` to figure out from the
10673        transition name which specific transition is taken (and which
10674        focal point is updated if necessary). This uses the
10675        `fromDecision`, `whichFocus`, and `inCommon` optional
10676        parameters, and also determines whether to update the common or
10677        the active `FocalContext`. Sets the exploration status of the
10678        decision explored to 'exploring'. Returns the decision ID for
10679        the destination reached, accounting for goto/bounce/follow
10680        effects that might have triggered.
10681
10682        If multiple decisions are reached (e.g., in multiple domains,
10683        like you arrive at the destination but also die) it returns the
10684        decision with the highest decision ID (i.e., discovered latest)
10685        among decisions in the same domain as the natural endpoint of the
10686        transition taken, or if there are no such decisions, it returns
10687        the decision with the highest ID out of all newly-arrived-at
10688        decisions.
10689
10690        The `destination` will be used to name the newly-explored
10691        decision, except when it's a `DecisionID`, in which case that
10692        decision must be unvisited, and we'll connect the specified
10693        transition to that decision.
10694
10695        The focalization of the destination domain in the context to be
10696        updated determines how active decisions are changed:
10697
10698        - If the destination domain is focalized as 'single', then in
10699            the subsequent `Situation`, the destination decision will
10700            become the single active decision in that domain.
10701        - If it's focalized as 'plural', then one of the
10702            `FocalPointName`s for that domain will be moved to activate
10703            that decision; which one can be specified using `whichFocus`
10704            or if left unspecified, will be deduced: if the starting
10705            decision is in the same domain, then the
10706            alphabetically-earliest focal point which is at the starting
10707            decision will be moved. If the starting position is in a
10708            different domain, then the alphabetically earliest focal
10709            point among all focal points in the destination domain will
10710            be moved.
10711        - If it's focalized as 'spreading', then the destination
10712            decision will be added to the set of active decisions in
10713            that domain, without removing any.
10714
10715        The transition named must have been pointing to an unvisited
10716        decision (see `hasBeenVisited`), and the name of that decision
10717        will be updated if a `destination` value is given (a
10718        `DecisionCollisionWarning` will be issued if the destination
10719        name is a duplicate of another name in the graph, although this
10720        is not an error). Additionally:
10721
10722        - If a `reciprocal` name is specified, the reciprocal transition
10723            will be renamed using that name, or created with that name if
10724            it didn't already exist. If reciprocal is left as `None` (the
10725            default) then no change will be made to the reciprocal
10726            transition, and it will not be created if it doesn't exist.
10727        - If a `zone` is specified, the newly-explored decision will be
10728            added to that zone (and that zone will be created at level 0
10729            if it didn't already exist). If `zone` is set to `None` then
10730            it will not be added to any new zones. If `zone` is left as
10731            the default (the `base.DefaultZone` value) then the explored
10732            decision will be added to each zone that the decision it was
10733            explored from is a part of. If a zone needs to be created,
10734            that zone will be added as a sub-zone of each zone which is a
10735            parent of a zone that directly contains the origin decision.
10736        - An `ExplorationStatusError` will be raised if the specified
10737            transition leads to a decision whose `ExplorationStatus` is
10738            'exploring' or higher (i.e., `hasBeenVisited`). (Use
10739            `returnTo` instead to adjust things when a transition to an
10740            unknown destination turns out to lead to an already-known
10741            destination.)
10742        - A `TransitionBlockedWarning` will be issued if the specified
10743            transition is not traversable given the current game state
10744            (but in that last case the step will still be taken).
10745        - By default, the decision type for the new step will be
10746            'active', but a `decisionType` value can be specified to
10747            override that.
10748        - By default, the 'mostLikely' `ChallengePolicy` will be used to
10749            resolve challenges in the consequence of the transition
10750            taken, but an alternate policy can be supplied using the
10751            `challengePolicy` argument.
10752        """
10753        now = self.getSituation()
10754
10755        transitionName, outcomes = base.nameAndOutcomes(transition)
10756
10757        # Deduce transition details from the name + optional specifiers
10758        (
10759            using,
10760            fromID,
10761            destID,
10762            whichFocus
10763        ) = self.deduceTransitionDetailsAtStep(
10764            -1,
10765            transitionName,
10766            fromDecision,
10767            whichFocus,
10768            inCommon
10769        )
10770
10771        # Issue a warning if the destination name is already in use
10772        if destination is not None:
10773            if isinstance(destination, base.DecisionName):
10774                try:
10775                    existingID = now.graph.resolveDecision(destination)
10776                    collision = existingID != destID
10777                except MissingDecisionError:
10778                    collision = False
10779                except AmbiguousDecisionSpecifierError:
10780                    collision = True
10781
10782                if collision and WARN_OF_NAME_COLLISIONS:
10783                    warnings.warn(
10784                        (
10785                            f"The destination name {repr(destination)} is"
10786                            f" already in use when exploring transition"
10787                            f" {repr(transition)} from decision"
10788                            f" {now.graph.identityOf(fromID)} at step"
10789                            f" {len(self) - 1}."
10790                        ),
10791                        DecisionCollisionWarning
10792                    )
10793
10794        # TODO: Different terminology for "exploration state above
10795        # noticed" vs. "DG thinks it's been visited"...
10796        if (
10797            self.hasBeenVisited(destID)
10798        ):
10799            frStr = ''
10800            if fromDecision is not None:
10801                frStr = f"from decision {now.graph.identityOf(fromDecision)} "
10802            raise ExplorationStatusError(
10803                f"Cannot explore {frStr}to decision"
10804                f" {now.graph.identityOf(destID)} because it has"
10805                f" already been visited. Use returnTo instead of"
10806                f" explore when discovering a connection back to a"
10807                f" previously-explored decision."
10808            )
10809
10810        if (
10811            isinstance(destination, base.DecisionID)
10812        and self.hasBeenVisited(destination)
10813        ):
10814            frStr = ''
10815            if fromDecision is not None:
10816                frStr = f"from decision {now.graph.identityOf(fromDecision)} "
10817            raise ExplorationStatusError(
10818                f"Cannot explore {frStr}to decision"
10819                f" {now.graph.identityOf(destination)} because it has"
10820                f" already been visited. Use returnTo instead of"
10821                f" explore when discovering a connection back to a"
10822                f" previously-explored decision."
10823            )
10824
10825        actionTaken: base.ExplorationAction = (
10826            'explore',
10827            using,
10828            fromID,
10829            (transitionName, outcomes),
10830            destination,
10831            reciprocal,
10832            zone
10833        )
10834        if whichFocus is not None:
10835            # A move-from-specific-focal-point action
10836            actionTaken = (
10837                'explore',
10838                whichFocus,
10839                (transitionName, outcomes),
10840                destination,
10841                reciprocal,
10842                zone
10843            )
10844
10845        # Advance the situation, applying transition effects and
10846        # updating the destination decision.
10847        _, finalDests = self.advanceSituation(
10848            actionTaken,
10849            decisionType,
10850            challengePolicy
10851        )
10852
10853        return self.mostApplicableDestination(
10854            now.graph,
10855            fromID,
10856            destID,
10857            finalDests
10858        )
10859
10860    def mostApplicableDestination(
10861        self,
10862        graph: DecisionGraph,
10863        fromID: base.DecisionID,
10864        destID: base.DecisionID,
10865        destinationSet: Set[base.DecisionID]
10866    ) -> base.DecisionID:
10867        """
10868        Returns the single decision ID that's "most applicable" as the
10869        destination of an action that moved from the given `fromID`
10870        decision to the given `destID` decision (naively) on the given
10871        `graph` with the given `destinationSet` as the set of newly-active
10872        decisions from an `advanceSituation` call.
10873
10874        `advanceSituation` can return multiple or zero active decisions
10875        (e.g., if you take a transition but then die as a consequence,
10876        you'll be at the destination plus at the death ending in the
10877        endings domain, or if you take a transition with 'follow'
10878        consequences in a spreading-focalized domain).
10879
10880        When multiple decisions are present in the destination set, this
10881        function returns the decision with the highest ID (i.e.,
10882        discovered most recently) that's in the same domain as the
10883        destination decision, or if there are none in that domain, the
10884        one with the highest decision ID overall.
10885
10886        If the destination set is empty, it returns the `fromID`.
10887        """
10888        if len(destinationSet) == 0:
10889            return fromID
10890        elif len(destinationSet) > 1:
10891            # Figure out which destination(s) are in the same domain as
10892            # the natural destination, and return the one with the
10893            # highest ID among those, or the one with the highest ID
10894            # overall if there are none.
10895            destDomain = graph.domainFor(destID)
10896            inSame = [
10897                x
10898                for x in destinationSet
10899                if graph.domainFor(x) == destDomain
10900            ]
10901            if len(inSame) == 0:
10902                return max(destinationSet)
10903            else:
10904                return max(inSame)
10905        else:
10906            return next(x for x in destinationSet)
10907
10908    def returnTo(
10909        self,
10910        transition: base.AnyTransition,
10911        destination: base.AnyDecisionSpecifier,
10912        reciprocal: Optional[base.Transition] = None,
10913        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
10914        whichFocus: Optional[base.FocalPointSpecifier] = None,
10915        inCommon: Union[bool, Literal["auto"]] = "auto",
10916        decisionType: base.DecisionType = "active",
10917        challengePolicy: base.ChallengePolicy = "specified"
10918    ) -> base.DecisionID:
10919        """
10920        Adds a new graph to the exploration that replaces the given
10921        transition at the current position (which must lead to an unknown
10922        node, or a `MissingDecisionError` will result). The new
10923        transition will connect back to the specified destination, which
10924        must already exist (or a different `ValueError` will be raised).
10925        Returns the decision ID for the destination reached.
10926
10927        Deduces transition details using the optional `fromDecision`,
10928        `whichFocus`, and `inCommon` arguments in addition to the
10929        `transition` value; see `deduceTransitionDetailsAtStep`.
10930
10931        If a `reciprocal` transition is specified, that transition must
10932        either not already exist in the destination decision or lead to
10933        an unknown region; it will be replaced (or added) as an edge
10934        leading back to the current position.
10935
10936        The `decisionType` and `challengePolicy` optional arguments are
10937        used for `advanceSituation`.
10938
10939        A `TransitionBlockedWarning` will be issued if the requirements
10940        for the transition are not met, but the step will still be taken.
10941        Raises a `MissingDecisionError` if there is no current
10942        transition.
10943        """
10944        now = self.getSituation()
10945
10946        transitionName, outcomes = base.nameAndOutcomes(transition)
10947
10948        # Deduce transition details from the name + optional specifiers
10949        (
10950            using,
10951            fromID,
10952            destID,
10953            whichFocus
10954        ) = self.deduceTransitionDetailsAtStep(
10955            -1,
10956            transitionName,
10957            fromDecision,
10958            whichFocus,
10959            inCommon
10960        )
10961
10962        # Replace with connection to existing destination
10963        destID = now.graph.resolveDecision(destination)
10964        if not self.hasBeenVisited(destID):
10965            raise ExplorationStatusError(
10966                f"Cannot return to decision"
10967                f" {now.graph.identityOf(destID)} because it has NOT"
10968                f" already been at least partially explored. Use"
10969                f" explore instead of returnTo when discovering a"
10970                f" connection to a previously-unexplored decision."
10971            )
10972
10973        now.graph.replaceUnconfirmed(
10974            fromID,
10975            transitionName,
10976            destID,
10977            reciprocal
10978        )
10979
10980        # A move-from-decision action
10981        actionTaken: base.ExplorationAction = (
10982            'take',
10983            using,
10984            fromID,
10985            (transitionName, outcomes)
10986        )
10987        if whichFocus is not None:
10988            # A move-from-specific-focal-point action
10989            actionTaken = ('take', whichFocus, (transitionName, outcomes))
10990
10991        # Next, advance the situation, applying transition effects
10992        _, finalDests = self.advanceSituation(
10993            actionTaken,
10994            decisionType,
10995            challengePolicy
10996        )
10997
10998        return self.mostApplicableDestination(
10999            now.graph,
11000            fromID,
11001            destID,
11002            finalDests
11003        )
11004
11005    def takeAction(
11006        self,
11007        action: base.AnyTransition,
11008        requires: Optional[base.Requirement] = None,
11009        consequence: Optional[base.Consequence] = None,
11010        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
11011        whichFocus: Optional[base.FocalPointSpecifier] = None,
11012        inCommon: Union[bool, Literal["auto"]] = "auto",
11013        decisionType: base.DecisionType = "active",
11014        challengePolicy: base.ChallengePolicy = "specified"
11015    ) -> base.DecisionID:
11016        """
11017        Adds a new graph to the exploration based on taking the given
11018        action, which must be a self-transition in the graph. If the
11019        action does not already exist in the graph, it will be created.
11020        Either way if requirements and/or a consequence are supplied,
11021        the requirements and consequence of the action will be updated
11022        to match them, and those are the requirements/consequence that
11023        will count.
11024
11025        Returns the decision ID for the decision reached, which normally
11026        is the same action you were just at, but which might be altered
11027        by goto, bounce, and/or follow effects.
11028
11029        Issues a `TransitionBlockedWarning` if the current game state
11030        doesn't satisfy the requirements for the action.
11031
11032        The `fromDecision`, `whichFocus`, and `inCommon` arguments are
11033        used for `deduceTransitionDetailsAtStep`, while `decisionType`
11034        and `challengePolicy` are used for `advanceSituation`.
11035
11036        When an action is being created, `fromDecision` (or
11037        `whichFocus`) must be specified, since the source decision won't
11038        be deducible from the transition name. Note that if a transition
11039        with the given name exists from *any* active decision, it will
11040        be used instead of creating a new action (possibly resulting in
11041        an error if it's not a self-loop transition). Also, you may get
11042        an `AmbiguousTransitionError` if several transitions with that
11043        name exist; in that case use `fromDecision` and/or `whichFocus`
11044        to disambiguate.
11045        """
11046        now = self.getSituation()
11047        graph = now.graph
11048
11049        actionName, outcomes = base.nameAndOutcomes(action)
11050
11051        try:
11052            (
11053                using,
11054                fromID,
11055                destID,
11056                whichFocus
11057            ) = self.deduceTransitionDetailsAtStep(
11058                -1,
11059                actionName,
11060                fromDecision,
11061                whichFocus,
11062                inCommon
11063            )
11064
11065            if destID != fromID:
11066                raise ValueError(
11067                    f"Cannot take action {repr(action)} because it's a"
11068                    f" transition to another decision, not an action"
11069                    f" (use explore, returnTo, and/or retrace instead)."
11070                )
11071
11072        except MissingTransitionError:
11073            using = 'active'
11074            if inCommon is True:
11075                using = 'common'
11076
11077            if fromDecision is not None:
11078                fromID = graph.resolveDecision(fromDecision)
11079            elif whichFocus is not None:
11080                maybeFromID = base.resolvePosition(now.state, whichFocus)
11081                if maybeFromID is None:
11082                    raise MissingDecisionError(
11083                        f"Focal point {repr(whichFocus)} was specified"
11084                        f" in takeAction but that focal point doesn't"
11085                        f" have a position."
11086                    )
11087                else:
11088                    fromID = maybeFromID
11089            else:
11090                raise AmbiguousTransitionError(
11091                    f"Taking action {repr(action)} is ambiguous because"
11092                    f" the source decision has not been specified via"
11093                    f" either fromDecision or whichFocus, and we"
11094                    f" couldn't find an existing action with that name."
11095                )
11096
11097            destID = fromID
11098
11099            # Since the action doesn't exist, add it:
11100            graph.addAction(fromID, actionName, requires, consequence)
11101
11102        # Update the transition requirement/consequence if requested
11103        # (before the action is taken)
11104        if requires is not None:
11105            graph.setTransitionRequirement(fromID, actionName, requires)
11106        if consequence is not None:
11107            graph.setConsequence(fromID, actionName, consequence)
11108
11109        # A move-from-decision action
11110        actionTaken: base.ExplorationAction = (
11111            'take',
11112            using,
11113            fromID,
11114            (actionName, outcomes)
11115        )
11116        if whichFocus is not None:
11117            # A move-from-specific-focal-point action
11118            actionTaken = ('take', whichFocus, (actionName, outcomes))
11119
11120        _, finalDests = self.advanceSituation(
11121            actionTaken,
11122            decisionType,
11123            challengePolicy
11124        )
11125
11126        return self.mostApplicableDestination(
11127            graph,
11128            fromID,
11129            destID,
11130            finalDests
11131        )
11132
11133    def retrace(
11134        self,
11135        transition: base.AnyTransition,
11136        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
11137        whichFocus: Optional[base.FocalPointSpecifier] = None,
11138        inCommon: Union[bool, Literal["auto"]] = "auto",
11139        decisionType: base.DecisionType = "active",
11140        challengePolicy: base.ChallengePolicy = "specified"
11141    ) -> base.DecisionID:
11142        """
11143        Adds a new graph to the exploration based on taking the given
11144        transition, which must already exist and which must not lead to
11145        an unknown region. Returns the ID of the destination decision,
11146        accounting for goto, bounce, and/or follow effects.
11147
11148        Issues a `TransitionBlockedWarning` if the current game state
11149        doesn't satisfy the requirements for the transition.
11150
11151        The `fromDecision`, `whichFocus`, and `inCommon` arguments are
11152        used for `deduceTransitionDetailsAtStep`, while `decisionType`
11153        and `challengePolicy` are used for `advanceSituation`.
11154        """
11155        now = self.getSituation()
11156
11157        transitionName, outcomes = base.nameAndOutcomes(transition)
11158
11159        (
11160            using,
11161            fromID,
11162            destID,
11163            whichFocus
11164        ) = self.deduceTransitionDetailsAtStep(
11165            -1,
11166            transitionName,
11167            fromDecision,
11168            whichFocus,
11169            inCommon
11170        )
11171
11172        visited = self.hasBeenVisited(destID)
11173        confirmed = now.graph.isConfirmed(destID)
11174        if not confirmed:
11175            raise ExplorationStatusError(
11176                f"Cannot retrace transition {transition!r} from"
11177                f" decision {now.graph.identityOf(fromID)} because it"
11178                f" leads to an unconfirmed decision.\nUse"
11179                f" `DiscreteExploration.explore` and provide"
11180                f" destination decision details instead."
11181            )
11182        if not visited:
11183            raise ExplorationStatusError(
11184                f"Cannot retrace transition {transition!r} from"
11185                f" decision {now.graph.identityOf(fromID)} because it"
11186                f" leads to an unvisited decision.\nUse"
11187                f" `DiscreteExploration.explore` and provide"
11188                f" destination decision details instead."
11189            )
11190
11191        # A move-from-decision action
11192        actionTaken: base.ExplorationAction = (
11193            'take',
11194            using,
11195            fromID,
11196            (transitionName, outcomes)
11197        )
11198        if whichFocus is not None:
11199            # A move-from-specific-focal-point action
11200            actionTaken = ('take', whichFocus, (transitionName, outcomes))
11201
11202        _, finalDests = self.advanceSituation(
11203            actionTaken,
11204            decisionType,
11205            challengePolicy
11206        )
11207
11208        return self.mostApplicableDestination(
11209            now.graph,
11210            fromID,
11211            destID,
11212            finalDests
11213        )
11214
11215    def warp(
11216        self,
11217        destination: base.AnyDecisionSpecifier,
11218        consequence: Optional[base.Consequence] = None,
11219        domain: Optional[base.Domain] = None,
11220        zone: Optional[base.Zone] = base.DefaultZone,
11221        whichFocus: Optional[base.FocalPointSpecifier] = None,
11222        inCommon: Union[bool] = False,
11223        decisionType: base.DecisionType = "active",
11224        challengePolicy: base.ChallengePolicy = "specified",
11225        allowNew: bool = False
11226    ) -> base.DecisionID:
11227        """
11228        Adds a new graph to the exploration that's a copy of the current
11229        graph, with the position updated to be at the destination without
11230        actually creating a transition from the old position to the new
11231        one. Returns the ID of the decision warped to (accounting for
11232        any goto or follow effects triggered).
11233
11234        Any provided consequences are applied, but are not associated
11235        with any transition (so any delays and charges are ignored, and
11236        'bounce' effects don't actually cancel the warp). 'goto' or
11237        'follow' effects might change the warp destination; 'follow'
11238        effects take the original destination as their starting point.
11239        Any mechanisms mentioned in extra consequences will be found
11240        based on the destination. Outcomes in supplied challenges should
11241        be pre-specified, or else they will be resolved with the
11242        `challengePolicy`.
11243
11244        `whichFocus` may be specified when the destination domain's
11245        focalization is 'plural' but for 'singular' or 'spreading'
11246        destination domains it is not allowed. `inCommon` determines
11247        whether the common or the active focal context is updated
11248        (default is to update the active context). The `decisionType`
11249        and `challengePolicy` are used for `advanceSituation`.
11250
11251        - If the destination did not already exist, it will be created if
11252            `allowNew` is `True` (default is `False`). If `allowNew` is
11253            `False` and the destination did not already exist, a
11254            `MissingDecisionError` will be raised. Initially, any
11255            newly-created decision will be disconnected from all other
11256            decisions. In this case, the `domain` value can be used to
11257            put it in a non-default domain.
11258        - The position is set to the specified destination, and if a
11259            `consequence` is specified it is applied. Note that
11260            'deactivate' effects are NOT allowed, and 'edit' effects
11261            must establish their own transition target because there is
11262            no transition that the effects are being applied to.
11263        - If the destination had been unexplored, its exploration status
11264            will be set to 'exploring'.
11265        - If a `zone` is specified, the destination will be added to that
11266            zone (even if the destination already existed) and that zone
11267            will be created (as a level-0 zone) if need be. If `zone` is
11268            set to `None`, then no zone will be applied. If `zone` is
11269            left as the default (`base.DefaultZone`) and the
11270            focalization of the destination domain is 'singular' or
11271            'plural' and the destination is newly created and there is
11272            an origin and the origin is in the same domain as the
11273            destination, then the destination will be added to all zones
11274            that the origin was a part of if the destination is newly
11275            created, but otherwise the destination will not be added to
11276            any zones. If the specified zone has to be created and
11277            there's an origin decision, it will be added as a sub-zone
11278            to all parents of zones directly containing the origin, as
11279            long as the origin is in the same domain as the destination.
11280        """
11281        now = self.getSituation()
11282        graph = now.graph
11283
11284        fromID: Optional[base.DecisionID]
11285
11286        new = False
11287        try:
11288            destID = graph.resolveDecision(destination)
11289        except MissingDecisionError:
11290            if not allowNew:
11291                raise
11292
11293            if isinstance(destination, tuple):
11294                # just the name; ignore zone/domain
11295                destination = destination[-1]
11296
11297            if not isinstance(destination, base.DecisionName):
11298                raise TypeError(
11299                    f"Warp destination {repr(destination)} does not"
11300                    f" exist, and cannot be created as it is not a"
11301                    f" decision name."
11302                )
11303            destID = graph.addDecision(destination, domain)
11304            graph.tagDecision(destID, 'unconfirmed')
11305            self.setExplorationStatus(destID, 'unknown')
11306            new = True
11307
11308        using: base.ContextSpecifier
11309        if inCommon:
11310            targetContext = self.getCommonContext()
11311            using = "common"
11312        else:
11313            targetContext = self.getActiveContext()
11314            using = "active"
11315
11316        destDomain = graph.domainFor(destID)
11317        targetFocalization = base.getDomainFocalization(
11318            targetContext,
11319            destDomain
11320        )
11321        if targetFocalization == 'singular':
11322            targetActive = targetContext['activeDecisions']
11323            if destDomain in targetActive:
11324                fromID = cast(
11325                    base.DecisionID,
11326                    targetContext['activeDecisions'][destDomain]
11327                )
11328            else:
11329                fromID = None
11330        elif targetFocalization == 'plural':
11331            if whichFocus is None:
11332                raise AmbiguousTransitionError(
11333                    f"Warping to {repr(destination)} is ambiguous"
11334                    f" becuase domain {repr(destDomain)} has plural"
11335                    f" focalization, and no whichFocus value was"
11336                    f" specified."
11337                )
11338
11339            fromID = base.resolvePosition(
11340                self.getSituation().state,
11341                whichFocus
11342            )
11343        else:
11344            fromID = None
11345
11346        # Handle zones
11347        if zone == base.DefaultZone:
11348            if (
11349                new
11350            and fromID is not None
11351            and graph.domainFor(fromID) == destDomain
11352            ):
11353                for prevZone in graph.zoneParents(fromID):
11354                    graph.addDecisionToZone(destination, prevZone)
11355            # Otherwise don't update zones
11356        elif zone is not None:
11357            # Newness is ignored when a zone is specified
11358            zone = cast(base.Zone, zone)
11359            # Create the zone at level 0 if it didn't already exist
11360            if graph.getZoneInfo(zone) is None:
11361                graph.createZone(zone, 0)
11362                # Add the newly created zone to each 2nd-level parent of
11363                # the previous decision if there is one and it's in the
11364                # same domain
11365                if (
11366                    fromID is not None
11367                and graph.domainFor(fromID) == destDomain
11368                ):
11369                    for prevZone in graph.zoneParents(fromID):
11370                        for prevUpper in graph.zoneParents(prevZone):
11371                            graph.addZoneToZone(zone, prevUpper)
11372            # Finally add the destination to the (maybe new) zone
11373            graph.addDecisionToZone(destID, zone)
11374        # else don't touch zones
11375
11376        # Encode the action taken
11377        actionTaken: base.ExplorationAction
11378        if whichFocus is None:
11379            actionTaken = (
11380                'warp',
11381                using,
11382                destID
11383            )
11384        else:
11385            actionTaken = (
11386                'warp',
11387                whichFocus,
11388                destID
11389            )
11390
11391        # Advance the situation
11392        _, finalDests = self.advanceSituation(
11393            actionTaken,
11394            decisionType,
11395            challengePolicy
11396        )
11397        now = self.getSituation()  # updating just in case
11398
11399        baseID = fromID
11400        if baseID is None:
11401            baseID = destID
11402
11403        finalDest = self.mostApplicableDestination(
11404            now.graph,
11405            baseID,
11406            destID,
11407            finalDests
11408        )
11409
11410        # Apply additional consequences:
11411        if consequence is not None:
11412            altDest = self.applyExtraneousConsequence(
11413                consequence,
11414                where=(destID, None),
11415                # TODO: Mechanism search from both ends?
11416                moveWhich=(
11417                    whichFocus[-1]
11418                    if whichFocus is not None
11419                    else None
11420                )
11421            )
11422            if altDest is not None:
11423                finalDest = altDest
11424            now = self.getSituation()  # updating just in case
11425
11426        return finalDest
11427
11428    def wait(
11429        self,
11430        consequence: Optional[base.Consequence] = None,
11431        decisionType: base.DecisionType = "active",
11432        challengePolicy: base.ChallengePolicy = "specified"
11433    ) -> Optional[base.DecisionID]:
11434        """
11435        Adds a wait step. If a consequence is specified, it is applied,
11436        although it will not have any position/transition information
11437        available during resolution/application.
11438
11439        A decision type other than "active" and/or a challenge policy
11440        other than "specified" can be included (see `advanceSituation`).
11441
11442        The "pending" decision type may not be used, a `ValueError` will
11443        result. This allows None as the action for waiting while
11444        preserving the pending/None type/action combination for
11445        unresolved situations.
11446
11447        If a goto or follow effect in the applied consequence implies a
11448        position update, this will return the new destination ID;
11449        otherwise it will return `None`. Triggering a 'bounce' effect
11450        will be an error, because there is no position information for
11451        the effect.
11452        """
11453        if decisionType == "pending":
11454            raise ValueError(
11455                "The 'pending' decision type may not be used for"
11456                " wait actions."
11457            )
11458        self.advanceSituation(('noAction',), decisionType, challengePolicy)
11459        now = self.getSituation()
11460        if consequence is not None:
11461            if challengePolicy != "specified":
11462                base.resetChallengeOutcomes(consequence)
11463            observed = base.observeChallengeOutcomes(
11464                base.RequirementContext(
11465                    state=now.state,
11466                    graph=now.graph,
11467                    searchFrom=set()
11468                ),
11469                consequence,
11470                location=None,  # No position info
11471                policy=challengePolicy,
11472                knownOutcomes=None  # bake outcomes into the consequence
11473            )
11474            # No location information since we might have multiple
11475            # active decisions and there's no indication of which one
11476            # we're "waiting at."
11477            finalDest = self.applyExtraneousConsequence(observed)
11478            now = self.getSituation()  # updating just in case
11479
11480            return finalDest
11481        else:
11482            return None
11483
11484    def revert(
11485        self,
11486        slot: base.SaveSlot = base.DEFAULT_SAVE_SLOT,
11487        aspects: Optional[Set[str]] = None,
11488        decisionType: base.DecisionType = "active"
11489    ) -> None:
11490        """
11491        Reverts the game state to a previously-saved game state (saved
11492        via a 'save' effect). The save slot name and set of aspects to
11493        revert are required. By default, all aspects except the graph
11494        are reverted.
11495        """
11496        if aspects is None:
11497            aspects = set()
11498
11499        action: base.ExplorationAction = ("revertTo", slot, aspects)
11500
11501        self.advanceSituation(action, decisionType)
11502
11503    def observeAll(
11504        self,
11505        where: base.AnyDecisionSpecifier,
11506        *transitions: Union[
11507            base.Transition,
11508            Tuple[base.Transition, base.AnyDecisionSpecifier],
11509            Tuple[
11510                base.Transition,
11511                base.AnyDecisionSpecifier,
11512                base.Transition
11513            ]
11514        ]
11515    ) -> List[base.DecisionID]:
11516        """
11517        Observes one or more new transitions, applying changes to the
11518        current graph. The transitions can be specified in one of three
11519        ways:
11520
11521        1. A transition name. The transition will be created and will
11522            point to a new unexplored node.
11523        2. A pair containing a transition name and a destination
11524            specifier. If the destination does not exist it will be
11525            created as an unexplored node, although in that case the
11526            decision specifier may not be an ID.
11527        3. A triple containing a transition name, a destination
11528            specifier, and a reciprocal name. Works the same as the pair
11529            case but also specifies the name for the reciprocal
11530            transition.
11531
11532        The new transitions are outgoing from specified decision.
11533
11534        Yields the ID of each decision connected to, whether those are
11535        new or existing decisions.
11536        """
11537        now = self.getSituation()
11538        fromID = now.graph.resolveDecision(where)
11539        result = []
11540        for entry in transitions:
11541            if isinstance(entry, base.Transition):
11542                result.append(self.observe(fromID, entry))
11543            else:
11544                result.append(self.observe(fromID, *entry))
11545        return result
11546
11547    def observe(
11548        self,
11549        where: base.AnyDecisionSpecifier,
11550        transition: base.Transition,
11551        destination: Optional[base.AnyDecisionSpecifier] = None,
11552        reciprocal: Optional[base.Transition] = None
11553    ) -> base.DecisionID:
11554        """
11555        Observes a single new outgoing transition from the specified
11556        decision. If specified the transition connects to a specific
11557        destination and/or has a specific reciprocal. The specified
11558        destination will be created if it doesn't exist, or where no
11559        destination is specified, a new unexplored decision will be
11560        added. The ID of the decision connected to is returned.
11561
11562        Sets the exploration status of the observed destination to
11563        "noticed" if a destination is specified and needs to be created
11564        (but not when no destination is specified).
11565
11566        For example:
11567
11568        >>> e = DiscreteExploration()
11569        >>> e.start('start')
11570        0
11571        >>> e.observe('start', 'up')
11572        1
11573        >>> g = e.getSituation().graph
11574        >>> g.destinationsFrom('start')
11575        {'up': 1}
11576        >>> e.getExplorationStatus(1)  # not given a name: assumed unknown
11577        'unknown'
11578        >>> e.observe('start', 'left', 'A')
11579        2
11580        >>> g.destinationsFrom('start')
11581        {'up': 1, 'left': 2}
11582        >>> g.nameFor(2)
11583        'A'
11584        >>> e.getExplorationStatus(2)  # given a name: noticed
11585        'noticed'
11586        >>> e.observe('start', 'up2', 1)
11587        1
11588        >>> g.destinationsFrom('start')
11589        {'up': 1, 'left': 2, 'up2': 1}
11590        >>> e.getExplorationStatus(1)  # existing decision: status unchanged
11591        'unknown'
11592        >>> e.observe('start', 'right', 'B', 'left')
11593        3
11594        >>> g.destinationsFrom('start')
11595        {'up': 1, 'left': 2, 'up2': 1, 'right': 3}
11596        >>> g.nameFor(3)
11597        'B'
11598        >>> e.getExplorationStatus(3)  # new + name -> noticed
11599        'noticed'
11600        >>> e.observe('start', 'right')  # repeat transition name
11601        Traceback (most recent call last):
11602        ...
11603        exploration.core.TransitionCollisionError...
11604        >>> e.observe('start', 'right2', 'B', 'left')  # repeat reciprocal
11605        Traceback (most recent call last):
11606        ...
11607        exploration.core.TransitionCollisionError...
11608        >>> g = e.getSituation().graph
11609        >>> g.createZone('Z', 0)
11610        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
11611 annotations=[])
11612        >>> g.addDecisionToZone('start', 'Z')
11613        >>> e.observe('start', 'down', 'C', 'up')
11614        4
11615        >>> g.destinationsFrom('start')
11616        {'up': 1, 'left': 2, 'up2': 1, 'right': 3, 'down': 4}
11617        >>> g.identityOf('C')
11618        '4 (C)'
11619        >>> g.zoneParents(4)  # not in any zones, 'cause still unexplored
11620        set()
11621        >>> e.observe(
11622        ...     'C',
11623        ...     'right',
11624        ...     base.DecisionSpecifier('main', 'Z2', 'D'),
11625        ... )  # creates zone
11626        5
11627        >>> g.destinationsFrom('C')
11628        {'up': 0, 'right': 5}
11629        >>> g.destinationsFrom('D')  # no reciprocal if not specified
11630        {}
11631        >>> g.identityOf('D')
11632        '5 (Z2::D)'
11633        >>> g.zoneParents(5)
11634        {'Z2'}
11635        """
11636        now = self.getSituation()
11637        fromID = now.graph.resolveDecision(where)
11638
11639        kwargs: Dict[
11640            str,
11641            Union[base.Transition, base.DecisionName, None]
11642        ] = {}
11643        if reciprocal is not None:
11644            kwargs['reciprocal'] = reciprocal
11645
11646        if destination is not None:
11647            try:
11648                destID = now.graph.resolveDecision(destination)
11649                now.graph.addTransition(
11650                    fromID,
11651                    transition,
11652                    destID,
11653                    reciprocal
11654                )
11655                return destID
11656            except MissingDecisionError:
11657                if isinstance(destination, base.DecisionSpecifier):
11658                    kwargs['toDomain'] = destination.domain
11659                    kwargs['placeInZone'] = destination.zone
11660                    kwargs['destinationName'] = destination.name
11661                elif isinstance(destination, base.DecisionName):
11662                    kwargs['destinationName'] = destination
11663                else:
11664                    assert isinstance(destination, base.DecisionID)
11665                    # We got to except by failing to resolve, so it's an
11666                    # invalid ID
11667                    raise
11668
11669        result = now.graph.addUnexploredEdge(
11670            fromID,
11671            transition,
11672            **kwargs  # type: ignore [arg-type]
11673        )
11674        if 'destinationName' in kwargs:
11675            self.setExplorationStatus(result, 'noticed', upgradeOnly=True)
11676        return result
11677
11678    def observeMechanisms(
11679        self,
11680        where: Optional[base.AnyDecisionSpecifier],
11681        *mechanisms: Union[
11682            base.MechanismName,
11683            Tuple[base.MechanismName, base.MechanismState]
11684        ]
11685    ) -> List[base.MechanismID]:
11686        """
11687        Adds one or more mechanisms to the exploration's current graph,
11688        located at the specified decision. Global mechanisms can be
11689        added by using `None` for the location. Mechanisms are named, or
11690        a (name, state) tuple can be used to set them into a specific
11691        state. Mechanisms not set to a state will be in the
11692        `base.DEFAULT_MECHANISM_STATE`.
11693        """
11694        now = self.getSituation()
11695        result = []
11696        for mSpec in mechanisms:
11697            setState = None
11698            if isinstance(mSpec, base.MechanismName):
11699                result.append(now.graph.addMechanism(mSpec, where))
11700            elif (
11701                isinstance(mSpec, tuple)
11702            and len(mSpec) == 2
11703            and isinstance(mSpec[0], base.MechanismName)
11704            and isinstance(mSpec[1], base.MechanismState)
11705            ):
11706                result.append(now.graph.addMechanism(mSpec[0], where))
11707                setState = mSpec[1]
11708            else:
11709                raise TypeError(
11710                    f"Invalid mechanism: {repr(mSpec)} (must be a"
11711                    f" mechanism name or a (name, state) tuple."
11712                )
11713
11714            if setState:
11715                self.setMechanismStateNow(result[-1], setState)
11716
11717        return result
11718
11719    def reZone(
11720        self,
11721        zone: Optional[base.Zone],
11722        where: base.AnyDecisionSpecifier,
11723        replace: Union[base.Zone, int] = 0
11724    ) -> None:
11725        """
11726        Alters the current graph without adding a new exploration step.
11727
11728        When given an integer `replace` value, calls
11729        `DecisionGraph.replaceZonesInHierarchy` targeting the
11730        specified decision, replacing ALL zones at the specified
11731        hierarchy level.
11732
11733        If given a zone to replace instead, replaces just that zone by
11734        thoroughly removing the given decision from that zone and then
11735        adding it to the new target zone directly. Thorough removal may
11736        affect membership in other zones...
11737
11738        Use `None` as the zone name to instead remove the current
11739        decision from all zones at the specified hierarchy level, or
11740        from the specified single zone (this uses thorough removal so
11741        may affect membership in lower-level zones).
11742        """
11743        graph = self.getSituation().graph
11744        dID = graph.resolveDecision(where)
11745
11746        if isinstance(replace, int):
11747            # Replace/discard all zones at level
11748            if zone is None:
11749                # Remove from ALL zones at specified level
11750                for escape in graph.zoneAncestors(dID, atLevel=replace):
11751                    graph.removeDecisionFromZone(dID, escape, True)
11752            else:
11753                graph.replaceZonesInHierarchy(dID, zone, replace)
11754        else:
11755            # Replace specific zone
11756            graph.removeDecisionFromZone(dID, replace, True)
11757            if zone is not None:
11758                graph.addDecisionToZone(dID, zone)
11759
11760    def runCommand(
11761        self,
11762        command: commands.Command,
11763        scope: Optional[commands.Scope] = None,
11764        line: int = -1
11765    ) -> commands.CommandResult:
11766        """
11767        Runs a single `Command` applying effects to the exploration, its
11768        current graph, and the provided execution context, and returning
11769        a command result, which contains the modified scope plus
11770        optional skip and label values (see `CommandResult`). This
11771        function also directly modifies the scope you give it. Variable
11772        references in the command are resolved via entries in the
11773        provided scope. If no scope is given, an empty one is created.
11774
11775        A line number may be supplied for use in error messages; if left
11776        out line -1 will be used.
11777
11778        Raises an error if the command is invalid.
11779
11780        For commands that establish a value as the 'current value', that
11781        value will be stored in the '_' variable. When this happens, the
11782        old contents of '_' are stored in '__' first, and the old
11783        contents of '__' are discarded. Note that non-automatic
11784        assignment to '_' does not move the old value to '__'.
11785        """
11786        try:
11787            if scope is None:
11788                scope = {}
11789
11790            skip: Union[int, str, None] = None
11791            label: Optional[str] = None
11792
11793            if command.command == 'val':
11794                command = cast(commands.LiteralValue, command)
11795                result = commands.resolveValue(command.value, scope)
11796                commands.pushCurrentValue(scope, result)
11797
11798            elif command.command == 'empty':
11799                command = cast(commands.EstablishCollection, command)
11800                collection = commands.resolveVarName(command.collection, scope)
11801                commands.pushCurrentValue(
11802                    scope,
11803                    {
11804                        'list': [],
11805                        'tuple': (),
11806                        'set': set(),
11807                        'dict': {},
11808                    }[collection]
11809                )
11810
11811            elif command.command == 'append':
11812                command = cast(commands.AppendValue, command)
11813                target = scope['_']
11814                addIt = commands.resolveValue(command.value, scope)
11815                if isinstance(target, list):
11816                    target.append(addIt)
11817                elif isinstance(target, tuple):
11818                    scope['_'] = target + (addIt,)
11819                elif isinstance(target, set):
11820                    target.add(addIt)
11821                elif isinstance(target, dict):
11822                    raise TypeError(
11823                        "'append' command cannot be used with a"
11824                        " dictionary. Use 'set' instead."
11825                    )
11826                else:
11827                    raise TypeError(
11828                        f"Invalid current value for 'append' command."
11829                        f" The current value must be a list, tuple, or"
11830                        f" set, but it was a '{type(target).__name__}'."
11831                    )
11832
11833            elif command.command == 'set':
11834                command = cast(commands.SetValue, command)
11835                target = scope['_']
11836                where = commands.resolveValue(command.location, scope)
11837                what = commands.resolveValue(command.value, scope)
11838                if isinstance(target, list):
11839                    if not isinstance(where, int):
11840                        raise TypeError(
11841                            f"Cannot set item in list: index {where!r}"
11842                            f" is not an integer."
11843                        )
11844                    target[where] = what
11845                elif isinstance(target, tuple):
11846                    if not isinstance(where, int):
11847                        raise TypeError(
11848                            f"Cannot set item in tuple: index {where!r}"
11849                            f" is not an integer."
11850                        )
11851                    if not (
11852                        0 <= where < len(target)
11853                    or -1 >= where >= -len(target)
11854                    ):
11855                        raise IndexError(
11856                            f"Cannot set item in tuple at index"
11857                            f" {where}: Tuple has length {len(target)}."
11858                        )
11859                    scope['_'] = target[:where] + (what,) + target[where + 1:]
11860                elif isinstance(target, set):
11861                    if what:
11862                        target.add(where)
11863                    else:
11864                        try:
11865                            target.remove(where)
11866                        except KeyError:
11867                            pass
11868                elif isinstance(target, dict):
11869                    target[where] = what
11870
11871            elif command.command == 'pop':
11872                command = cast(commands.PopValue, command)
11873                target = scope['_']
11874                if isinstance(target, list):
11875                    result = target.pop()
11876                    commands.pushCurrentValue(scope, result)
11877                elif isinstance(target, tuple):
11878                    result = target[-1]
11879                    updated = target[:-1]
11880                    scope['__'] = updated
11881                    scope['_'] = result
11882                else:
11883                    raise TypeError(
11884                        f"Cannot 'pop' from a {type(target).__name__}"
11885                        f" (current value must be a list or tuple)."
11886                    )
11887
11888            elif command.command == 'get':
11889                command = cast(commands.GetValue, command)
11890                target = scope['_']
11891                where = commands.resolveValue(command.location, scope)
11892                if isinstance(target, list):
11893                    if not isinstance(where, int):
11894                        raise TypeError(
11895                            f"Cannot get item from list: index"
11896                            f" {where!r} is not an integer."
11897                        )
11898                elif isinstance(target, tuple):
11899                    if not isinstance(where, int):
11900                        raise TypeError(
11901                            f"Cannot get item from tuple: index"
11902                            f" {where!r} is not an integer."
11903                        )
11904                elif isinstance(target, set):
11905                    result = where in target
11906                    commands.pushCurrentValue(scope, result)
11907                elif isinstance(target, dict):
11908                    result = target[where]
11909                    commands.pushCurrentValue(scope, result)
11910                else:
11911                    result = getattr(target, where)
11912                    commands.pushCurrentValue(scope, result)
11913
11914            elif command.command == 'remove':
11915                command = cast(commands.RemoveValue, command)
11916                target = scope['_']
11917                where = commands.resolveValue(command.location, scope)
11918                if isinstance(target, (list, tuple)):
11919                    # this cast is not correct but suppresses warnings
11920                    # given insufficient narrowing by MyPy
11921                    target = cast(Tuple[Any, ...], target)
11922                    if not isinstance(where, int):
11923                        raise TypeError(
11924                            f"Cannot remove item from list or tuple:"
11925                            f" index {where!r} is not an integer."
11926                        )
11927                    scope['_'] = target[:where] + target[where + 1:]
11928                elif isinstance(target, set):
11929                    target.remove(where)
11930                elif isinstance(target, dict):
11931                    del target[where]
11932                else:
11933                    raise TypeError(
11934                        f"Cannot use 'remove' on a/an"
11935                        f" {type(target).__name__}."
11936                    )
11937
11938            elif command.command == 'op':
11939                command = cast(commands.ApplyOperator, command)
11940                left = commands.resolveValue(command.left, scope)
11941                right = commands.resolveValue(command.right, scope)
11942                op = command.op
11943                if op == '+':
11944                    result = left + right
11945                elif op == '-':
11946                    result = left - right
11947                elif op == '*':
11948                    result = left * right
11949                elif op == '/':
11950                    result = left / right
11951                elif op == '//':
11952                    result = left // right
11953                elif op == '**':
11954                    result = left ** right
11955                elif op == '%':
11956                    result = left % right
11957                elif op == '^':
11958                    result = left ^ right
11959                elif op == '|':
11960                    result = left | right
11961                elif op == '&':
11962                    result = left & right
11963                elif op == 'and':
11964                    result = left and right
11965                elif op == 'or':
11966                    result = left or right
11967                elif op == '<':
11968                    result = left < right
11969                elif op == '>':
11970                    result = left > right
11971                elif op == '<=':
11972                    result = left <= right
11973                elif op == '>=':
11974                    result = left >= right
11975                elif op == '==':
11976                    result = left == right
11977                elif op == 'is':
11978                    result = left is right
11979                else:
11980                    raise RuntimeError("Invalid operator '{op}'.")
11981
11982                commands.pushCurrentValue(scope, result)
11983
11984            elif command.command == 'unary':
11985                command = cast(commands.ApplyUnary, command)
11986                value = commands.resolveValue(command.value, scope)
11987                op = command.op
11988                if op == '-':
11989                    result = -value
11990                elif op == '~':
11991                    result = ~value
11992                elif op == 'not':
11993                    result = not value
11994
11995                commands.pushCurrentValue(scope, result)
11996
11997            elif command.command == 'assign':
11998                command = cast(commands.VariableAssignment, command)
11999                varname = commands.resolveVarName(command.varname, scope)
12000                value = commands.resolveValue(command.value, scope)
12001                scope[varname] = value
12002
12003            elif command.command == 'delete':
12004                command = cast(commands.VariableDeletion, command)
12005                varname = commands.resolveVarName(command.varname, scope)
12006                del scope[varname]
12007
12008            elif command.command == 'load':
12009                command = cast(commands.LoadVariable, command)
12010                varname = commands.resolveVarName(command.varname, scope)
12011                commands.pushCurrentValue(scope, scope[varname])
12012
12013            elif command.command == 'call':
12014                command = cast(commands.FunctionCall, command)
12015                function = command.function
12016                if function.startswith('$'):
12017                    function = commands.resolveValue(function, scope)
12018
12019                toCall: Callable
12020                args: Tuple[str, ...]
12021                kwargs: Dict[str, Any]
12022
12023                if command.target == 'builtin':
12024                    toCall = commands.COMMAND_BUILTINS[function]
12025                    args = (scope['_'],)
12026                    kwargs = {}
12027                    if toCall == round:
12028                        if 'ndigits' in scope:
12029                            kwargs['ndigits'] = scope['ndigits']
12030                    elif toCall == range and args[0] is None:
12031                        start = scope.get('start', 0)
12032                        stop = scope['stop']
12033                        step = scope.get('step', 1)
12034                        args = (start, stop, step)
12035
12036                else:
12037                    if command.target == 'stored':
12038                        toCall = function
12039                    elif command.target == 'graph':
12040                        toCall = getattr(self.getSituation().graph, function)
12041                    elif command.target == 'exploration':
12042                        toCall = getattr(self, function)
12043                    else:
12044                        raise TypeError(
12045                            f"Invalid call target '{command.target}'"
12046                            f" (must be one of 'builtin', 'stored',"
12047                            f" 'graph', or 'exploration'."
12048                        )
12049
12050                    # Fill in arguments via kwargs defined in scope
12051                    args = ()
12052                    kwargs = {}
12053                    signature = inspect.signature(toCall)
12054                    # TODO: Maybe try some type-checking here?
12055                    for argName, param in signature.parameters.items():
12056                        if param.kind == inspect.Parameter.VAR_POSITIONAL:
12057                            if argName in scope:
12058                                args = args + tuple(scope[argName])
12059                            # Else leave args as-is
12060                        elif param.kind == inspect.Parameter.KEYWORD_ONLY:
12061                            # These must have a default
12062                            if argName in scope:
12063                                kwargs[argName] = scope[argName]
12064                        elif param.kind == inspect.Parameter.VAR_KEYWORD:
12065                            # treat as a dictionary
12066                            if argName in scope:
12067                                argsToUse = scope[argName]
12068                                if not isinstance(argsToUse, dict):
12069                                    raise TypeError(
12070                                        f"Variable '{argName}' must"
12071                                        f" hold a dictionary when"
12072                                        f" calling function"
12073                                        f" '{toCall.__name__} which"
12074                                        f" uses that argument as a"
12075                                        f" keyword catchall."
12076                                    )
12077                                kwargs.update(scope[argName])
12078                        else:  # a normal parameter
12079                            if argName in scope:
12080                                args = args + (scope[argName],)
12081                            elif param.default == inspect.Parameter.empty:
12082                                raise TypeError(
12083                                    f"No variable named '{argName}' has"
12084                                    f" been defined to supply the"
12085                                    f" required parameter with that"
12086                                    f" name for function"
12087                                    f" '{toCall.__name__}'."
12088                                )
12089
12090                result = toCall(*args, **kwargs)
12091                commands.pushCurrentValue(scope, result)
12092
12093            elif command.command == 'skip':
12094                command = cast(commands.SkipCommands, command)
12095                doIt = commands.resolveValue(command.condition, scope)
12096                if doIt:
12097                    skip = commands.resolveValue(command.amount, scope)
12098                    if not isinstance(skip, (int, str)):
12099                        raise TypeError(
12100                            f"Skip amount must be an integer or a label"
12101                            f" name (got {skip!r})."
12102                        )
12103
12104            elif command.command == 'label':
12105                command = cast(commands.Label, command)
12106                label = commands.resolveValue(command.name, scope)
12107                if not isinstance(label, str):
12108                    raise TypeError(
12109                        f"Label name must be a string (got {label!r})."
12110                    )
12111
12112            else:
12113                raise ValueError(
12114                    f"Invalid command type: {command.command!r}"
12115                )
12116        except ValueError as e:
12117            raise commands.CommandValueError(command, line, e)
12118        except TypeError as e:
12119            raise commands.CommandTypeError(command, line, e)
12120        except IndexError as e:
12121            raise commands.CommandIndexError(command, line, e)
12122        except KeyError as e:
12123            raise commands.CommandKeyError(command, line, e)
12124        except Exception as e:
12125            raise commands.CommandOtherError(command, line, e)
12126
12127        return (scope, skip, label)
12128
12129    def runCommandBlock(
12130        self,
12131        block: List[commands.Command],
12132        scope: Optional[commands.Scope] = None
12133    ) -> commands.Scope:
12134        """
12135        Runs a list of commands, using the given scope (or creating a new
12136        empty scope if none was provided). Returns the scope after
12137        running all of the commands, which may also edit the exploration
12138        and/or the current graph of course.
12139
12140        Note that if a skip command would skip past the end of the
12141        block, execution will end. If a skip command would skip before
12142        the beginning of the block, execution will start from the first
12143        command.
12144
12145        Example:
12146
12147        >>> e = DiscreteExploration()
12148        >>> scope = e.runCommandBlock([
12149        ...    commands.command('assign', 'decision', "'START'"),
12150        ...    commands.command('call', 'exploration', 'start'),
12151        ...    commands.command('assign', 'where', '$decision'),
12152        ...    commands.command('assign', 'transition', "'left'"),
12153        ...    commands.command('call', 'exploration', 'observe'),
12154        ...    commands.command('assign', 'transition', "'right'"),
12155        ...    commands.command('call', 'exploration', 'observe'),
12156        ...    commands.command('call', 'graph', 'destinationsFrom'),
12157        ...    commands.command('call', 'builtin', 'print'),
12158        ...    commands.command('assign', 'transition', "'right'"),
12159        ...    commands.command('assign', 'destination', "'EastRoom'"),
12160        ...    commands.command('call', 'exploration', 'explore'),
12161        ... ])
12162        {'left': 1, 'right': 2}
12163        >>> scope['decision']
12164        'START'
12165        >>> scope['where']
12166        'START'
12167        >>> scope['_']  # result of 'explore' call is dest ID
12168        2
12169        >>> scope['transition']
12170        'right'
12171        >>> scope['destination']
12172        'EastRoom'
12173        >>> g = e.getSituation().graph
12174        >>> len(e)
12175        3
12176        >>> len(g)
12177        3
12178        >>> g.namesListing(g)
12179        '  0 (START)\\n  1 (_u.0)\\n  2 (EastRoom)\\n'
12180        """
12181        if scope is None:
12182            scope = {}
12183
12184        labelPositions: Dict[str, List[int]] = {}
12185
12186        # Keep going until we've exhausted the commands list
12187        index = 0
12188        while index < len(block):
12189
12190            # Execute the next command
12191            scope, skip, label = self.runCommand(
12192                block[index],
12193                scope,
12194                index + 1
12195            )
12196
12197            # Increment our index, or apply a skip
12198            if skip is None:
12199                index = index + 1
12200
12201            elif isinstance(skip, int):  # Integer skip value
12202                if skip < 0:
12203                    index += skip
12204                    if index < 0:  # can't skip before the start
12205                        index = 0
12206                else:
12207                    index += skip + 1  # may end loop if we skip too far
12208
12209            else:  # must be a label name
12210                if skip in labelPositions:  # an established label
12211                    # We jump to the last previous index, or if there
12212                    # are none, to the first future index.
12213                    prevIndices = [
12214                        x
12215                        for x in labelPositions[skip]
12216                        if x < index
12217                    ]
12218                    futureIndices = [
12219                        x
12220                        for x in labelPositions[skip]
12221                        if x >= index
12222                    ]
12223                    if len(prevIndices) > 0:
12224                        index = max(prevIndices)
12225                    else:
12226                        index = min(futureIndices)
12227                else:  # must be a forward-reference
12228                    for future in range(index + 1, len(block)):
12229                        inspect = block[future]
12230                        if inspect.command == 'label':
12231                            inspect = cast(commands.Label, inspect)
12232                            if inspect.name == skip:
12233                                index = future
12234                                break
12235                    else:
12236                        raise KeyError(
12237                            f"Skip command indicated a jump to label"
12238                            f" {skip!r} but that label had not already"
12239                            f" been defined and there is no future"
12240                            f" label with that name either (future"
12241                            f" labels based on variables cannot be"
12242                            f" skipped to from above as their names"
12243                            f" are not known yet)."
12244                        )
12245
12246            # If there's a label, record it
12247            if label is not None:
12248                labelPositions.setdefault(label, []).append(index)
12249
12250            # And now the while loop continues, or ends if we're at the
12251            # end of the commands list.
12252
12253        # Return the scope object.
12254        return scope
12255
12256    @staticmethod
12257    def example() -> 'DiscreteExploration':
12258        """
12259        Returns a little example exploration. Has a few decisions
12260        including one that's unexplored, and uses a few steps to explore
12261        them.
12262
12263        >>> e = DiscreteExploration.example()
12264        >>> len(e)
12265        7
12266        >>> def pg(n):
12267        ...     print(e[n].graph.namesListing(e[n].graph))
12268        >>> pg(0)
12269          0 (House)
12270        <BLANKLINE>
12271        >>> pg(1)
12272          0 (House)
12273          1 (_u.0)
12274          2 (_u.1)
12275          3 (_u.2)
12276        <BLANKLINE>
12277        >>> pg(2)
12278          0 (House)
12279          1 (_u.0)
12280          2 (_u.1)
12281          3 (Yard)
12282          4 (_u.3)
12283          5 (_u.4)
12284        <BLANKLINE>
12285        >>> pg(3)
12286          0 (House)
12287          1 (_u.0)
12288          2 (_u.1)
12289          3 (Yard)
12290          4 (_u.3)
12291          5 (_u.4)
12292        <BLANKLINE>
12293        >>> pg(4)
12294          0 (House)
12295          1 (_u.0)
12296          2 (Cellar)
12297          3 (Yard)
12298          5 (_u.4)
12299        <BLANKLINE>
12300        >>> pg(5)
12301          0 (House)
12302          1 (_u.0)
12303          2 (Cellar)
12304          3 (Yard)
12305          5 (_u.4)
12306        <BLANKLINE>
12307        >>> pg(6)
12308          0 (House)
12309          1 (_u.0)
12310          2 (Cellar)
12311          3 (Yard)
12312          5 (Lane)
12313        <BLANKLINE>
12314        """
12315        result = DiscreteExploration()
12316        result.start("House")
12317        result.observeAll("House", "ladder", "stairsDown", "frontDoor")
12318        result.explore("frontDoor", "Yard", "frontDoor")
12319        result.observe("Yard", "cellarDoors")
12320        result.observe("Yard", "frontGate")
12321        result.retrace("frontDoor")
12322        result.explore("stairsDown", "Cellar", "stairsUp")
12323        result.observe("Cellar", "stairsOut")
12324        result.returnTo("stairsOut", "Yard", "cellarDoors")
12325        result.explore("frontGate", "Lane", "redGate")
12326        return result
ENDINGS_DOMAIN = 'endings'

Domain value for endings.

TRIGGERS_DOMAIN = 'triggers'

Domain value for triggers.

LookupResult = ~LookupResult

A type variable for lookup results from the generic DecisionGraph.localLookup function.

LookupLayersList = typing.List[typing.Union[NoneType, int, str]]

A list of layers to look things up in, consisting of None for the starting provided decision set, integers for zone heights, and some custom strings like "fallback" and "all" for fallback sets.

class DecisionInfo(typing.TypedDict):
78class DecisionInfo(TypedDict):
79    """
80    The information stored per-decision in a `DecisionGraph` includes
81    the decision name (since the key is a decision ID), the domain, a
82    tags dictionary, and an annotations list.
83    """
84    name: base.DecisionName
85    domain: base.Domain
86    tags: Dict[base.Tag, base.TagValue]
87    annotations: List[base.Annotation]

The information stored per-decision in a DecisionGraph includes the decision name (since the key is a decision ID), the domain, a tags dictionary, and an annotations list.

name: str
domain: str
tags: Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]
annotations: List[str]
class TransitionProperties(typing.TypedDict):
 94class TransitionProperties(TypedDict, total=False):
 95    """
 96    Represents bundled properties of a transition, including a
 97    requirement, effects, tags, and/or annotations. Does not include the
 98    reciprocal. Has the following slots:
 99
100    - `'requirement'`: The requirement for the transition. This is
101        always a `Requirement`, although it might be `ReqNothing` if
102        nothing special is required.
103    - `'consequence'`: The `Consequence` of the transition.
104    - `'tags'`: Any tags applied to the transition (as a dictionary).
105    - `'annotations'`: A list of annotations applied to the transition.
106    """
107    requirement: base.Requirement
108    consequence: base.Consequence
109    tags: Dict[base.Tag, base.TagValue]
110    annotations: List[base.Annotation]

Represents bundled properties of a transition, including a requirement, effects, tags, and/or annotations. Does not include the reciprocal. Has the following slots:

  • 'requirement': The requirement for the transition. This is always a Requirement, although it might be ReqNothing if nothing special is required.
  • 'consequence': The Consequence of the transition.
  • 'tags': Any tags applied to the transition (as a dictionary).
  • 'annotations': A list of annotations applied to the transition.
tags: Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]
annotations: List[str]
def mergeProperties( a: Optional[TransitionProperties], b: Optional[TransitionProperties]) -> TransitionProperties:
113def mergeProperties(
114    a: Optional[TransitionProperties],
115    b: Optional[TransitionProperties]
116) -> TransitionProperties:
117    """
118    Merges two sets of transition properties, following these rules:
119
120    1. Tags and annotations are combined. Annotations from the
121        second property set are ordered after those from the first.
122    2. If one of the transitions has a `ReqNothing` instance as its
123        requirement, we use the other requirement. If both have
124        complex requirements, we create a new `ReqAll` which
125        combines them as the requirement.
126    3. The consequences are merged by placing all of the consequences of
127        the first transition before those of the second one. This may in
128        some cases change the net outcome of those consequences,
129        because not all transition properties are compatible. (Imagine
130        merging two transitions one of which causes a capability to be
131        gained and the other of which causes a capability to be lost.
132        What should happen?).
133    4. The result will not list a reciprocal.
134
135    If either transition is `None`, then a deep copy of the other is
136    returned. If both are `None`, then an empty transition properties
137    dictionary is returned, with `ReqNothing` as the requirement, no
138    effects, no tags, and no annotations.
139
140    Deep copies of consequences are always made, so that any `Effects`
141    applications which edit effects won't end up with entangled effects.
142    """
143    if a is None:
144        if b is None:
145            return {
146                "requirement": base.ReqNothing(),
147                "consequence": [],
148                "tags": {},
149                "annotations": []
150            }
151        else:
152            return copy.deepcopy(b)
153    elif b is None:
154        return copy.deepcopy(a)
155    # implicitly neither a or b is None below
156
157    result: TransitionProperties = {
158        "requirement": base.ReqNothing(),
159        "consequence": copy.deepcopy(a["consequence"] + b["consequence"]),
160        "tags": a["tags"] | b["tags"],
161        "annotations": a["annotations"] + b["annotations"]
162    }
163
164    if a["requirement"] == base.ReqNothing():
165        result["requirement"] = b["requirement"]
166    elif b["requirement"] == base.ReqNothing():
167        result["requirement"] = a["requirement"]
168    else:
169        result["requirement"] = base.ReqAll(
170            [a["requirement"], b["requirement"]]
171        )
172
173    return result

Merges two sets of transition properties, following these rules:

  1. Tags and annotations are combined. Annotations from the second property set are ordered after those from the first.
  2. If one of the transitions has a ReqNothing instance as its requirement, we use the other requirement. If both have complex requirements, we create a new ReqAll which combines them as the requirement.
  3. The consequences are merged by placing all of the consequences of the first transition before those of the second one. This may in some cases change the net outcome of those consequences, because not all transition properties are compatible. (Imagine merging two transitions one of which causes a capability to be gained and the other of which causes a capability to be lost. What should happen?).
  4. The result will not list a reciprocal.

If either transition is None, then a deep copy of the other is returned. If both are None, then an empty transition properties dictionary is returned, with ReqNothing as the requirement, no effects, no tags, and no annotations.

Deep copies of consequences are always made, so that any Effects applications which edit effects won't end up with entangled effects.

class TransitionBlockedWarning(builtins.Warning):
180class TransitionBlockedWarning(Warning):
181    """
182    An warning type for indicating that a transition which has been
183    requested does not have its requirements satisfied by the current
184    game state.
185    """
186    pass

An warning type for indicating that a transition which has been requested does not have its requirements satisfied by the current game state.

Inherited Members
builtins.Warning
Warning
builtins.BaseException
with_traceback
add_note
args
class BadStart(builtins.ValueError):
189class BadStart(ValueError):
190    """
191    An error raised when the start method is used improperly.
192    """
193    pass

An error raised when the start method is used improperly.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class MissingDecisionError(builtins.KeyError):
196class MissingDecisionError(KeyError):
197    """
198    An error raised when attempting to use a decision that does not
199    exist.
200    """
201    pass

An error raised when attempting to use a decision that does not exist.

Inherited Members
builtins.KeyError
KeyError
builtins.BaseException
with_traceback
add_note
args
class AmbiguousDecisionSpecifierError(builtins.KeyError):
204class AmbiguousDecisionSpecifierError(KeyError):
205    """
206    An error raised when an ambiguous decision specifier is provided.
207    Note that if a decision specifier simply doesn't match anything, you
208    will get a `MissingDecisionError` instead.
209    """
210    pass

An error raised when an ambiguous decision specifier is provided. Note that if a decision specifier simply doesn't match anything, you will get a MissingDecisionError instead.

Inherited Members
builtins.KeyError
KeyError
builtins.BaseException
with_traceback
add_note
args
class AmbiguousTransitionError(builtins.KeyError):
213class AmbiguousTransitionError(KeyError):
214    """
215    An error raised when an ambiguous transition is specified.
216    If a transition specifier simply doesn't match anything, you
217    will get a `MissingTransitionError` instead.
218    """
219    pass

An error raised when an ambiguous transition is specified. If a transition specifier simply doesn't match anything, you will get a MissingTransitionError instead.

Inherited Members
builtins.KeyError
KeyError
builtins.BaseException
with_traceback
add_note
args
class MissingTransitionError(builtins.KeyError):
222class MissingTransitionError(KeyError):
223    """
224    An error raised when attempting to use a transition that does not
225    exist.
226    """
227    pass

An error raised when attempting to use a transition that does not exist.

Inherited Members
builtins.KeyError
KeyError
builtins.BaseException
with_traceback
add_note
args
class MissingTransitionWarning(builtins.Warning):
230class MissingTransitionWarning(Warning):
231    """
232    Softer form of a `MissingTransitionError`.
233    """
234    pass

Softer form of a MissingTransitionError.

Inherited Members
builtins.Warning
Warning
builtins.BaseException
with_traceback
add_note
args
class MissingMechanismWarning(builtins.Warning):
237class MissingMechanismWarning(Warning):
238    """
239    A warning to use when attempting look up a mechanism name but no
240    mechanism is found. Use `MissingMechanismError` instead if the issue
241    is an error.
242    """
243    pass

A warning to use when attempting look up a mechanism name but no mechanism is found. Use MissingMechanismError instead if the issue is an error.

Inherited Members
builtins.Warning
Warning
builtins.BaseException
with_traceback
add_note
args
class MissingMechanismError(builtins.KeyError):
246class MissingMechanismError(KeyError):
247    """
248    An error raised when attempting to use a mechanism that does not
249    exist.
250    """
251    pass

An error raised when attempting to use a mechanism that does not exist.

Inherited Members
builtins.KeyError
KeyError
builtins.BaseException
with_traceback
add_note
args
class MissingZoneError(builtins.KeyError):
254class MissingZoneError(KeyError):
255    """
256    An error raised when attempting to use a zone that does not exist.
257    """
258    pass

An error raised when attempting to use a zone that does not exist.

Inherited Members
builtins.KeyError
KeyError
builtins.BaseException
with_traceback
add_note
args
class InvalidLevelError(builtins.ValueError):
261class InvalidLevelError(ValueError):
262    """
263    An error raised when an operation fails because of an invalid zone
264    level.
265    """
266    pass

An error raised when an operation fails because of an invalid zone level.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class InvalidDestinationError(builtins.ValueError):
269class InvalidDestinationError(ValueError):
270    """
271    An error raised when attempting to perform an operation with a
272    transition but that transition does not lead to a destination that's
273    compatible with the operation.
274    """
275    pass

An error raised when attempting to perform an operation with a transition but that transition does not lead to a destination that's compatible with the operation.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class ExplorationStatusError(builtins.ValueError):
278class ExplorationStatusError(ValueError):
279    """
280    An error raised when attempting to perform an operation that
281    requires a previously-visited destination with a decision that
282    represents a not-yet-visited decision, or vice versa. For
283    `Situation`s, Exploration states 'unknown', 'hypothesized', and
284    'noticed' count as "not-yet-visited" while 'exploring' and 'explored'
285    count as "visited" (see `base.hasBeenVisited`) Meanwhile, in a
286    `DecisionGraph` where exploration statuses are not present, the
287    presence or absence of the 'unconfirmed' tag is used to determine
288    whether something has been confirmed or not.
289    """
290    pass

An error raised when attempting to perform an operation that requires a previously-visited destination with a decision that represents a not-yet-visited decision, or vice versa. For Situations, Exploration states 'unknown', 'hypothesized', and 'noticed' count as "not-yet-visited" while 'exploring' and 'explored' count as "visited" (see base.hasBeenVisited) Meanwhile, in a DecisionGraph where exploration statuses are not present, the presence or absence of the 'unconfirmed' tag is used to determine whether something has been confirmed or not.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
WARN_OF_NAME_COLLISIONS = False

Whether or not to issue warnings when two decision names are the same.

class DecisionCollisionWarning(builtins.Warning):
299class DecisionCollisionWarning(Warning):
300    """
301    A warning raised when attempting to create a new decision using the
302    name of a decision that already exists.
303    """
304    pass

A warning raised when attempting to create a new decision using the name of a decision that already exists.

Inherited Members
builtins.Warning
Warning
builtins.BaseException
with_traceback
add_note
args
class TransitionCollisionError(builtins.ValueError):
307class TransitionCollisionError(ValueError):
308    """
309    An error raised when attempting to re-use a transition name for a
310    new transition, or otherwise when a transition name conflicts with
311    an already-established transition.
312    """
313    pass

An error raised when attempting to re-use a transition name for a new transition, or otherwise when a transition name conflicts with an already-established transition.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class TransitionCollisionWarning(builtins.Warning):
316class TransitionCollisionWarning(Warning):
317    """
318    Softer form of a `TransitionCollisionError`.
319    """
320    pass

Softer form of a TransitionCollisionError.

Inherited Members
builtins.Warning
Warning
builtins.BaseException
with_traceback
add_note
args
class AmbiguousMechanismWarning(builtins.Warning):
323class AmbiguousMechanismWarning(Warning):
324    """
325    An warning to use when a mechanism reference is potentially
326    ambiguous. Use `AmbiguousMechanismError` instead if the issue is an
327    error.
328    """
329    pass

An warning to use when a mechanism reference is potentially ambiguous. Use AmbiguousMechanismError instead if the issue is an error.

Inherited Members
builtins.Warning
Warning
builtins.BaseException
with_traceback
add_note
args
class AmbiguousMechanismError(builtins.ValueError):
332class AmbiguousMechanismError(ValueError):
333    """
334    An error raised when attempting look up a mechanism name but more
335    than one mechanism shares that name within an applicable search
336    region.
337    """
338    pass

An error raised when attempting look up a mechanism name but more than one mechanism shares that name within an applicable search region.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class MechanismCollisionError(builtins.ValueError):
341class MechanismCollisionError(ValueError):
342    """
343    An error raised when attempting to re-use a mechanism name at the
344    same decision where a mechanism with that name already exists.
345    """
346    pass

An error raised when attempting to re-use a mechanism name at the same decision where a mechanism with that name already exists.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class DecisionCollisionError(builtins.ValueError):
349class DecisionCollisionError(ValueError):
350    """
351    An error raised when attempting to re-use a decision ID.
352    """
353    pass

An error raised when attempting to re-use a decision ID.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class DomainCollisionError(builtins.KeyError):
356class DomainCollisionError(KeyError):
357    """
358    An error raised when attempting to create a domain with the same
359    name as an existing domain.
360    """
361    pass

An error raised when attempting to create a domain with the same name as an existing domain.

Inherited Members
builtins.KeyError
KeyError
builtins.BaseException
with_traceback
add_note
args
class MissingFocalContextError(builtins.KeyError):
364class MissingFocalContextError(KeyError):
365    """
366    An error raised when attempting to pick out a focal context with a
367    name that doesn't exist.
368    """
369    pass

An error raised when attempting to pick out a focal context with a name that doesn't exist.

Inherited Members
builtins.KeyError
KeyError
builtins.BaseException
with_traceback
add_note
args
class FocalContextCollisionError(builtins.KeyError):
372class FocalContextCollisionError(KeyError):
373    """
374    An error raised when attempting to create a focal context with the
375    same name as an existing focal context.
376    """
377    pass

An error raised when attempting to create a focal context with the same name as an existing focal context.

Inherited Members
builtins.KeyError
KeyError
builtins.BaseException
with_traceback
add_note
args
class InvalidActionError(builtins.TypeError):
380class InvalidActionError(TypeError):
381    """
382    An error raised when attempting to take an exploration action which
383    is not correctly formed.
384    """
385    pass

An error raised when attempting to take an exploration action which is not correctly formed.

Inherited Members
builtins.TypeError
TypeError
builtins.BaseException
with_traceback
add_note
args
class ImpossibleActionError(builtins.ValueError):
388class ImpossibleActionError(ValueError):
389    """
390    An error raised when attempting to take an exploration action which
391    is correctly formed but which specifies an action that doesn't match
392    up with the graph state.
393    """
394    pass

An error raised when attempting to take an exploration action which is correctly formed but which specifies an action that doesn't match up with the graph state.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class DoubleActionError(builtins.ValueError):
397class DoubleActionError(ValueError):
398    """
399    An error raised when attempting to set up an `ExplorationAction`
400    when the current situation already has an action specified.
401    """
402    pass

An error raised when attempting to set up an ExplorationAction when the current situation already has an action specified.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class InactiveDomainWarning(builtins.Warning):
405class InactiveDomainWarning(Warning):
406    """
407    A warning used when an inactive domain is referenced but the
408    operation in progress can still succeed (for example when
409    deactivating an already-inactive domain).
410    """

A warning used when an inactive domain is referenced but the operation in progress can still succeed (for example when deactivating an already-inactive domain).

Inherited Members
builtins.Warning
Warning
builtins.BaseException
with_traceback
add_note
args
class ZoneCollisionError(builtins.ValueError):
413class ZoneCollisionError(ValueError):
414    """
415    An error raised when attempting to re-use a zone name for a new zone,
416    or otherwise when a zone name conflicts with an already-established
417    zone.
418    """
419    pass

An error raised when attempting to re-use a zone name for a new zone, or otherwise when a zone name conflicts with an already-established zone.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class InvalidMechanismSpecifierWarning(builtins.Warning):
422class InvalidMechanismSpecifierWarning(Warning):
423    """
424    A warning used when a mechanism specifier includes both a numerical
425    decision ID and superfluous domain/zone parts (which get ignored).
426    """

A warning used when a mechanism specifier includes both a numerical decision ID and superfluous domain/zone parts (which get ignored).

Inherited Members
builtins.Warning
Warning
builtins.BaseException
with_traceback
add_note
args
class DecisionGraph(exploration.graphs.UniqueExitsGraph[int, str]):
 433class DecisionGraph(
 434    graphs.UniqueExitsGraph[base.DecisionID, base.Transition]
 435):
 436    """
 437    Represents a view of the world as a topological graph at a moment in
 438    time. It derives from `networkx.MultiDiGraph`.
 439
 440    Each node (a `Decision`) represents a place in the world where there
 441    are multiple opportunities for travel/action, or a dead end where
 442    you must turn around and go back; typically this is a single room in
 443    a game, but sometimes one room has multiple decision points. Edges
 444    (`Transition`s) represent choices that can be made to travel to
 445    other decision points (e.g., taking the left door), or when they are
 446    self-edges, they represent actions that can be taken within a
 447    location that affect the world or the game state.
 448
 449    Each `Transition` includes a `Effects` dictionary
 450    indicating the effects that it has. Other effects of the transition
 451    that are not simple enough to be included in this format may be
 452    represented in an `DiscreteExploration` by changing the graph in the
 453    next step to reflect further effects of a transition.
 454
 455    In addition to normal transitions between decisions, a
 456    `DecisionGraph` can represent potential transitions which lead to
 457    unknown destinations. These are represented by adding decisions with
 458    the `'unconfirmed'` tag (whose names where not specified begin with
 459    `'_u.'`) with a separate unconfirmed decision for each transition
 460    (although where it's known that two transitions lead to the same
 461    unconfirmed decision, this can be represented as well).
 462
 463    Both nodes and edges can have `Annotation`s associated with them that
 464    include extra details about the explorer's perception of the
 465    situation. They can also have `Tag`s, which represent specific
 466    categories a transition or decision falls into.
 467
 468    Nodes can also be part of one or more `Zones`, and zones can also be
 469    part of other zones, allowing for a hierarchical description of the
 470    underlying space.
 471
 472    Equivalences can be specified to mark that some combination of
 473    capabilities can stand in for another capability.
 474    """
 475    def __init__(self) -> None:
 476        super().__init__()
 477
 478        self.zones: Dict[base.Zone, base.ZoneInfo] = {}
 479        """
 480        Mapping from zone names to zone info
 481        """
 482
 483        self.unknownCount: int = 0
 484        """
 485        Number of unknown decisions that have been created (not number
 486        of current unknown decisions, which is likely lower)
 487        """
 488
 489        self.equivalences: base.Equivalences = {}
 490        """
 491        See `base.Equivalences`. Determines what capabilities and/or
 492        mechanism states can count as active based on alternate
 493        requirements.
 494        """
 495
 496        self.reversionTypes: Dict[str, Set[str]] = {}
 497        """
 498        This tracks shorthand reversion types. See `base.revertedState`
 499        for how these are applied. Keys are custom names and values are
 500        reversion type strings that `base.revertedState` could access.
 501        """
 502
 503        self.nextID: base.DecisionID = 0
 504        """
 505        The ID to use for the next new decision we create.
 506        """
 507
 508        self.nextMechanismID: base.MechanismID = 0
 509        """
 510        ID for the next mechanism.
 511        """
 512
 513        self.mechanisms: Dict[
 514            base.MechanismID,
 515            Tuple[Optional[base.DecisionID], base.MechanismName]
 516        ] = {}
 517        """
 518        Mapping from `MechanismID`s to (`DecisionID`, `MechanismName`)
 519        pairs. For global mechanisms, the `DecisionID` is None.
 520        """
 521
 522        self.globalMechanisms: Dict[
 523            base.MechanismName,
 524            base.MechanismID
 525        ] = {}
 526        """
 527        Global mechanisms
 528        """
 529
 530        self.nameLookup: Dict[base.DecisionName, List[base.DecisionID]] = {}
 531        """
 532        A cache for name -> ID lookups
 533        """
 534
 535    # Note: not hashable
 536
 537    def __eq__(self, other):
 538        """
 539        Equality checker. `DecisionGraph`s can only be equal to other
 540        `DecisionGraph`s, not to other kinds of things.
 541        """
 542        if not isinstance(other, DecisionGraph):
 543            return False
 544        else:
 545            # Checks nodes, edges, and all attached data
 546            if not super().__eq__(other):
 547                return False
 548
 549            # Check unknown count
 550            if self.unknownCount != other.unknownCount:
 551                return False
 552
 553            # Check zones
 554            if self.zones != other.zones:
 555                return False
 556
 557            # Check equivalences
 558            if self.equivalences != other.equivalences:
 559                return False
 560
 561            # Check reversion types
 562            if self.reversionTypes != other.reversionTypes:
 563                return False
 564
 565            # Check mechanisms
 566            if self.nextMechanismID != other.nextMechanismID:
 567                return False
 568
 569            if self.mechanisms != other.mechanisms:
 570                return False
 571
 572            if self.globalMechanisms != other.globalMechanisms:
 573                return False
 574
 575            # Check names:
 576            if self.nameLookup != other.nameLookup:
 577                return False
 578
 579            return True
 580
 581    def listDifferences(
 582        self,
 583        other: 'DecisionGraph'
 584    ) -> Generator[str, None, None]:
 585        """
 586        Generates strings describing differences between this graph and
 587        another graph. This does NOT perform graph matching, so it will
 588        consider graphs different even if they have identical structures
 589        but use different IDs for the nodes in those structures.
 590        """
 591        if not isinstance(other, DecisionGraph):
 592            yield "other is not a graph"
 593        else:
 594            suppress = False
 595            myNodes = set(self.nodes)
 596            theirNodes = set(other.nodes)
 597            for n in myNodes:
 598                if n not in theirNodes:
 599                    suppress = True
 600                    yield (
 601                        f"other graph missing node {n}"
 602                    )
 603                else:
 604                    if self.nodes[n] != other.nodes[n]:
 605                        suppress = True
 606                        yield (
 607                            f"other graph has differences at node {n}:"
 608                            f"\n  Ours:  {self.nodes[n]}"
 609                            f"\nTheirs:  {other.nodes[n]}"
 610                        )
 611                    myDests = self.destinationsFrom(n)
 612                    theirDests = other.destinationsFrom(n)
 613                    for tr in myDests:
 614                        myTo = myDests[tr]
 615                        if tr not in theirDests:
 616                            suppress = True
 617                            yield (
 618                                f"at {self.identityOf(n)}: other graph"
 619                                f" missing transition {tr!r}"
 620                            )
 621                        else:
 622                            theirTo = theirDests[tr]
 623                            if myTo != theirTo:
 624                                suppress = True
 625                                yield (
 626                                    f"at {self.identityOf(n)}: other"
 627                                    f" graph transition {tr!r} leads to"
 628                                    f" {theirTo} instead of {myTo}"
 629                                )
 630                            else:
 631                                myProps = self.edges[n, myTo, tr]  # type:ignore [index] # noqa
 632                                theirProps = other.edges[n, myTo, tr]  # type:ignore [index] # noqa
 633                                if myProps != theirProps:
 634                                    suppress = True
 635                                    yield (
 636                                        f"at {self.identityOf(n)}: other"
 637                                        f" graph transition {tr!r} has"
 638                                        f" different properties:"
 639                                        f"\n  Ours:  {myProps}"
 640                                        f"\nTheirs:  {theirProps}"
 641                                    )
 642            for extra in theirNodes - myNodes:
 643                suppress = True
 644                yield (
 645                    f"other graph has extra node {extra}"
 646                )
 647
 648            # TODO: Fix networkx stubs!
 649            if self.graph != other.graph:  # type:ignore [attr-defined]
 650                suppress = True
 651                yield (
 652                    " different graph attributes:"  # type:ignore [attr-defined]  # noqa
 653                    f"\n  Ours:  {self.graph}"
 654                    f"\nTheirs:  {other.graph}"
 655                )
 656
 657            # Checks any other graph data we might have missed
 658            if not super().__eq__(other) and not suppress:
 659                for attr in dir(self):
 660                    if attr.startswith('__') and attr.endswith('__'):
 661                        continue
 662                    if not hasattr(other, attr):
 663                        yield f"other graph missing attribute: {attr!r}"
 664                    else:
 665                        myVal = getattr(self, attr)
 666                        theirVal = getattr(other, attr)
 667                        if (
 668                            myVal != theirVal
 669                        and not ((callable(myVal) and callable(theirVal)))
 670                        ):
 671                            yield (
 672                                f"other has different val for {attr!r}:"
 673                                f"\n  Ours:  {myVal}"
 674                                f"\nTheirs:  {theirVal}"
 675                            )
 676                for attr in sorted(set(dir(other)) - set(dir(self))):
 677                    yield f"other has extra attribute: {attr!r}"
 678                yield "graph data is different"
 679                # TODO: More detail here!
 680
 681            # Check unknown count
 682            if self.unknownCount != other.unknownCount:
 683                yield "unknown count is different"
 684
 685            # Check zones
 686            if self.zones != other.zones:
 687                yield "zones are different"
 688
 689            # Check equivalences
 690            if self.equivalences != other.equivalences:
 691                yield "equivalences are different"
 692
 693            # Check reversion types
 694            if self.reversionTypes != other.reversionTypes:
 695                yield "reversionTypes are different"
 696
 697            # Check mechanisms
 698            if self.nextMechanismID != other.nextMechanismID:
 699                yield "nextMechanismID is different"
 700
 701            if self.mechanisms != other.mechanisms:
 702                yield "mechanisms are different"
 703
 704            if self.globalMechanisms != other.globalMechanisms:
 705                yield "global mechanisms are different"
 706
 707            # Check names:
 708            if self.nameLookup != other.nameLookup:
 709                for name in self.nameLookup:
 710                    if name not in other.nameLookup:
 711                        yield (
 712                            f"other graph is missing name lookup entry"
 713                            f" for {name!r}"
 714                        )
 715                    else:
 716                        mine = self.nameLookup[name]
 717                        theirs = other.nameLookup[name]
 718                        if theirs != mine:
 719                            yield (
 720                                f"name lookup for {name!r} is {theirs}"
 721                                f" instead of {mine}"
 722                            )
 723                extras = set(other.nameLookup) - set(self.nameLookup)
 724                if extras:
 725                    yield (
 726                        f"other graph has extra name lookup entries:"
 727                        f" {extras}"
 728                    )
 729
 730    def _assignID(self) -> base.DecisionID:
 731        """
 732        Returns the next `base.DecisionID` to use and increments the
 733        next ID counter.
 734        """
 735        result = self.nextID
 736        self.nextID += 1
 737        return result
 738
 739    def _assignMechanismID(self) -> base.MechanismID:
 740        """
 741        Returns the next `base.MechanismID` to use and increments the
 742        next ID counter.
 743        """
 744        result = self.nextMechanismID
 745        self.nextMechanismID += 1
 746        return result
 747
 748    def decisionInfo(self, dID: base.DecisionID) -> DecisionInfo:
 749        """
 750        Retrieves the decision info for the specified decision, as a
 751        live editable dictionary.
 752
 753        For example:
 754
 755        >>> g = DecisionGraph()
 756        >>> g.addDecision('A')
 757        0
 758        >>> g.annotateDecision('A', 'note')
 759        >>> g.decisionInfo(0)
 760        {'name': 'A', 'domain': 'main', 'tags': {}, 'annotations': ['note']}
 761        """
 762        return cast(DecisionInfo, self.nodes[dID])
 763
 764    def resolveDecisions(
 765        self,
 766        spec: base.AnyDecisionSpecifier,
 767        zoneHint: Optional[base.Zone] = None,
 768        domainHint: Optional[base.Domain] = None
 769    ) -> Set[base.DecisionID]:
 770        """
 771        Works like `resolveDecision`, except that it returns a set of
 772        decision IDs. Where `resolveDecision` would raise an
 773        `AmbiguousDecisionSpecifierError`, it instead returns a set with
 774        multiple IDs. Where `resolveDecision` would raise a
 775        `MissingDecisionError`, it instead returns an empty set.
 776
 777        Examples:
 778
 779        >>> g = DecisionGraph()
 780        >>> g.addDecision('A')
 781        0
 782        >>> g.addDecision('B')
 783        1
 784        >>> g.addDecision('C')
 785        2
 786        >>> g.addDecision('A')
 787        3
 788        >>> g.addDecision('B', 'menu')
 789        4
 790        >>> g.createZone('Z', 0)
 791        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 792 annotations=[])
 793        >>> g.createZone('Z2', 0)
 794        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 795 annotations=[])
 796        >>> g.createZone('Zup', 1)
 797        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 798 annotations=[])
 799        >>> g.addDecisionToZone(0, 'Z')
 800        >>> g.addDecisionToZone(1, 'Z')
 801        >>> g.addDecisionToZone(2, 'Z')
 802        >>> g.addDecisionToZone(3, 'Z2')
 803        >>> g.addZoneToZone('Z', 'Zup')
 804        >>> g.addZoneToZone('Z2', 'Zup')
 805        >>> g.resolveDecisions(1)
 806        {1}
 807        >>> g.resolveDecisions('A')
 808        {0, 3}
 809        >>> g.resolveDecisions('B')
 810        {1, 4}
 811        >>> g.resolveDecisions('C')
 812        {2}
 813        >>> g.resolveDecisions('A', 'Z')
 814        {0}
 815        >>> g.resolveDecisions('A', zoneHint='Z2')
 816        {3}
 817        >>> g.resolveDecisions('B', domainHint='main')
 818        {1}
 819        >>> g.resolveDecisions('B', None, 'menu')
 820        {4}
 821        >>> g.resolveDecisions('B', zoneHint='Z2')
 822        set()
 823        >>> g.resolveDecisions('A', domainHint='menu')
 824        set()
 825        >>> g.resolveDecisions('A', domainHint='madeup')
 826        set()
 827        >>> g.resolveDecisions('A', zoneHint='madeup')
 828        set()
 829        >>> g.resolveDecisions(17)
 830        set()
 831        """
 832        # Parse it to either an ID or specifier if it's a string:
 833        if isinstance(spec, str):
 834            try:
 835                spec = int(spec)
 836            except ValueError:
 837                pass
 838
 839        # If it's an ID, check for existence:
 840        if isinstance(spec, base.DecisionID):
 841            if spec in self:
 842                return { spec }
 843            else:
 844                return set()
 845        else:
 846            if isinstance(spec, base.DecisionName):
 847                spec = base.DecisionSpecifier(
 848                    domain=None,
 849                    zone=None,
 850                    name=spec
 851                )
 852            elif not isinstance(spec, base.DecisionSpecifier):
 853                raise TypeError(
 854                    f"Specification is not provided as a"
 855                    f" DecisionSpecifier or other valid type. (got type"
 856                    f" {type(spec)})."
 857                )
 858
 859            # Merge domain hints from spec/args
 860            if (
 861                spec.domain is not None
 862            and domainHint is not None
 863            and spec.domain != domainHint
 864            ):
 865                raise ValueError(
 866                    f"Specifier {repr(spec)} includes domain hint"
 867                    f" {repr(spec.domain)} which is incompatible with"
 868                    f" explicit domain hint {repr(domainHint)}."
 869                )
 870            else:
 871                domainHint = spec.domain or domainHint
 872
 873            # Merge zone hints from spec/args
 874            if (
 875                spec.zone is not None
 876            and zoneHint is not None
 877            and spec.zone != zoneHint
 878            ):
 879                raise ValueError(
 880                    f"Specifier {repr(spec)} includes zone hint"
 881                    f" {repr(spec.zone)} which is incompatible with"
 882                    f" explicit zone hint {repr(zoneHint)}."
 883                )
 884            else:
 885                zoneHint = spec.zone or zoneHint
 886
 887            if spec.name not in self.nameLookup:
 888                return set()
 889            else:
 890                options = self.nameLookup[spec.name]
 891                if len(options) == 0:
 892                    return set()
 893                return {
 894                    opt
 895                    for opt in options
 896                    if (
 897                        domainHint is None
 898                     or self.domainFor(opt) == domainHint
 899                    ) and (
 900                        zoneHint is None
 901                     or zoneHint in self.zoneAncestors(opt)
 902                    )
 903                }
 904
 905    def resolveDecision(
 906        self,
 907        spec: base.AnyDecisionSpecifier,
 908        zoneHint: Optional[base.Zone] = None,
 909        domainHint: Optional[base.Domain] = None
 910    ) -> base.DecisionID:
 911        """
 912        Given a decision specifier returns the ID associated with that
 913        decision, or raises an `AmbiguousDecisionSpecifierError` or a
 914        `MissingDecisionError` if the specified decision is either
 915        missing or ambiguous. Cannot handle strings that contain domain
 916        and/or zone parts; use
 917        `parsing.ParseFormat.parseDecisionSpecifier` to turn such
 918        strings into `DecisionSpecifier`s if you need to first.
 919
 920        Examples:
 921
 922        >>> g = DecisionGraph()
 923        >>> g.addDecision('A')
 924        0
 925        >>> g.addDecision('B')
 926        1
 927        >>> g.addDecision('C')
 928        2
 929        >>> g.addDecision('A')
 930        3
 931        >>> g.addDecision('B', 'menu')
 932        4
 933        >>> g.createZone('Z', 0)
 934        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 935 annotations=[])
 936        >>> g.createZone('Z2', 0)
 937        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 938 annotations=[])
 939        >>> g.createZone('Zup', 1)
 940        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 941 annotations=[])
 942        >>> g.addDecisionToZone(0, 'Z')
 943        >>> g.addDecisionToZone(1, 'Z')
 944        >>> g.addDecisionToZone(2, 'Z')
 945        >>> g.addDecisionToZone(3, 'Z2')
 946        >>> g.addZoneToZone('Z', 'Zup')
 947        >>> g.addZoneToZone('Z2', 'Zup')
 948        >>> g.resolveDecision(1)
 949        1
 950        >>> g.resolveDecision('A')
 951        Traceback (most recent call last):
 952        ...
 953        exploration.core.AmbiguousDecisionSpecifierError...
 954        >>> g.resolveDecision('B')
 955        Traceback (most recent call last):
 956        ...
 957        exploration.core.AmbiguousDecisionSpecifierError...
 958        >>> g.resolveDecision('C')
 959        2
 960        >>> g.resolveDecision('A', 'Z')
 961        0
 962        >>> g.resolveDecision('A', zoneHint='Z2')
 963        3
 964        >>> g.resolveDecision('B', domainHint='main')
 965        1
 966        >>> g.resolveDecision('B', None, 'menu')
 967        4
 968        >>> g.resolveDecision('B', zoneHint='Z2')
 969        Traceback (most recent call last):
 970        ...
 971        exploration.core.MissingDecisionError...
 972        >>> g.resolveDecision('A', domainHint='menu')
 973        Traceback (most recent call last):
 974        ...
 975        exploration.core.MissingDecisionError...
 976        >>> g.resolveDecision('A', domainHint='madeup')
 977        Traceback (most recent call last):
 978        ...
 979        exploration.core.MissingDecisionError...
 980        >>> g.resolveDecision('A', zoneHint='madeup')
 981        Traceback (most recent call last):
 982        ...
 983        exploration.core.MissingDecisionError...
 984        """
 985        options = self.resolveDecisions(spec, zoneHint, domainHint)
 986        if len(options) == 0:  # zero options: decision doesn't exist
 987            if (
 988                (isinstance(spec, str) and spec.isdigit())
 989             or isinstance(spec, int)
 990            ):
 991                raise MissingDecisionError(
 992                    f"There is no decision with ID {int(spec)}."
 993                )
 994            elif isinstance(spec, str):
 995                if spec not in self.nameLookup:
 996                    raise MissingDecisionError(
 997                        f"There is no decision named {spec!r}."
 998                    )
 999                else:
1000                    filterDesc = ""
1001                    if domainHint is not None:
1002                        filterDesc += f" in domain {repr(domainHint)}"
1003                    if zoneHint is not None:
1004                        filterDesc += f" in zone {repr(zoneHint)}"
1005                    raise MissingDecisionError(
1006                        f"There is at least one decision named {spec!r},"
1007                        f" but there are none {filterDesc}."
1008                    )
1009            else:
1010                assert isinstance(spec, base.DecisionSpecifier)
1011                if spec.name not in self.nameLookup:
1012                    raise MissingDecisionError(
1013                        f"There is no decision named {spec.name!r}."
1014                    )
1015                else:
1016                    filterDesc = ""
1017                    domainHint = domainHint or spec.domain
1018                    zoneHint = zoneHint or spec.zone
1019                    if domainHint is not None:
1020                        filterDesc += f" in domain {repr(domainHint)}"
1021                    if zoneHint is not None:
1022                        filterDesc += f" in zone {repr(zoneHint)}"
1023                    raise MissingDecisionError(
1024                        f"There is at least one decision matching {spec!r},"
1025                        f" but there are none {filterDesc}."
1026                    )
1027        elif len(options) > 1:  # multiple options: specifier was ambiguous
1028            assert not isinstance(spec, int)  # couldn't be ambiguous
1029            if isinstance(spec, str):
1030                assert not spec.isdigit()  # couldn't be ambiguous
1031                raise AmbiguousDecisionSpecifierError(
1032                    f"There are {len(options)} decisions named"
1033                    f" {repr(spec)}."
1034                )
1035            else:
1036                assert isinstance(spec, base.DecisionSpecifier)
1037                filterDesc = ""
1038                domainHint = domainHint or spec.domain
1039                zoneHint = zoneHint or spec.zone
1040                if domainHint is not None:
1041                    filterDesc += f" in domain {repr(domainHint)}"
1042                if zoneHint is not None:
1043                    filterDesc += f" in zone {repr(zoneHint)}"
1044                raise AmbiguousDecisionSpecifierError(
1045                    f"There are {len(options)} decisions named"
1046                    f" {repr(spec.name)}{filterDesc}."
1047                )
1048        else:  # only 1 option: successfully resolved to unique decision
1049            return list(options)[0]
1050
1051    def getDecision(
1052        self,
1053        decision: base.AnyDecisionSpecifier,
1054        zoneHint: Optional[base.Zone] = None,
1055        domainHint: Optional[base.Domain] = None
1056    ) -> Optional[base.DecisionID]:
1057        """
1058        Works like `resolveDecision` but returns None instead of raising
1059        a `MissingDecisionError` if the specified decision isn't listed.
1060        May still raise an `AmbiguousDecisionSpecifierError`.
1061        """
1062        try:
1063            return self.resolveDecision(
1064                decision,
1065                zoneHint,
1066                domainHint
1067            )
1068        except MissingDecisionError:
1069            return None
1070
1071    def nameFor(
1072        self,
1073        decision: base.AnyDecisionSpecifier
1074    ) -> base.DecisionName:
1075        """
1076        Returns the name of the specified decision. Note that names are
1077        not necessarily unique.
1078
1079        Example:
1080
1081        >>> d = DecisionGraph()
1082        >>> d.addDecision('A')
1083        0
1084        >>> d.addDecision('B')
1085        1
1086        >>> d.addDecision('B')
1087        2
1088        >>> d.nameFor(0)
1089        'A'
1090        >>> d.nameFor(1)
1091        'B'
1092        >>> d.nameFor(2)
1093        'B'
1094        >>> d.nameFor(3)
1095        Traceback (most recent call last):
1096        ...
1097        exploration.core.MissingDecisionError...
1098        """
1099        dID = self.resolveDecision(decision)
1100        return self.nodes[dID]['name']
1101
1102    def shortIdentity(
1103        self,
1104        decision: Optional[base.AnyDecisionSpecifier],
1105        includeZones: bool = True,
1106        alwaysDomain: Optional[bool] = None
1107    ):
1108        """
1109        Returns a string containing the name for the given decision,
1110        prefixed by its level-0 zone(s) and domain. If the value provided
1111        is `None`, it returns the string "(nowhere)". This is not
1112        necessarily unique.
1113
1114        If `includeZones` is true (the default) then zone information
1115        is included before the decision name.
1116
1117        If `alwaysDomain` is true or false, then the domain information
1118        will always (or never) be included. If it's `None` (the default)
1119        then domain info will only be included for decisions which are
1120        not in the default domain.
1121
1122        This string is NOT necessarily valid input to
1123        `parsing.ParseFormat.parseDecisionSpecifier` (see
1124        `journal.JournalObserver.identifyingString` for a function that
1125        can generate that).
1126        """
1127        if decision is None:
1128            return "(nowhere)"
1129        else:
1130            dID = self.resolveDecision(decision)
1131            thisDomain = self.domainFor(dID)
1132            dSpec = ''
1133            zSpec = ''
1134            if (
1135                alwaysDomain is True
1136             or (
1137                    alwaysDomain is None
1138                and thisDomain != base.DEFAULT_DOMAIN
1139                )
1140            ):
1141                dSpec = thisDomain + '//'  # TODO: Don't hardcode this?
1142            if includeZones:
1143                zones = [
1144                    z
1145                    for z in self.zoneParents(dID)
1146                    if self.zones[z].level == 0
1147                ]
1148                if len(zones) == 1:
1149                    zSpec = zones[0] + '::'  # TODO: Don't hardcode this?
1150                elif len(zones) > 1:
1151                    zSpec = '[' + ', '.join(sorted(zones)) + ']::'
1152                # else leave zSpec empty
1153
1154            return f"{dSpec}{zSpec}{self.nameFor(dID)}"
1155
1156    def identityOf(
1157        self,
1158        decision: Optional[base.AnyDecisionSpecifier],
1159        includeZones: bool = True,
1160        alwaysDomain: Optional[bool] = None
1161    ) -> str:
1162        """
1163        Returns the given node's ID, plus its `shortIdentity` in
1164        parentheses. Arguments are passed through to `shortIdentity`.
1165        """
1166        if decision is None:
1167            return "(nowhere)"
1168        else:
1169            dID = self.resolveDecision(decision)
1170            short = self.shortIdentity(decision, includeZones, alwaysDomain)
1171            return f"{dID} ({short})"
1172
1173    def namesListing(
1174        self,
1175        decisions: Collection[base.DecisionID],
1176        includeZones: bool = True,
1177        indent: int = 2
1178    ) -> str:
1179        """
1180        Returns a multi-line string containing an indented listing of
1181        the provided decision IDs with their names in parentheses after
1182        each. Useful for debugging & error messages.
1183
1184        Includes level-0 zones where applicable, with a zone separator
1185        before the decision, unless `includeZones` is set to False. Where
1186        there are multiple level-0 zones, they're listed together in
1187        brackets.
1188
1189        Uses the string '(none)' when there are no decisions are in the
1190        list.
1191
1192        Set `indent` to something other than 2 to control how much
1193        indentation is added.
1194
1195        For example:
1196
1197        >>> g = DecisionGraph()
1198        >>> g.addDecision('A')
1199        0
1200        >>> g.addDecision('B')
1201        1
1202        >>> g.addDecision('C')
1203        2
1204        >>> g.namesListing(['A', 'C', 'B'])
1205        '  0 (A)\\n  2 (C)\\n  1 (B)\\n'
1206        >>> g.namesListing([])
1207        '  (none)\\n'
1208        >>> g.createZone('zone', 0)
1209        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1210 annotations=[])
1211        >>> g.createZone('zone2', 0)
1212        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1213 annotations=[])
1214        >>> g.createZone('zoneUp', 1)
1215        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
1216 annotations=[])
1217        >>> g.addDecisionToZone(0, 'zone')
1218        >>> g.addDecisionToZone(1, 'zone')
1219        >>> g.addDecisionToZone(1, 'zone2')
1220        >>> g.addDecisionToZone(2, 'zoneUp')  # won't be listed: it's level-1
1221        >>> g.namesListing(['A', 'C', 'B'])
1222        '  0 (zone::A)\\n  2 (C)\\n  1 ([zone, zone2]::B)\\n'
1223        """
1224        ind = ' ' * indent
1225        if len(decisions) == 0:
1226            return ind + '(none)\n'
1227        else:
1228            result = ''
1229            for dID in decisions:
1230                result += ind + self.identityOf(dID, includeZones) + '\n'
1231            return result
1232
1233    def destinationsListing(
1234        self,
1235        destinations: Dict[base.Transition, base.DecisionID],
1236        includeZones: bool = True,
1237        indent: int = 2
1238    ) -> str:
1239        """
1240        Returns a multi-line string containing an indented listing of
1241        the provided transitions along with their destinations and the
1242        names of those destinations in parentheses. Useful for debugging
1243        & error messages. (Use e.g., `destinationsFrom` to get a
1244        transitions -> destinations dictionary in the required format.)
1245
1246        Uses the string '(no transitions)' when there are no transitions
1247        in the dictionary.
1248
1249        Set `indent` to something other than 2 to control how much
1250        indentation is added.
1251
1252        For example:
1253
1254        >>> g = DecisionGraph()
1255        >>> g.addDecision('A')
1256        0
1257        >>> g.addDecision('B')
1258        1
1259        >>> g.addDecision('C')
1260        2
1261        >>> g.addTransition('A', 'north', 'B', 'south')
1262        >>> g.addTransition('B', 'east', 'C', 'west')
1263        >>> g.addTransition('C', 'southwest', 'A', 'northeast')
1264        >>> g.destinationsListing(g.destinationsFrom('A'))
1265        '  north to 1 (B)\\n  northeast to 2 (C)\\n'
1266        >>> g.destinationsListing(g.destinationsFrom('B'))
1267        '  south to 0 (A)\\n  east to 2 (C)\\n'
1268        >>> g.destinationsListing({})
1269        '  (none)\\n'
1270        >>> g.createZone('zone', 0)
1271        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1272 annotations=[])
1273        >>> g.addDecisionToZone(0, 'zone')
1274        >>> g.destinationsListing(g.destinationsFrom('B'))
1275        '  south to 0 (zone::A)\\n  east to 2 (C)\\n'
1276        """
1277        ind = ' ' * indent
1278        if len(destinations) == 0:
1279            return ind + '(none)\n'
1280        else:
1281            result = ''
1282            for transition, dID in destinations.items():
1283                line = f"{transition} to {self.identityOf(dID, includeZones)}"
1284                result += ind + line + '\n'
1285            return result
1286
1287    def domainFor(self, decision: base.AnyDecisionSpecifier) -> base.Domain:
1288        """
1289        Returns the domain that a decision belongs to.
1290        """
1291        dID = self.resolveDecision(decision)
1292        return self.nodes[dID]['domain']
1293
1294    def allDecisionsInDomain(
1295        self,
1296        domain: base.Domain
1297    ) -> Set[base.DecisionID]:
1298        """
1299        Returns the set of all `DecisionID`s for decisions in the
1300        specified domain.
1301        """
1302        return set(dID for dID in self if self.nodes[dID]['domain'] == domain)
1303
1304    def destination(
1305        self,
1306        decision: base.AnyDecisionSpecifier,
1307        transition: base.Transition
1308    ) -> base.DecisionID:
1309        """
1310        Overrides base `UniqueExitsGraph.destination` to raise
1311        `MissingDecisionError` or `MissingTransitionError` as
1312        appropriate, and to work with an `AnyDecisionSpecifier`.
1313        """
1314        dID = self.resolveDecision(decision)
1315        try:
1316            return super().destination(dID, transition)
1317        except KeyError:
1318            raise MissingTransitionError(
1319                f"Transition {transition!r} does not exist at decision"
1320                f" {self.identityOf(dID)}."
1321            )
1322
1323    def getDestination(
1324        self,
1325        decision: base.AnyDecisionSpecifier,
1326        transition: base.Transition,
1327        default: Any = None
1328    ) -> Optional[base.DecisionID]:
1329        """
1330        Overrides base `UniqueExitsGraph.getDestination` with different
1331        argument names, since those matter for the edit DSL.
1332        """
1333        dID = self.resolveDecision(decision)
1334        return super().getDestination(dID, transition)
1335
1336    def destinationsFrom(
1337        self,
1338        decision: base.AnyDecisionSpecifier
1339    ) -> Dict[base.Transition, base.DecisionID]:
1340        """
1341        Override that just changes the type of the exception from a
1342        `KeyError` to a `MissingDecisionError` when the source does not
1343        exist.
1344        """
1345        dID = self.resolveDecision(decision)
1346        return super().destinationsFrom(dID)
1347
1348    def newTransitionNameFrom(
1349        self,
1350        decision: base.AnyDecisionSpecifier,
1351        baseName: base.Transition
1352    ) -> base.Transition:
1353        """
1354        Given a decision and a desired transition name, returns a
1355        transition name that doesn't match any existing transition from
1356        the specified destination. Returns the given name as-is if it
1357        doesn't collide with an existing transition name, otherwise
1358        appends a number to it, starting with 2. Note that a number will
1359        be appended even if the base name already has a number at the
1360        end, so for example if a decision already has 'up' and 'up2' as
1361        options, asking for a new transition based on 'up' will give
1362        'up3', but asking for a new transition based on 'up2' will give
1363        'up22'.
1364
1365        Some examples:
1366
1367        >>> g = DecisionGraph()
1368        >>> g.addDecision('A')
1369        0
1370        >>> g.newTransitionNameFrom('A', 'up')
1371        'up'
1372        >>> g.addDecision('B')
1373        1
1374        >>> g.addTransition('A', 'up', 'B')
1375        >>> g.newTransitionNameFrom('A', 'up')
1376        'up2'
1377        >>> g.addTransition('A', 'up2', 'B')
1378        >>> g.newTransitionNameFrom('A', 'up')
1379        'up3'
1380        >>> g.newTransitionNameFrom('A', 'up2')  # suffixes not parsed
1381        'up22'
1382        """
1383        already = self.destinationsFrom(decision)
1384        candidate = baseName
1385        i = 2
1386        while candidate in already:
1387            candidate = baseName + str(i)
1388            i += 1
1389        return candidate 
1390
1391    def bothEnds(
1392        self,
1393        decision: base.AnyDecisionSpecifier,
1394        transition: base.Transition
1395    ) -> Set[base.DecisionID]:
1396        """
1397        Returns a set containing the `DecisionID`(s) for both the start
1398        and end of the specified transition. Raises a
1399        `MissingDecisionError` or `MissingTransitionError`if the
1400        specified decision and/or transition do not exist.
1401
1402        Note that for actions since the source and destination are the
1403        same, the set will have only one element.
1404        """
1405        dID = self.resolveDecision(decision)
1406        result = {dID}
1407        dest = self.destination(dID, transition)
1408        if dest is not None:
1409            result.add(dest)
1410        return result
1411
1412    def decisionActions(
1413        self,
1414        decision: base.AnyDecisionSpecifier
1415    ) -> Set[base.Transition]:
1416        """
1417        Retrieves the set of self-edges at a decision. Editing the set
1418        will not affect the graph.
1419
1420        Example:
1421
1422        >>> g = DecisionGraph()
1423        >>> g.addDecision('A')
1424        0
1425        >>> g.addDecision('B')
1426        1
1427        >>> g.addDecision('C')
1428        2
1429        >>> g.addAction('A', 'action1')
1430        >>> g.addAction('A', 'action2')
1431        >>> g.addAction('B', 'action3')
1432        >>> sorted(g.decisionActions('A'))
1433        ['action1', 'action2']
1434        >>> g.decisionActions('B')
1435        {'action3'}
1436        >>> g.decisionActions('C')
1437        set()
1438        """
1439        result = set()
1440        dID = self.resolveDecision(decision)
1441        for transition, dest in self.destinationsFrom(dID).items():
1442            if dest == dID:
1443                result.add(transition)
1444        return result
1445
1446    def getTransitionProperties(
1447        self,
1448        decision: base.AnyDecisionSpecifier,
1449        transition: base.Transition
1450    ) -> TransitionProperties:
1451        """
1452        Returns a dictionary containing transition properties for the
1453        specified transition from the specified decision. The properties
1454        included are:
1455
1456        - 'requirement': The requirement for the transition.
1457        - 'consequence': Any consequence of the transition.
1458        - 'tags': Any tags applied to the transition.
1459        - 'annotations': Any annotations on the transition.
1460
1461        The reciprocal of the transition is not included.
1462
1463        The result is a clone of the stored properties; edits to the
1464        dictionary will NOT modify the graph.
1465        """
1466        dID = self.resolveDecision(decision)
1467        dest = self.destination(dID, transition)
1468
1469        info: TransitionProperties = copy.deepcopy(
1470            self.edges[dID, dest, transition]  # type:ignore
1471        )
1472        return {
1473            'requirement': info.get('requirement', base.ReqNothing()),
1474            'consequence': info.get('consequence', []),
1475            'tags': info.get('tags', {}),
1476            'annotations': info.get('annotations', [])
1477        }
1478
1479    def setTransitionProperties(
1480        self,
1481        decision: base.AnyDecisionSpecifier,
1482        transition: base.Transition,
1483        requirement: Optional[base.Requirement] = None,
1484        consequence: Optional[base.Consequence] = None,
1485        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
1486        annotations: Optional[List[base.Annotation]] = None
1487    ) -> None:
1488        """
1489        Sets one or more transition properties all at once. Can be used
1490        to set the requirement, consequence, tags, and/or annotations.
1491        Old values are overwritten, although if `None`s are provided (or
1492        arguments are omitted), corresponding properties are not
1493        updated.
1494
1495        To add tags or annotations to existing tags/annotations instead
1496        of replacing them, use `tagTransition` or `annotateTransition`
1497        instead.
1498        """
1499        dID = self.resolveDecision(decision)
1500        if requirement is not None:
1501            self.setTransitionRequirement(dID, transition, requirement)
1502        if consequence is not None:
1503            self.setConsequence(dID, transition, consequence)
1504        if tags is not None:
1505            dest = self.destination(dID, transition)
1506            # TODO: Submit pull request to update MultiDiGraph stubs in
1507            # types-networkx to include OutMultiEdgeView that accepts
1508            # from/to/key tuples as indices.
1509            info = cast(
1510                TransitionProperties,
1511                self.edges[dID, dest, transition]  # type:ignore
1512            )
1513            info['tags'] = tags
1514        if annotations is not None:
1515            dest = self.destination(dID, transition)
1516            info = cast(
1517                TransitionProperties,
1518                self.edges[dID, dest, transition]  # type:ignore
1519            )
1520            info['annotations'] = annotations
1521
1522    def getTransitionRequirement(
1523        self,
1524        decision: base.AnyDecisionSpecifier,
1525        transition: base.Transition
1526    ) -> base.Requirement:
1527        """
1528        Returns the `Requirement` for accessing a specific transition at
1529        a specific decision. For transitions which don't have
1530        requirements, returns a `ReqNothing` instance.
1531        """
1532        dID = self.resolveDecision(decision)
1533        dest = self.destination(dID, transition)
1534
1535        info = cast(
1536            TransitionProperties,
1537            self.edges[dID, dest, transition]  # type:ignore
1538        )
1539
1540        return info.get('requirement', base.ReqNothing())
1541
1542    def setTransitionRequirement(
1543        self,
1544        decision: base.AnyDecisionSpecifier,
1545        transition: base.Transition,
1546        requirement: Optional[base.Requirement]
1547    ) -> None:
1548        """
1549        Sets the `Requirement` for accessing a specific transition at
1550        a specific decision. Raises a `KeyError` if the decision or
1551        transition does not exist.
1552
1553        Deletes the requirement if `None` is given as the requirement.
1554
1555        Use `parsing.ParseFormat.parseRequirement` first if you have a
1556        requirement in string format.
1557
1558        Does not raise an error if deletion is requested for a
1559        non-existent requirement, and silently overwrites any previous
1560        requirement.
1561        """
1562        dID = self.resolveDecision(decision)
1563
1564        dest = self.destination(dID, transition)
1565
1566        info = cast(
1567            TransitionProperties,
1568            self.edges[dID, dest, transition]  # type:ignore
1569        )
1570
1571        if requirement is None:
1572            try:
1573                del info['requirement']
1574            except KeyError:
1575                pass
1576        else:
1577            if not isinstance(requirement, base.Requirement):
1578                raise TypeError(
1579                    f"Invalid requirement type: {type(requirement)}"
1580                )
1581
1582            info['requirement'] = requirement
1583
1584    def getConsequence(
1585        self,
1586        decision: base.AnyDecisionSpecifier,
1587        transition: base.Transition
1588    ) -> base.Consequence:
1589        """
1590        Retrieves the consequence of a transition.
1591
1592        A `KeyError` is raised if the specified decision/transition
1593        combination doesn't exist.
1594        """
1595        dID = self.resolveDecision(decision)
1596
1597        dest = self.destination(dID, transition)
1598
1599        info = cast(
1600            TransitionProperties,
1601            self.edges[dID, dest, transition]  # type:ignore
1602        )
1603
1604        return info.get('consequence', [])
1605
1606    def addConsequence(
1607        self,
1608        decision: base.AnyDecisionSpecifier,
1609        transition: base.Transition,
1610        consequence: base.Consequence
1611    ) -> Tuple[int, int]:
1612        """
1613        Adds the given `Consequence` to the consequence list for the
1614        specified transition, extending that list at the end. Note that
1615        this does NOT make a copy of the consequence, so it should not
1616        be used to copy consequences from one transition to another
1617        without making a deep copy first.
1618
1619        A `MissingDecisionError` or a `MissingTransitionError` is raised
1620        if the specified decision/transition combination doesn't exist.
1621
1622        Returns a pair of integers indicating the minimum and maximum
1623        depth-first-traversal-indices of the added consequence part(s)
1624        (inclusive).
1625
1626        The outer consequence list itself (index 0) is not counted.
1627
1628        >>> d = DecisionGraph()
1629        >>> d.addDecision('A')
1630        0
1631        >>> d.addDecision('B')
1632        1
1633        >>> d.addTransition('A', 'fwd', 'B', 'rev')
1634        >>> d.addConsequence('A', 'fwd', [base.effect(gain='sword')])
1635        (1, 1)
1636        >>> d.addConsequence('B', 'rev', [base.effect(lose='sword')])
1637        (1, 1)
1638        >>> ef = d.getConsequence('A', 'fwd')
1639        >>> er = d.getConsequence('B', 'rev')
1640        >>> ef == [base.effect(gain='sword')]
1641        True
1642        >>> er == [base.effect(lose='sword')]
1643        True
1644        >>> d.addConsequence('A', 'fwd', [base.effect(deactivate=True)])
1645        (2, 2)
1646        >>> ef = d.getConsequence('A', 'fwd')
1647        >>> ef == [base.effect(gain='sword'), base.effect(deactivate=True)]
1648        True
1649        >>> d.addConsequence(
1650        ...     'A',
1651        ...     'fwd',  # adding to consequence with 3 parts already
1652        ...     [  # outer list not counted because it merges
1653        ...         base.challenge(  # 1 part
1654        ...             None,
1655        ...             0,
1656        ...             [base.effect(gain=('flowers', 3))],  # 2 parts
1657        ...             [base.effect(gain=('flowers', 1))]  # 2 parts
1658        ...         )
1659        ...     ]
1660        ... )  # note indices below are inclusive; indices are 3, 4, 5, 6, 7
1661        (3, 7)
1662        """
1663        dID = self.resolveDecision(decision)
1664
1665        dest = self.destination(dID, transition)
1666
1667        info = cast(
1668            TransitionProperties,
1669            self.edges[dID, dest, transition]  # type:ignore
1670        )
1671
1672        existing = info.setdefault('consequence', [])
1673        startIndex = base.countParts(existing)
1674        existing.extend(consequence)
1675        endIndex = base.countParts(existing) - 1
1676        return (startIndex, endIndex)
1677
1678    def setConsequence(
1679        self,
1680        decision: base.AnyDecisionSpecifier,
1681        transition: base.Transition,
1682        consequence: base.Consequence
1683    ) -> None:
1684        """
1685        Replaces the transition consequence for the given transition at
1686        the given decision. Any previous consequence is discarded. See
1687        `Consequence` for the structure of these. Note that this does
1688        NOT make a copy of the consequence, do that first to avoid
1689        effect-entanglement if you're copying a consequence.
1690
1691        A `MissingDecisionError` or a `MissingTransitionError` is raised
1692        if the specified decision/transition combination doesn't exist.
1693        """
1694        dID = self.resolveDecision(decision)
1695
1696        dest = self.destination(dID, transition)
1697
1698        info = cast(
1699            TransitionProperties,
1700            self.edges[dID, dest, transition]  # type:ignore
1701        )
1702
1703        info['consequence'] = consequence
1704
1705    def addEquivalence(
1706        self,
1707        requirement: base.Requirement,
1708        capabilityOrMechanismState: Union[
1709            base.Capability,
1710            Tuple[base.MechanismID, base.MechanismState]
1711        ]
1712    ) -> None:
1713        """
1714        Adds the given requirement as an equivalence for the given
1715        capability or the given mechanism state. Note that having a
1716        capability via an equivalence does not count as actually having
1717        that capability; it only counts for the purpose of satisfying
1718        `Requirement`s.
1719
1720        Note also that because a mechanism-based requirement looks up
1721        the specific mechanism locally based on a name, an equivalence
1722        defined in one location may affect mechanism requirements in
1723        other locations unless the mechanism name in the requirement is
1724        zone-qualified to be specific. But in such situations the base
1725        mechanism would have caused issues in any case.
1726        """
1727        self.equivalences.setdefault(
1728            capabilityOrMechanismState,
1729            set()
1730        ).add(requirement)
1731
1732    def removeEquivalence(
1733        self,
1734        requirement: base.Requirement,
1735        capabilityOrMechanismState: Union[
1736            base.Capability,
1737            Tuple[base.MechanismID, base.MechanismState]
1738        ]
1739    ) -> None:
1740        """
1741        Removes an equivalence. Raises a `KeyError` if no such
1742        equivalence existed.
1743        """
1744        self.equivalences[capabilityOrMechanismState].remove(requirement)
1745
1746    def hasAnyEquivalents(
1747        self,
1748        capabilityOrMechanismState: Union[
1749            base.Capability,
1750            Tuple[base.MechanismID, base.MechanismState]
1751        ]
1752    ) -> bool:
1753        """
1754        Returns `True` if the given capability or mechanism state has at
1755        least one equivalence.
1756        """
1757        return capabilityOrMechanismState in self.equivalences
1758
1759    def allEquivalents(
1760        self,
1761        capabilityOrMechanismState: Union[
1762            base.Capability,
1763            Tuple[base.MechanismID, base.MechanismState]
1764        ]
1765    ) -> Set[base.Requirement]:
1766        """
1767        Returns the set of equivalences for the given capability. This is
1768        a live set which may be modified (it's probably better to use
1769        `addEquivalence` and `removeEquivalence` instead...).
1770        """
1771        return self.equivalences.setdefault(
1772            capabilityOrMechanismState,
1773            set()
1774        )
1775
1776    def reversionType(self, name: str, equivalentTo: Set[str]) -> None:
1777        """
1778        Specifies a new reversion type, so that when used in a reversion
1779        aspects set with a colon before the name, all items in the
1780        `equivalentTo` value will be added to that set. These may
1781        include other custom reversion type names (with the colon) but
1782        take care not to create an equivalence loop which would result
1783        in a crash.
1784
1785        If you re-use the same name, it will override the old equivalence
1786        for that name.
1787        """
1788        self.reversionTypes[name] = equivalentTo
1789
1790    def addAction(
1791        self,
1792        decision: base.AnyDecisionSpecifier,
1793        action: base.Transition,
1794        requires: Optional[base.Requirement] = None,
1795        consequence: Optional[base.Consequence] = None,
1796        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
1797        annotations: Optional[List[base.Annotation]] = None,
1798    ) -> None:
1799        """
1800        Adds the given action as a possibility at the given decision. An
1801        action is just a self-edge, which can have requirements like any
1802        edge, and which can have consequences like any edge.
1803        The optional arguments are given to `setTransitionRequirement`
1804        and `setConsequence`; see those functions for descriptions
1805        of what they mean.
1806
1807        Raises a `KeyError` if a transition with the given name already
1808        exists at the given decision.
1809        """
1810        if tags is None:
1811            tags = {}
1812        if annotations is None:
1813            annotations = []
1814
1815        dID = self.resolveDecision(decision)
1816
1817        self.add_edge(
1818            dID,
1819            dID,
1820            key=action,
1821            tags=tags,
1822            annotations=annotations
1823        )
1824        self.setTransitionRequirement(dID, action, requires)
1825        if consequence is not None:
1826            self.setConsequence(dID, action, consequence)
1827
1828    def tagDecision(
1829        self,
1830        decision: base.AnyDecisionSpecifier,
1831        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
1832        tagValue: Union[
1833            base.TagValue,
1834            type[base.NoTagValue]
1835        ] = base.NoTagValue
1836    ) -> None:
1837        """
1838        Adds a tag (or many tags from a dictionary of tags) to a
1839        decision, using `1` as the value if no value is provided. It's
1840        a `ValueError` to provide a value when a dictionary of tags is
1841        provided to set multiple tags at once.
1842
1843        Note that certain tags have special meanings:
1844
1845        - 'unconfirmed' is used for decisions that represent unconfirmed
1846            parts of the graph (this is separate from the 'unknown'
1847            and/or 'hypothesized' exploration statuses, which are only
1848            tracked in a `DiscreteExploration`, not in a `DecisionGraph`).
1849            Various methods require this tag and many also add or remove
1850            it.
1851        """
1852        if isinstance(tagOrTags, base.Tag):
1853            if tagValue is base.NoTagValue:
1854                tagValue = 1
1855
1856            # Not sure why this cast is necessary given the `if` above...
1857            tagValue = cast(base.TagValue, tagValue)
1858
1859            tagOrTags = {tagOrTags: tagValue}
1860
1861        elif tagValue is not base.NoTagValue:
1862            raise ValueError(
1863                "Provided a dictionary to update multiple tags, but"
1864                " also a tag value."
1865            )
1866
1867        dID = self.resolveDecision(decision)
1868
1869        tagsAlready = self.nodes[dID].setdefault('tags', {})
1870        tagsAlready.update(tagOrTags)
1871
1872    def untagDecision(
1873        self,
1874        decision: base.AnyDecisionSpecifier,
1875        tag: base.Tag
1876    ) -> Union[base.TagValue, type[base.NoTagValue]]:
1877        """
1878        Removes a tag from a decision. Returns the tag's old value if
1879        the tag was present and got removed, or `NoTagValue` if the tag
1880        wasn't present.
1881        """
1882        dID = self.resolveDecision(decision)
1883
1884        target = self.nodes[dID]['tags']
1885        try:
1886            return target.pop(tag)
1887        except KeyError:
1888            return base.NoTagValue
1889
1890    def decisionTags(
1891        self,
1892        decision: base.AnyDecisionSpecifier
1893    ) -> Dict[base.Tag, base.TagValue]:
1894        """
1895        Returns the dictionary of tags for a decision. Edits to the
1896        returned value will be applied to the graph.
1897        """
1898        dID = self.resolveDecision(decision)
1899
1900        return self.nodes[dID]['tags']
1901
1902    def annotateDecision(
1903        self,
1904        decision: base.AnyDecisionSpecifier,
1905        annotationOrAnnotations: Union[
1906            base.Annotation,
1907            Sequence[base.Annotation]
1908        ]
1909    ) -> None:
1910        """
1911        Adds an annotation to a decision's annotations list.
1912        """
1913        dID = self.resolveDecision(decision)
1914
1915        if isinstance(annotationOrAnnotations, base.Annotation):
1916            annotationOrAnnotations = [annotationOrAnnotations]
1917        self.nodes[dID]['annotations'].extend(annotationOrAnnotations)
1918
1919    def decisionAnnotations(
1920        self,
1921        decision: base.AnyDecisionSpecifier
1922    ) -> List[base.Annotation]:
1923        """
1924        Returns the list of annotations for the specified decision.
1925        Modifying the list affects the graph.
1926        """
1927        dID = self.resolveDecision(decision)
1928
1929        return self.nodes[dID]['annotations']
1930
1931    def tagTransition(
1932        self,
1933        decision: base.AnyDecisionSpecifier,
1934        transition: base.Transition,
1935        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
1936        tagValue: Union[
1937            base.TagValue,
1938            type[base.NoTagValue]
1939        ] = base.NoTagValue
1940    ) -> None:
1941        """
1942        Adds a tag (or each tag from a dictionary) to a transition
1943        coming out of a specific decision. `1` will be used as the
1944        default value if a single tag is supplied; supplying a tag value
1945        when providing a dictionary of multiple tags to update is a
1946        `ValueError`.
1947
1948        Note that certain transition tags have special meanings:
1949        - 'trigger' causes any actions (but not normal transitions) that
1950            it applies to to be automatically triggered when
1951            `advanceSituation` is called and the decision they're
1952            attached to is active in the new situation (as long as the
1953            action's requirements are met). This happens once per
1954            situation; use 'wait' steps to re-apply triggers.
1955        """
1956        dID = self.resolveDecision(decision)
1957
1958        dest = self.destination(dID, transition)
1959        if isinstance(tagOrTags, base.Tag):
1960            if tagValue is base.NoTagValue:
1961                tagValue = 1
1962
1963            # Not sure why this is necessary given the `if` above...
1964            tagValue = cast(base.TagValue, tagValue)
1965
1966            tagOrTags = {tagOrTags: tagValue}
1967        elif tagValue is not base.NoTagValue:
1968            raise ValueError(
1969                "Provided a dictionary to update multiple tags, but"
1970                " also a tag value."
1971            )
1972
1973        info = cast(
1974            TransitionProperties,
1975            self.edges[dID, dest, transition]  # type:ignore
1976        )
1977
1978        info.setdefault('tags', {}).update(tagOrTags)
1979
1980    def untagTransition(
1981        self,
1982        decision: base.AnyDecisionSpecifier,
1983        transition: base.Transition,
1984        tagOrTags: Union[base.Tag, Set[base.Tag]]
1985    ) -> None:
1986        """
1987        Removes a tag (or each tag in a set) from a transition coming out
1988        of a specific decision. Raises a `KeyError` if (one of) the
1989        specified tag(s) is not currently applied to the specified
1990        transition.
1991        """
1992        dID = self.resolveDecision(decision)
1993
1994        dest = self.destination(dID, transition)
1995        if isinstance(tagOrTags, base.Tag):
1996            tagOrTags = {tagOrTags}
1997
1998        info = cast(
1999            TransitionProperties,
2000            self.edges[dID, dest, transition]  # type:ignore
2001        )
2002        tagsAlready = info.setdefault('tags', {})
2003
2004        for tag in tagOrTags:
2005            tagsAlready.pop(tag)
2006
2007    def transitionTags(
2008        self,
2009        decision: base.AnyDecisionSpecifier,
2010        transition: base.Transition
2011    ) -> Dict[base.Tag, base.TagValue]:
2012        """
2013        Returns the dictionary of tags for a transition. Edits to the
2014        returned dictionary will be applied to the graph.
2015        """
2016        dID = self.resolveDecision(decision)
2017
2018        dest = self.destination(dID, transition)
2019        info = cast(
2020            TransitionProperties,
2021            self.edges[dID, dest, transition]  # type:ignore
2022        )
2023        return info.setdefault('tags', {})
2024
2025    def annotateTransition(
2026        self,
2027        decision: base.AnyDecisionSpecifier,
2028        transition: base.Transition,
2029        annotations: Union[base.Annotation, Sequence[base.Annotation]]
2030    ) -> None:
2031        """
2032        Adds an annotation (or a sequence of annotations) to a
2033        transition's annotations list.
2034        """
2035        dID = self.resolveDecision(decision)
2036
2037        dest = self.destination(dID, transition)
2038        if isinstance(annotations, base.Annotation):
2039            annotations = [annotations]
2040        info = cast(
2041            TransitionProperties,
2042            self.edges[dID, dest, transition]  # type:ignore
2043        )
2044        info['annotations'].extend(annotations)
2045
2046    def transitionAnnotations(
2047        self,
2048        decision: base.AnyDecisionSpecifier,
2049        transition: base.Transition
2050    ) -> List[base.Annotation]:
2051        """
2052        Returns the annotation list for a specific transition at a
2053        specific decision. Editing the list affects the graph.
2054        """
2055        dID = self.resolveDecision(decision)
2056
2057        dest = self.destination(dID, transition)
2058        info = cast(
2059            TransitionProperties,
2060            self.edges[dID, dest, transition]  # type:ignore
2061        )
2062        return info['annotations']
2063
2064    def annotateZone(
2065        self,
2066        zone: base.Zone,
2067        annotations: Union[base.Annotation, Sequence[base.Annotation]]
2068    ) -> None:
2069        """
2070        Adds an annotation (or many annotations from a sequence) to a
2071        zone.
2072
2073        Raises a `MissingZoneError` if the specified zone does not exist.
2074        """
2075        if zone not in self.zones:
2076            raise MissingZoneError(
2077                f"Can't add annotation(s) to zone {zone!r} because that"
2078                f" zone doesn't exist yet."
2079            )
2080
2081        if isinstance(annotations, base.Annotation):
2082            annotations = [ annotations ]
2083
2084        self.zones[zone].annotations.extend(annotations)
2085
2086    def zoneAnnotations(self, zone: base.Zone) -> List[base.Annotation]:
2087        """
2088        Returns the list of annotations for the specified zone (empty if
2089        none have been added yet).
2090        """
2091        return self.zones[zone].annotations
2092
2093    def tagZone(
2094        self,
2095        zone: base.Zone,
2096        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
2097        tagValue: Union[
2098            base.TagValue,
2099            type[base.NoTagValue]
2100        ] = base.NoTagValue
2101    ) -> None:
2102        """
2103        Adds a tag (or many tags from a dictionary of tags) to a
2104        zone, using `1` as the value if no value is provided. It's
2105        a `ValueError` to provide a value when a dictionary of tags is
2106        provided to set multiple tags at once.
2107
2108        Raises a `MissingZoneError` if the specified zone does not exist.
2109        """
2110        if zone not in self.zones:
2111            raise MissingZoneError(
2112                f"Can't add tag(s) to zone {zone!r} because that zone"
2113                f" doesn't exist yet."
2114            )
2115
2116        if isinstance(tagOrTags, base.Tag):
2117            if tagValue is base.NoTagValue:
2118                tagValue = 1
2119
2120            # Not sure why this cast is necessary given the `if` above...
2121            tagValue = cast(base.TagValue, tagValue)
2122
2123            tagOrTags = {tagOrTags: tagValue}
2124
2125        elif tagValue is not base.NoTagValue:
2126            raise ValueError(
2127                "Provided a dictionary to update multiple tags, but"
2128                " also a tag value."
2129            )
2130
2131        tagsAlready = self.zones[zone].tags
2132        tagsAlready.update(tagOrTags)
2133
2134    def untagZone(
2135        self,
2136        zone: base.Zone,
2137        tag: base.Tag
2138    ) -> Union[base.TagValue, type[base.NoTagValue]]:
2139        """
2140        Removes a tag from a zone. Returns the tag's old value if the
2141        tag was present and got removed, or `NoTagValue` if the tag
2142        wasn't present.
2143
2144        Raises a `MissingZoneError` if the specified zone does not exist.
2145        """
2146        if zone not in self.zones:
2147            raise MissingZoneError(
2148                f"Can't remove tag {tag!r} from zone {zone!r} because"
2149                f" that zone doesn't exist yet."
2150            )
2151        target = self.zones[zone].tags
2152        try:
2153            return target.pop(tag)
2154        except KeyError:
2155            return base.NoTagValue
2156
2157    def zoneTags(
2158        self,
2159        zone: base.Zone
2160    ) -> Dict[base.Tag, base.TagValue]:
2161        """
2162        Returns the dictionary of tags for a zone. Edits to the returned
2163        value will be applied to the graph. Returns an empty tags
2164        dictionary if called on a zone that didn't have any tags
2165        previously, but raises a `MissingZoneError` if attempting to get
2166        tags for a zone which does not exist.
2167
2168        For example:
2169
2170        >>> g = DecisionGraph()
2171        >>> g.addDecision('A')
2172        0
2173        >>> g.addDecision('B')
2174        1
2175        >>> g.createZone('Zone')
2176        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2177 annotations=[])
2178        >>> g.tagZone('Zone', 'color', 'blue')
2179        >>> g.tagZone(
2180        ...     'Zone',
2181        ...     {'shape': 'square', 'color': 'red', 'sound': 'loud'}
2182        ... )
2183        >>> g.untagZone('Zone', 'sound')
2184        'loud'
2185        >>> g.zoneTags('Zone')
2186        {'color': 'red', 'shape': 'square'}
2187        """
2188        if zone in self.zones:
2189            return self.zones[zone].tags
2190        else:
2191            raise MissingZoneError(
2192                f"Tags for zone {zone!r} don't exist because that"
2193                f" zone has not been created yet."
2194            )
2195
2196    def createZone(self, zone: base.Zone, level: int = 0) -> base.ZoneInfo:
2197        """
2198        Creates an empty zone with the given name at the given level
2199        (default 0). Raises a `ZoneCollisionError` if that zone name is
2200        already in use (at any level), including if it's in use by a
2201        decision.
2202
2203        Raises an `InvalidLevelError` if the level value is less than 0.
2204
2205        Returns the `ZoneInfo` for the new blank zone.
2206
2207        For example:
2208
2209        >>> d = DecisionGraph()
2210        >>> d.createZone('Z', 0)
2211        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2212 annotations=[])
2213        >>> d.getZoneInfo('Z')
2214        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2215 annotations=[])
2216        >>> d.createZone('Z2', 0)
2217        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2218 annotations=[])
2219        >>> d.createZone('Z3', -1)  # level -1 is not valid (must be >= 0)
2220        Traceback (most recent call last):
2221        ...
2222        exploration.core.InvalidLevelError...
2223        >>> d.createZone('Z2')  # Name Z2 is already in use
2224        Traceback (most recent call last):
2225        ...
2226        exploration.core.ZoneCollisionError...
2227        """
2228        if level < 0:
2229            raise InvalidLevelError(
2230                "Cannot create a zone with a negative level."
2231            )
2232        if zone in self.zones:
2233            raise ZoneCollisionError(f"Zone {zone!r} already exists.")
2234        if zone in self:
2235            raise ZoneCollisionError(
2236                f"A decision named {zone!r} already exists, so a zone"
2237                f" with that name cannot be created."
2238            )
2239        info: base.ZoneInfo = base.ZoneInfo(
2240            level=level,
2241            parents=set(),
2242            contents=set(),
2243            tags={},
2244            annotations=[]
2245        )
2246        self.zones[zone] = info
2247        return info
2248
2249    def getZoneInfo(self, zone: base.Zone) -> Optional[base.ZoneInfo]:
2250        """
2251        Returns the `ZoneInfo` (level, parents, and contents) for the
2252        specified zone, or `None` if that zone does not exist.
2253
2254        For example:
2255
2256        >>> d = DecisionGraph()
2257        >>> d.createZone('Z', 0)
2258        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2259 annotations=[])
2260        >>> d.getZoneInfo('Z')
2261        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2262 annotations=[])
2263        >>> d.createZone('Z2', 0)
2264        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2265 annotations=[])
2266        >>> d.getZoneInfo('Z2')
2267        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2268 annotations=[])
2269        """
2270        return self.zones.get(zone)
2271
2272    def deleteZone(self, zone: base.Zone) -> base.ZoneInfo:
2273        """
2274        Deletes the specified zone, returning a `ZoneInfo` object with
2275        the information on the level, parents, and contents of that zone.
2276
2277        Raises a `MissingZoneError` if the zone in question does not
2278        exist.
2279
2280        The zone will be removed as a child/parent of any zones that used
2281        to contain it or be contained in it.
2282
2283        For example:
2284
2285        >>> d = DecisionGraph()
2286        >>> d.createZone('Z', 0)
2287        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2288 annotations=[])
2289        >>> d.getZoneInfo('Z')
2290        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2291 annotations=[])
2292        >>> d.deleteZone('Z')
2293        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2294 annotations=[])
2295        >>> d.getZoneInfo('Z') is None  # no info any more
2296        True
2297        >>> d.deleteZone('Z')  # can't re-delete
2298        Traceback (most recent call last):
2299        ...
2300        exploration.core.MissingZoneError...
2301        """
2302        info = self.getZoneInfo(zone)
2303        if info is None:
2304            raise MissingZoneError(
2305                f"Cannot delete zone {zone!r}: it does not exist."
2306            )
2307        for sub in info.contents:
2308            if 'zones' in self.nodes[sub]:
2309                try:
2310                    self.nodes[sub]['zones'].remove(zone)
2311                except KeyError:
2312                    pass
2313        del self.zones[zone]
2314        # Clean up child/contents info in ALL other zones
2315        for otherZoneInfo in self.zones.values():
2316            if zone in otherZoneInfo.parents:
2317                otherZoneInfo.parents.remove(zone)
2318            if zone in otherZoneInfo.contents:
2319                otherZoneInfo.contents.remove(zone)
2320        return info
2321
2322    def addDecisionToZone(
2323        self,
2324        decision: base.AnyDecisionSpecifier,
2325        zone: base.Zone
2326    ) -> None:
2327        """
2328        Adds a decision directly to a zone. Should normally only be used
2329        with level-0 zones. Raises a `MissingZoneError` if the specified
2330        zone did not already exist.
2331
2332        For example:
2333
2334        >>> d = DecisionGraph()
2335        >>> d.addDecision('A')
2336        0
2337        >>> d.addDecision('B')
2338        1
2339        >>> d.addDecision('C')
2340        2
2341        >>> d.createZone('Z', 0)
2342        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2343 annotations=[])
2344        >>> d.addDecisionToZone('A', 'Z')
2345        >>> d.getZoneInfo('Z')
2346        ZoneInfo(level=0, parents=set(), contents={0}, tags={},\
2347 annotations=[])
2348        >>> d.addDecisionToZone('B', 'Z')
2349        >>> d.getZoneInfo('Z')
2350        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2351 annotations=[])
2352        """
2353        dID = self.resolveDecision(decision)
2354
2355        if zone not in self.zones:
2356            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2357
2358        self.zones[zone].contents.add(dID)
2359        self.nodes[dID].setdefault('zones', set()).add(zone)
2360
2361    def removeDecisionFromZone(
2362        self,
2363        decision: base.AnyDecisionSpecifier,
2364        zone: base.Zone,
2365        thorough: bool = False
2366    ) -> bool:
2367        """
2368        Removes a decision from a zone if it had been in it, returning
2369        True if that decision had been in that zone, and False if it was
2370        not in that zone, including if that zone didn't exist.
2371
2372        Note that this only removes a decision from direct zone
2373        membership. If the decision is a member of one or more zones
2374        which are (directly or indirectly) sub-zones of the target zone,
2375        the decision will remain in those zones, and will still be
2376        indirectly part of the target zone afterwards. You can set
2377        `thorough` to True to also remove the decision from any immediate
2378        parents which are descendants of the specified zone, thereby
2379        ensuring that it isn't afterwards even indirectly included in
2380        that zone, even though this may affect membership in multiple
2381        zones at different levels.
2382
2383        When 'thorough' is used the result is True even if the decision
2384        had been an indirect member of the target zone; without it,
2385        False is returned for indirect members.
2386
2387        Examples:
2388
2389        >>> g = DecisionGraph()
2390        >>> g.addDecision('A')
2391        0
2392        >>> g.addDecision('B')
2393        1
2394        >>> g.createZone('level0', 0)
2395        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2396 annotations=[])
2397        >>> g.createZone('level1', 1)
2398        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2399 annotations=[])
2400        >>> g.createZone('level2', 2)
2401        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2402 annotations=[])
2403        >>> g.createZone('level3', 3)
2404        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
2405 annotations=[])
2406        >>> g.addDecisionToZone('A', 'level0')
2407        >>> g.addDecisionToZone('B', 'level0')
2408        >>> g.addZoneToZone('level0', 'level1')
2409        >>> g.addZoneToZone('level1', 'level2')
2410        >>> g.addZoneToZone('level2', 'level3')
2411        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
2412        >>> g.removeDecisionFromZone('A', 'level1')
2413        False
2414        >>> g.zoneParents(0)
2415        {'level0'}
2416        >>> g.removeDecisionFromZone('A', 'level0')
2417        True
2418        >>> g.zoneParents(0)
2419        set()
2420        >>> g.removeDecisionFromZone('A', 'level0')
2421        False
2422        >>> g.removeDecisionFromZone('B', 'level0')
2423        True
2424        >>> g.zoneParents(1)
2425        {'level2'}
2426        >>> g.removeDecisionFromZone('B', 'level0')
2427        False
2428        >>> g.removeDecisionFromZone('B', 'level2')
2429        True
2430        >>> g.zoneParents(1)
2431        set()
2432
2433        Example of 'thorough' argument:
2434
2435        >>> g = DecisionGraph()
2436        >>> g.addDecision('A')
2437        0
2438        >>> g.addDecision('B')
2439        1
2440        >>> g.addDecision('C')
2441        2
2442        >>> g.createZone('level0', 0)
2443        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2444 annotations=[])
2445        >>> g.createZone('level1', 1)
2446        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2447 annotations=[])
2448        >>> g.addDecisionToZone('A', 'level0')
2449        >>> g.addDecisionToZone('B', 'level0')
2450        >>> g.addDecisionToZone('C', 'level0')
2451        >>> g.addDecisionToZone('B', 'level1')  # also direct
2452        >>> g.addDecisionToZone('C', 'level1')  # also direct
2453        >>> g.addZoneToZone('level0', 'level1')
2454        >>> g.removeDecisionFromZone('A', 'level1')  # indirect member
2455        False
2456        >>> g.allDecisionsInZone('level1')  # A is still in there indirectly
2457        {0, 1, 2}
2458        >>> g.removeDecisionFromZone('A', 'level1', True)
2459        True
2460        >>> g.allDecisionsInZone('level1')  # A is now gone
2461        {1, 2}
2462        >>> g.zoneParents(0)  # removed from 'level0'
2463        set()
2464        >>> g.removeDecisionFromZone('B', 'level1')  # not thorough
2465        True
2466        >>> g.allDecisionsInZone('level1')  # B still there indirectly
2467        {1, 2}
2468        >>> g.removeDecisionFromZone('B', 'level1', True)  # thorough
2469        True
2470        >>> g.allDecisionsInZone('level1')  # now gone
2471        {2}
2472        >>> g.removeDecisionFromZone('C', 'level1', True)  # 1st time
2473        True
2474        >>> g.allDecisionsInZone('level1')  # now gone
2475        set()
2476        """
2477        dID = self.resolveDecision(decision)
2478
2479        if zone not in self.zones:
2480            return False
2481
2482        if thorough:
2483            parents = self.nodes[dID]['zones']  # editable reference
2484            discard = set()
2485            for parentZone in parents:
2486                if parentZone == zone:
2487                    info = self.zones[parentZone]
2488                    info.contents.remove(dID)
2489                    discard.add(zone)
2490                elif zone in self.zoneAncestors(parentZone):
2491                    info = self.zones[parentZone]
2492                    info.contents.remove(dID)
2493                    discard.add(parentZone)
2494            if discard:
2495                for indirectZone in discard:
2496                    parents.remove(indirectZone)
2497                return True
2498            else:
2499                return False
2500        else:
2501            info = self.zones[zone]
2502            if dID not in info.contents:
2503                return False
2504            else:
2505                info.contents.remove(dID)
2506                try:
2507                    self.nodes[dID]['zones'].remove(zone)
2508                except KeyError:
2509                    pass
2510                return True
2511
2512    def addZoneToZone(
2513        self,
2514        addIt: base.Zone,
2515        addTo: base.Zone
2516    ) -> None:
2517        """
2518        Adds a zone to another zone. The `addIt` one must be at a
2519        strictly lower level than the `addTo` zone, or an
2520        `InvalidLevelError` will be raised.
2521
2522        If the zone to be added didn't already exist, it is created at
2523        one level below the target zone. Similarly, if the zone being
2524        added to didn't already exist, it is created at one level above
2525        the target zone. If neither existed, a `MissingZoneError` will
2526        be raised.
2527
2528        For example:
2529
2530        >>> d = DecisionGraph()
2531        >>> d.addDecision('A')
2532        0
2533        >>> d.addDecision('B')
2534        1
2535        >>> d.addDecision('C')
2536        2
2537        >>> d.createZone('Z', 0)
2538        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2539 annotations=[])
2540        >>> d.addDecisionToZone('A', 'Z')
2541        >>> d.addDecisionToZone('B', 'Z')
2542        >>> d.getZoneInfo('Z')
2543        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2544 annotations=[])
2545        >>> d.createZone('Z2', 0)
2546        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2547 annotations=[])
2548        >>> d.addDecisionToZone('B', 'Z2')
2549        >>> d.addDecisionToZone('C', 'Z2')
2550        >>> d.getZoneInfo('Z2')
2551        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
2552 annotations=[])
2553        >>> d.createZone('l1Z', 1)
2554        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2555 annotations=[])
2556        >>> d.createZone('l2Z', 2)
2557        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2558 annotations=[])
2559        >>> d.addZoneToZone('Z', 'l1Z')
2560        >>> d.getZoneInfo('Z')
2561        ZoneInfo(level=0, parents={'l1Z'}, contents={0, 1}, tags={},\
2562 annotations=[])
2563        >>> d.getZoneInfo('l1Z')
2564        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
2565 annotations=[])
2566        >>> d.addZoneToZone('l1Z', 'l2Z')
2567        >>> d.getZoneInfo('l1Z')
2568        ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\
2569 annotations=[])
2570        >>> d.getZoneInfo('l2Z')
2571        ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\
2572 annotations=[])
2573        >>> d.addZoneToZone('Z2', 'l2Z')
2574        >>> d.getZoneInfo('Z2')
2575        ZoneInfo(level=0, parents={'l2Z'}, contents={1, 2}, tags={},\
2576 annotations=[])
2577        >>> l2i = d.getZoneInfo('l2Z')
2578        >>> l2i.level
2579        2
2580        >>> l2i.parents
2581        set()
2582        >>> sorted(l2i.contents)
2583        ['Z2', 'l1Z']
2584        >>> d.addZoneToZone('NZ', 'NZ2')
2585        Traceback (most recent call last):
2586        ...
2587        exploration.core.MissingZoneError...
2588        >>> d.addZoneToZone('Z', 'l1Z2')
2589        >>> zi = d.getZoneInfo('Z')
2590        >>> zi.level
2591        0
2592        >>> sorted(zi.parents)
2593        ['l1Z', 'l1Z2']
2594        >>> sorted(zi.contents)
2595        [0, 1]
2596        >>> d.getZoneInfo('l1Z2')
2597        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
2598 annotations=[])
2599        >>> d.addZoneToZone('NZ', 'l1Z')
2600        >>> d.getZoneInfo('NZ')
2601        ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\
2602 annotations=[])
2603        >>> zi = d.getZoneInfo('l1Z')
2604        >>> zi.level
2605        1
2606        >>> zi.parents
2607        {'l2Z'}
2608        >>> sorted(zi.contents)
2609        ['NZ', 'Z']
2610        """
2611        # Create one or the other (but not both) if they're missing
2612        addInfo = self.getZoneInfo(addIt)
2613        toInfo = self.getZoneInfo(addTo)
2614        if addInfo is None and toInfo is None:
2615            raise MissingZoneError(
2616                f"Cannot add zone {addIt!r} to zone {addTo!r}: neither"
2617                f" exists already."
2618            )
2619
2620        # Create missing addIt
2621        elif addInfo is None:
2622            toInfo = cast(base.ZoneInfo, toInfo)
2623            newLevel = toInfo.level - 1
2624            if newLevel < 0:
2625                raise InvalidLevelError(
2626                    f"Zone {addTo!r} is at level {toInfo.level} and so"
2627                    f" a new zone cannot be added underneath it."
2628                )
2629            addInfo = self.createZone(addIt, newLevel)
2630
2631        # Create missing addTo
2632        elif toInfo is None:
2633            addInfo = cast(base.ZoneInfo, addInfo)
2634            newLevel = addInfo.level + 1
2635            if newLevel < 0:
2636                raise InvalidLevelError(
2637                    f"Zone {addIt!r} is at level {addInfo.level} (!!!)"
2638                    f" and so a new zone cannot be added above it."
2639                )
2640            toInfo = self.createZone(addTo, newLevel)
2641
2642        # Now both addInfo and toInfo are defined
2643        if addInfo.level >= toInfo.level:
2644            raise InvalidLevelError(
2645                f"Cannot add zone {addIt!r} at level {addInfo.level}"
2646                f" to zone {addTo!r} at level {toInfo.level}: zones can"
2647                f" only contain zones of lower levels."
2648            )
2649
2650        # Now both addInfo and toInfo are defined
2651        toInfo.contents.add(addIt)
2652        addInfo.parents.add(addTo)
2653
2654    def removeZoneFromZone(
2655        self,
2656        removeIt: base.Zone,
2657        removeFrom: base.Zone
2658    ) -> bool:
2659        """
2660        Removes a zone from a zone if it had been in it, returning True
2661        if that zone had been in that zone, and False if it was not in
2662        that zone, including if either zone did not exist.
2663
2664        For example:
2665
2666        >>> d = DecisionGraph()
2667        >>> d.createZone('Z', 0)
2668        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2669 annotations=[])
2670        >>> d.createZone('Z2', 0)
2671        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2672 annotations=[])
2673        >>> d.createZone('l1Z', 1)
2674        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2675 annotations=[])
2676        >>> d.createZone('l2Z', 2)
2677        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2678 annotations=[])
2679        >>> d.addZoneToZone('Z', 'l1Z')
2680        >>> d.addZoneToZone('l1Z', 'l2Z')
2681        >>> d.getZoneInfo('Z')
2682        ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\
2683 annotations=[])
2684        >>> d.getZoneInfo('l1Z')
2685        ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\
2686 annotations=[])
2687        >>> d.getZoneInfo('l2Z')
2688        ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\
2689 annotations=[])
2690        >>> d.removeZoneFromZone('l1Z', 'l2Z')
2691        True
2692        >>> d.getZoneInfo('l1Z')
2693        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
2694 annotations=[])
2695        >>> d.getZoneInfo('l2Z')
2696        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2697 annotations=[])
2698        >>> d.removeZoneFromZone('Z', 'l1Z')
2699        True
2700        >>> d.getZoneInfo('Z')
2701        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2702 annotations=[])
2703        >>> d.getZoneInfo('l1Z')
2704        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2705 annotations=[])
2706        >>> d.removeZoneFromZone('Z', 'l1Z')
2707        False
2708        >>> d.removeZoneFromZone('Z', 'madeup')
2709        False
2710        >>> d.removeZoneFromZone('nope', 'madeup')
2711        False
2712        >>> d.removeZoneFromZone('nope', 'l1Z')
2713        False
2714        """
2715        remInfo = self.getZoneInfo(removeIt)
2716        fromInfo = self.getZoneInfo(removeFrom)
2717
2718        if remInfo is None or fromInfo is None:
2719            return False
2720
2721        if removeIt not in fromInfo.contents:
2722            return False
2723
2724        remInfo.parents.remove(removeFrom)
2725        fromInfo.contents.remove(removeIt)
2726        return True
2727
2728    def decisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]:
2729        """
2730        Returns a set of all decisions included directly in the given
2731        zone, not counting decisions included via intermediate
2732        sub-zones (see `allDecisionsInZone` to include those).
2733
2734        Raises a `MissingZoneError` if the specified zone does not
2735        exist.
2736
2737        The returned set is a copy, not a live editable set.
2738
2739        For example:
2740
2741        >>> d = DecisionGraph()
2742        >>> d.addDecision('A')
2743        0
2744        >>> d.addDecision('B')
2745        1
2746        >>> d.addDecision('C')
2747        2
2748        >>> d.createZone('Z', 0)
2749        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2750 annotations=[])
2751        >>> d.addDecisionToZone('A', 'Z')
2752        >>> d.addDecisionToZone('B', 'Z')
2753        >>> d.getZoneInfo('Z')
2754        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2755 annotations=[])
2756        >>> d.decisionsInZone('Z')
2757        {0, 1}
2758        >>> d.createZone('Z2', 0)
2759        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2760 annotations=[])
2761        >>> d.addDecisionToZone('B', 'Z2')
2762        >>> d.addDecisionToZone('C', 'Z2')
2763        >>> d.getZoneInfo('Z2')
2764        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
2765 annotations=[])
2766        >>> d.decisionsInZone('Z')
2767        {0, 1}
2768        >>> d.decisionsInZone('Z2')
2769        {1, 2}
2770        >>> d.createZone('l1Z', 1)
2771        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2772 annotations=[])
2773        >>> d.addZoneToZone('Z', 'l1Z')
2774        >>> d.decisionsInZone('Z')
2775        {0, 1}
2776        >>> d.decisionsInZone('l1Z')
2777        set()
2778        >>> d.decisionsInZone('madeup')
2779        Traceback (most recent call last):
2780        ...
2781        exploration.core.MissingZoneError...
2782        >>> zDec = d.decisionsInZone('Z')
2783        >>> zDec.add(2)  # won't affect the zone
2784        >>> zDec
2785        {0, 1, 2}
2786        >>> d.decisionsInZone('Z')
2787        {0, 1}
2788        """
2789        info = self.getZoneInfo(zone)
2790        if info is None:
2791            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2792
2793        # Everything that's not a zone must be a decision
2794        return {
2795            item
2796            for item in info.contents
2797            if isinstance(item, base.DecisionID)
2798        }
2799
2800    def subZones(self, zone: base.Zone) -> Set[base.Zone]:
2801        """
2802        Returns the set of all immediate sub-zones of the given zone.
2803        Will be an empty set if there are no sub-zones; raises a
2804        `MissingZoneError` if the specified zone does not exit.
2805
2806        The returned set is a copy, not a live editable set.
2807
2808        For example:
2809
2810        >>> d = DecisionGraph()
2811        >>> d.createZone('Z', 0)
2812        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2813 annotations=[])
2814        >>> d.subZones('Z')
2815        set()
2816        >>> d.createZone('l1Z', 1)
2817        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2818 annotations=[])
2819        >>> d.addZoneToZone('Z', 'l1Z')
2820        >>> d.subZones('Z')
2821        set()
2822        >>> d.subZones('l1Z')
2823        {'Z'}
2824        >>> s = d.subZones('l1Z')
2825        >>> s.add('Q')  # doesn't affect the zone
2826        >>> sorted(s)
2827        ['Q', 'Z']
2828        >>> d.subZones('l1Z')
2829        {'Z'}
2830        >>> d.subZones('madeup')
2831        Traceback (most recent call last):
2832        ...
2833        exploration.core.MissingZoneError...
2834        """
2835        info = self.getZoneInfo(zone)
2836        if info is None:
2837            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2838
2839        # Sub-zones will appear in self.zones
2840        return {
2841            item
2842            for item in info.contents
2843            if isinstance(item, base.Zone)
2844        }
2845
2846    def allDecisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]:
2847        """
2848        Returns a set containing all decisions in the given zone,
2849        including those included via sub-zones.
2850
2851        Raises a `MissingZoneError` if the specified zone does not
2852        exist.`
2853
2854        For example:
2855
2856        >>> d = DecisionGraph()
2857        >>> d.addDecision('A')
2858        0
2859        >>> d.addDecision('B')
2860        1
2861        >>> d.addDecision('C')
2862        2
2863        >>> d.createZone('Z', 0)
2864        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2865 annotations=[])
2866        >>> d.addDecisionToZone('A', 'Z')
2867        >>> d.addDecisionToZone('B', 'Z')
2868        >>> d.getZoneInfo('Z')
2869        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2870 annotations=[])
2871        >>> d.decisionsInZone('Z')
2872        {0, 1}
2873        >>> d.allDecisionsInZone('Z')
2874        {0, 1}
2875        >>> d.createZone('Z2', 0)
2876        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2877 annotations=[])
2878        >>> d.addDecisionToZone('B', 'Z2')
2879        >>> d.addDecisionToZone('C', 'Z2')
2880        >>> d.getZoneInfo('Z2')
2881        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
2882 annotations=[])
2883        >>> d.decisionsInZone('Z')
2884        {0, 1}
2885        >>> d.decisionsInZone('Z2')
2886        {1, 2}
2887        >>> d.allDecisionsInZone('Z2')
2888        {1, 2}
2889        >>> d.createZone('l1Z', 1)
2890        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2891 annotations=[])
2892        >>> d.createZone('l2Z', 2)
2893        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2894 annotations=[])
2895        >>> d.addZoneToZone('Z', 'l1Z')
2896        >>> d.addZoneToZone('l1Z', 'l2Z')
2897        >>> d.addZoneToZone('Z2', 'l2Z')
2898        >>> d.decisionsInZone('Z')
2899        {0, 1}
2900        >>> d.decisionsInZone('Z2')
2901        {1, 2}
2902        >>> d.decisionsInZone('l1Z')
2903        set()
2904        >>> d.allDecisionsInZone('l1Z')
2905        {0, 1}
2906        >>> d.allDecisionsInZone('l2Z')
2907        {0, 1, 2}
2908        """
2909        result: Set[base.DecisionID] = set()
2910        info = self.getZoneInfo(zone)
2911        if info is None:
2912            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2913
2914        for item in info.contents:
2915            if isinstance(item, base.Zone):
2916                # This can't be an error because of the condition above
2917                result |= self.allDecisionsInZone(item)
2918            else:  # it's a decision
2919                result.add(item)
2920
2921        return result
2922
2923    def zoneHierarchyLevel(self, zone: base.Zone) -> int:
2924        """
2925        Returns the hierarchy level of the given zone, as stored in its
2926        zone info.
2927
2928        By convention, level-0 zones contain decisions directly, and
2929        higher-level zones contain zones of lower levels. This
2930        convention is not enforced, and there could be exceptions to it.
2931
2932        Raises a `MissingZoneError` if the specified zone does not
2933        exist.
2934
2935        For example:
2936
2937        >>> d = DecisionGraph()
2938        >>> d.createZone('Z', 0)
2939        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2940 annotations=[])
2941        >>> d.createZone('l1Z', 1)
2942        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2943 annotations=[])
2944        >>> d.createZone('l5Z', 5)
2945        ZoneInfo(level=5, parents=set(), contents=set(), tags={},\
2946 annotations=[])
2947        >>> d.zoneHierarchyLevel('Z')
2948        0
2949        >>> d.zoneHierarchyLevel('l1Z')
2950        1
2951        >>> d.zoneHierarchyLevel('l5Z')
2952        5
2953        >>> d.zoneHierarchyLevel('madeup')
2954        Traceback (most recent call last):
2955        ...
2956        exploration.core.MissingZoneError...
2957        """
2958        info = self.getZoneInfo(zone)
2959        if info is None:
2960            raise MissingZoneError(f"Zone {zone!r} dose not exist.")
2961
2962        return info.level
2963
2964    def zoneParents(
2965        self,
2966        zoneOrDecision: Union[base.Zone, base.DecisionID]
2967    ) -> Set[base.Zone]:
2968        """
2969        Returns the set of all zones which directly contain the target
2970        zone or decision.
2971
2972        Raises a `MissingDecisionError` if the target is neither a valid
2973        zone nor a valid decision.
2974
2975        Returns a copy, not a live editable set.
2976
2977        Example:
2978
2979        >>> g = DecisionGraph()
2980        >>> g.addDecision('A')
2981        0
2982        >>> g.addDecision('B')
2983        1
2984        >>> g.createZone('level0', 0)
2985        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2986 annotations=[])
2987        >>> g.createZone('level1', 1)
2988        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2989 annotations=[])
2990        >>> g.createZone('level2', 2)
2991        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2992 annotations=[])
2993        >>> g.createZone('level3', 3)
2994        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
2995 annotations=[])
2996        >>> g.addDecisionToZone('A', 'level0')
2997        >>> g.addDecisionToZone('B', 'level0')
2998        >>> g.addZoneToZone('level0', 'level1')
2999        >>> g.addZoneToZone('level1', 'level2')
3000        >>> g.addZoneToZone('level2', 'level3')
3001        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
3002        >>> sorted(g.zoneParents(0))
3003        ['level0']
3004        >>> sorted(g.zoneParents(1))
3005        ['level0', 'level2']
3006        """
3007        if zoneOrDecision in self.zones:
3008            zoneOrDecision = cast(base.Zone, zoneOrDecision)
3009            info = cast(base.ZoneInfo, self.getZoneInfo(zoneOrDecision))
3010            return copy.copy(info.parents)
3011        elif zoneOrDecision in self:
3012            return self.nodes[zoneOrDecision].get('zones', set())
3013        else:
3014            raise MissingDecisionError(
3015                f"Name {zoneOrDecision!r} is neither a valid zone nor a"
3016                f" valid decision."
3017            )
3018
3019    def zoneAncestors(
3020        self,
3021        zoneOrDecision: Union[base.Zone, base.DecisionID],
3022        exclude: Set[base.Zone] = set(),
3023        atLevel: Optional[int] = None
3024    ) -> Set[base.Zone]:
3025        """
3026        Returns the set of zones which contain the target zone or
3027        decision, either directly or indirectly. The target is not
3028        included in the set.
3029
3030        Any ones listed in the `exclude` set are also excluded, as are
3031        any of their ancestors which are not also ancestors of the
3032        target zone via another path of inclusion.
3033
3034        If `atLevel` is not `None`, then only zones at that hierarchy
3035        level will be included.
3036
3037        Raises a `MissingDecisionError` if the target is nether a valid
3038        zone nor a valid decision.
3039
3040        Example:
3041
3042        >>> g = DecisionGraph()
3043        >>> g.addDecision('A')
3044        0
3045        >>> g.addDecision('B')
3046        1
3047        >>> g.createZone('level0', 0)
3048        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
3049 annotations=[])
3050        >>> g.createZone('level1', 1)
3051        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3052 annotations=[])
3053        >>> g.createZone('level2', 2)
3054        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
3055 annotations=[])
3056        >>> g.createZone('level3', 3)
3057        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
3058 annotations=[])
3059        >>> g.addDecisionToZone('A', 'level0')
3060        >>> g.addDecisionToZone('B', 'level0')
3061        >>> g.addZoneToZone('level0', 'level1')
3062        >>> g.addZoneToZone('level1', 'level2')
3063        >>> g.addZoneToZone('level2', 'level3')
3064        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
3065        >>> sorted(g.zoneAncestors(0))
3066        ['level0', 'level1', 'level2', 'level3']
3067        >>> sorted(g.zoneAncestors(1))
3068        ['level0', 'level1', 'level2', 'level3']
3069        >>> sorted(g.zoneParents(0))
3070        ['level0']
3071        >>> sorted(g.zoneParents(1))
3072        ['level0', 'level2']
3073        >>> sorted(g.zoneAncestors(0, atLevel=2))
3074        ['level2']
3075        >>> sorted(g.zoneAncestors(0, exclude={'level2'}))
3076        ['level0', 'level1']
3077        """
3078        # Copy is important here!
3079        result = set(self.zoneParents(zoneOrDecision))
3080        result -= exclude
3081        for parent in copy.copy(result):
3082            # Recursively dig up ancestors, but exclude
3083            # results-so-far to avoid re-enumerating when there are
3084            # multiple braided inclusion paths.
3085            result |= self.zoneAncestors(parent, result | exclude, atLevel)
3086
3087        if atLevel is not None:
3088            return {
3089                z for z in result if self.zoneHierarchyLevel(z) == atLevel
3090            }
3091        else:
3092            return result
3093
3094    def region(
3095        self,
3096        decision: base.DecisionID,
3097        useLevel: int=1
3098    ) -> Optional[base.Zone]:
3099        """
3100        Returns the 'region' that this decision belongs to. 'Regions'
3101        are level-1 zones, but when a decision is in multiple level-1
3102        zones, its region counts as the smallest of those zones in terms
3103        of total decisions contained, breaking ties by the one with the
3104        alphabetically earlier name.
3105
3106        Always returns a single zone name string, unless the target
3107        decision is not in any level-1 zones, in which case it returns
3108        `None`.
3109
3110        If `useLevel` is specified, then zones of the specified level
3111        will be used instead of level-1 zones.
3112
3113        Example:
3114
3115        >>> g = DecisionGraph()
3116        >>> g.addDecision('A')
3117        0
3118        >>> g.addDecision('B')
3119        1
3120        >>> g.addDecision('C')
3121        2
3122        >>> g.addDecision('D')
3123        3
3124        >>> g.createZone('zoneX', 0)
3125        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
3126 annotations=[])
3127        >>> g.createZone('regionA', 1)
3128        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3129 annotations=[])
3130        >>> g.createZone('zoneY', 0)
3131        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
3132 annotations=[])
3133        >>> g.createZone('regionB', 1)
3134        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3135 annotations=[])
3136        >>> g.createZone('regionC', 1)
3137        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3138 annotations=[])
3139        >>> g.createZone('quadrant', 2)
3140        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
3141 annotations=[])
3142        >>> g.addDecisionToZone('A', 'zoneX')
3143        >>> g.addDecisionToZone('B', 'zoneY')
3144        >>> # C is not in any level-1 zones
3145        >>> g.addDecisionToZone('D', 'zoneX')
3146        >>> g.addDecisionToZone('D', 'zoneY')  # D is in both
3147        >>> g.addZoneToZone('zoneX', 'regionA')
3148        >>> g.addZoneToZone('zoneY', 'regionB')
3149        >>> g.addZoneToZone('zoneX', 'regionC')  # includes both
3150        >>> g.addZoneToZone('zoneY', 'regionC')
3151        >>> g.addZoneToZone('regionA', 'quadrant')
3152        >>> g.addZoneToZone('regionB', 'quadrant')
3153        >>> g.addDecisionToZone('C', 'regionC')  # Direct in level-2
3154        >>> sorted(g.allDecisionsInZone('zoneX'))
3155        [0, 3]
3156        >>> sorted(g.allDecisionsInZone('zoneY'))
3157        [1, 3]
3158        >>> sorted(g.allDecisionsInZone('regionA'))
3159        [0, 3]
3160        >>> sorted(g.allDecisionsInZone('regionB'))
3161        [1, 3]
3162        >>> sorted(g.allDecisionsInZone('regionC'))
3163        [0, 1, 2, 3]
3164        >>> sorted(g.allDecisionsInZone('quadrant'))
3165        [0, 1, 3]
3166        >>> g.region(0)  # for A; region A is smaller than region C
3167        'regionA'
3168        >>> g.region(1)  # for B; region B is also smaller than C
3169        'regionB'
3170        >>> g.region(2)  # for C
3171        'regionC'
3172        >>> g.region(3)  # for D; tie broken alphabetically
3173        'regionA'
3174        >>> g.region(0, useLevel=0)  # for A at level 0
3175        'zoneX'
3176        >>> g.region(1, useLevel=0)  # for B at level 0
3177        'zoneY'
3178        >>> g.region(2, useLevel=0) is None  # for C at level 0 (none)
3179        True
3180        >>> g.region(3, useLevel=0)  # for D at level 0; tie
3181        'zoneX'
3182        >>> g.region(0, useLevel=2) # for A at level 2
3183        'quadrant'
3184        >>> g.region(1, useLevel=2) # for B at level 2
3185        'quadrant'
3186        >>> g.region(2, useLevel=2) is None # for C at level 2 (none)
3187        True
3188        >>> g.region(3, useLevel=2)  # for D at level 2
3189        'quadrant'
3190        """
3191        relevant = self.zoneAncestors(decision, atLevel=useLevel)
3192        if len(relevant) == 0:
3193            return None
3194        elif len(relevant) == 1:
3195            for zone in relevant:
3196                return zone
3197            return None  # not really necessary but keeps mypy happy
3198        else:
3199            # more than one zone ancestor at the relevant hierarchy
3200            # level: need to measure their sizes
3201            minSize = None
3202            candidates = []
3203            for zone in relevant:
3204                size = len(self.allDecisionsInZone(zone))
3205                if minSize is None or size < minSize:
3206                    candidates = [zone]
3207                    minSize = size
3208                elif size == minSize:
3209                    candidates.append(zone)
3210            return min(candidates)
3211
3212    def zoneEdges(self, zone: base.Zone) -> Optional[
3213        Tuple[
3214            Set[Tuple[base.DecisionID, base.Transition]],
3215            Set[Tuple[base.DecisionID, base.Transition]]
3216        ]
3217    ]:
3218        """
3219        Given a zone to look at, finds all of the transitions which go
3220        out of and into that zone, ignoring internal transitions between
3221        decisions in the zone. This includes all decisions in sub-zones.
3222        The return value is a pair of sets for outgoing and then
3223        incoming transitions, where each transition is specified as a
3224        (sourceID, transitionName) pair.
3225
3226        Returns `None` if the target zone isn't yet fully defined.
3227
3228        Note that this takes time proportional to *all* edges plus *all*
3229        nodes in the graph no matter how large or small the zone in
3230        question is.
3231
3232        >>> g = DecisionGraph()
3233        >>> g.addDecision('A')
3234        0
3235        >>> g.addDecision('B')
3236        1
3237        >>> g.addDecision('C')
3238        2
3239        >>> g.addDecision('D')
3240        3
3241        >>> g.addTransition('A', 'up', 'B', 'down')
3242        >>> g.addTransition('B', 'right', 'C', 'left')
3243        >>> g.addTransition('C', 'down', 'D', 'up')
3244        >>> g.addTransition('D', 'left', 'A', 'right')
3245        >>> g.addTransition('A', 'tunnel', 'C', 'tunnel')
3246        >>> g.createZone('Z', 0)
3247        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
3248 annotations=[])
3249        >>> g.createZone('ZZ', 1)
3250        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3251 annotations=[])
3252        >>> g.addZoneToZone('Z', 'ZZ')
3253        >>> g.addDecisionToZone('A', 'Z')
3254        >>> g.addDecisionToZone('B', 'Z')
3255        >>> g.addDecisionToZone('D', 'ZZ')
3256        >>> outgoing, incoming = g.zoneEdges('Z')  # TODO: Sort for testing
3257        >>> sorted(outgoing)
3258        [(0, 'right'), (0, 'tunnel'), (1, 'right')]
3259        >>> sorted(incoming)
3260        [(2, 'left'), (2, 'tunnel'), (3, 'left')]
3261        >>> outgoing, incoming = g.zoneEdges('ZZ')
3262        >>> sorted(outgoing)
3263        [(0, 'tunnel'), (1, 'right'), (3, 'up')]
3264        >>> sorted(incoming)
3265        [(2, 'down'), (2, 'left'), (2, 'tunnel')]
3266        >>> g.zoneEdges('madeup') is None
3267        True
3268        """
3269        # Find the interior nodes
3270        try:
3271            interior = self.allDecisionsInZone(zone)
3272        except MissingZoneError:
3273            return None
3274
3275        # Set up our result
3276        results: Tuple[
3277            Set[Tuple[base.DecisionID, base.Transition]],
3278            Set[Tuple[base.DecisionID, base.Transition]]
3279        ] = (set(), set())
3280
3281        # Because finding incoming edges requires searching the entire
3282        # graph anyways, it's more efficient to just consider each edge
3283        # once.
3284        for fromDecision in self:
3285            fromThere = self[fromDecision]
3286            for toDecision in fromThere:
3287                for transition in fromThere[toDecision]:
3288                    sourceIn = fromDecision in interior
3289                    destIn = toDecision in interior
3290                    if sourceIn and not destIn:
3291                        results[0].add((fromDecision, transition))
3292                    elif destIn and not sourceIn:
3293                        results[1].add((fromDecision, transition))
3294
3295        return results
3296
3297    def replaceZonesInHierarchy(
3298        self,
3299        target: base.AnyDecisionSpecifier,
3300        zone: base.Zone,
3301        level: int
3302    ) -> None:
3303        """
3304        This method replaces one or more zones which contain the
3305        specified `target` decision with a specific zone, at a specific
3306        level in the zone hierarchy (see `zoneHierarchyLevel`). If the
3307        named zone doesn't yet exist, it will be created.
3308
3309        To do this, it looks at all zones which contain the target
3310        decision directly or indirectly (see `zoneAncestors`) and which
3311        are at the specified level.
3312
3313        - Any direct children of those zones which are ancestors of the
3314            target decision are removed from those zones and placed into
3315            the new zone instead, regardless of their levels. Indirect
3316            children are not affected (except perhaps indirectly via
3317            their parents' ancestors changing).
3318        - The new zone is placed into every direct parent of those
3319            zones, regardless of their levels (those parents are by
3320            definition all ancestors of the target decision).
3321        - If there were no zones at the target level, every zone at the
3322            next level down which is an ancestor of the target decision
3323            (or just that decision if the level is 0) is placed into the
3324            new zone as a direct child (and is removed from any previous
3325            parents it had). In this case, the new zone will also be
3326            added as a sub-zone to every ancestor of the target decision
3327            at the level above the specified level, if there are any.
3328            * In this case, if there are no zones at the level below the
3329                specified level, the highest level of zones smaller than
3330                that is treated as the level below, down to targeting
3331                the decision itself.
3332            * Similarly, if there are no zones at the level above the
3333                specified level but there are zones at a higher level,
3334                the new zone will be added to each of the zones in the
3335                lowest level above the target level that has zones in it.
3336
3337        A `MissingDecisionError` will be raised if the specified
3338        decision is not valid, or if the decision is left as default but
3339        there is no current decision in the exploration.
3340
3341        An `InvalidLevelError` will be raised if the level is less than
3342        zero.
3343
3344        Example:
3345
3346        >>> g = DecisionGraph()
3347        >>> g.addDecision('decision')
3348        0
3349        >>> g.addDecision('alternate')
3350        1
3351        >>> g.createZone('zone0', 0)
3352        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
3353 annotations=[])
3354        >>> g.createZone('zone1', 1)
3355        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3356 annotations=[])
3357        >>> g.createZone('zone2.1', 2)
3358        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
3359 annotations=[])
3360        >>> g.createZone('zone2.2', 2)
3361        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
3362 annotations=[])
3363        >>> g.createZone('zone3', 3)
3364        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
3365 annotations=[])
3366        >>> g.addDecisionToZone('decision', 'zone0')
3367        >>> g.addDecisionToZone('alternate', 'zone0')
3368        >>> g.addZoneToZone('zone0', 'zone1')
3369        >>> g.addZoneToZone('zone1', 'zone2.1')
3370        >>> g.addZoneToZone('zone1', 'zone2.2')
3371        >>> g.addZoneToZone('zone2.1', 'zone3')
3372        >>> g.addZoneToZone('zone2.2', 'zone3')
3373        >>> g.zoneHierarchyLevel('zone0')
3374        0
3375        >>> g.zoneHierarchyLevel('zone1')
3376        1
3377        >>> g.zoneHierarchyLevel('zone2.1')
3378        2
3379        >>> g.zoneHierarchyLevel('zone2.2')
3380        2
3381        >>> g.zoneHierarchyLevel('zone3')
3382        3
3383        >>> sorted(g.decisionsInZone('zone0'))
3384        [0, 1]
3385        >>> sorted(g.zoneAncestors('zone0'))
3386        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
3387        >>> g.subZones('zone1')
3388        {'zone0'}
3389        >>> g.zoneParents('zone0')
3390        {'zone1'}
3391        >>> g.replaceZonesInHierarchy('decision', 'new0', 0)
3392        >>> g.zoneParents('zone0')
3393        {'zone1'}
3394        >>> g.zoneParents('new0')
3395        {'zone1'}
3396        >>> sorted(g.zoneAncestors('zone0'))
3397        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
3398        >>> sorted(g.zoneAncestors('new0'))
3399        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
3400        >>> g.decisionsInZone('zone0')
3401        {1}
3402        >>> g.decisionsInZone('new0')
3403        {0}
3404        >>> sorted(g.subZones('zone1'))
3405        ['new0', 'zone0']
3406        >>> g.zoneParents('new0')
3407        {'zone1'}
3408        >>> g.replaceZonesInHierarchy('decision', 'new1', 1)
3409        >>> sorted(g.zoneAncestors(0))
3410        ['new0', 'new1', 'zone2.1', 'zone2.2', 'zone3']
3411        >>> g.subZones('zone1')
3412        {'zone0'}
3413        >>> g.subZones('new1')
3414        {'new0'}
3415        >>> g.zoneParents('new0')
3416        {'new1'}
3417        >>> sorted(g.zoneParents('zone1'))
3418        ['zone2.1', 'zone2.2']
3419        >>> sorted(g.zoneParents('new1'))
3420        ['zone2.1', 'zone2.2']
3421        >>> g.zoneParents('zone2.1')
3422        {'zone3'}
3423        >>> g.zoneParents('zone2.2')
3424        {'zone3'}
3425        >>> sorted(g.subZones('zone2.1'))
3426        ['new1', 'zone1']
3427        >>> sorted(g.subZones('zone2.2'))
3428        ['new1', 'zone1']
3429        >>> sorted(g.allDecisionsInZone('zone2.1'))
3430        [0, 1]
3431        >>> sorted(g.allDecisionsInZone('zone2.2'))
3432        [0, 1]
3433        >>> g.replaceZonesInHierarchy('decision', 'new2', 2)
3434        >>> g.zoneParents('zone2.1')
3435        {'zone3'}
3436        >>> g.zoneParents('zone2.2')
3437        {'zone3'}
3438        >>> g.subZones('zone2.1')
3439        {'zone1'}
3440        >>> g.subZones('zone2.2')
3441        {'zone1'}
3442        >>> g.subZones('new2')
3443        {'new1'}
3444        >>> g.zoneParents('new2')
3445        {'zone3'}
3446        >>> g.allDecisionsInZone('zone2.1')
3447        {1}
3448        >>> g.allDecisionsInZone('zone2.2')
3449        {1}
3450        >>> g.allDecisionsInZone('new2')
3451        {0}
3452        >>> sorted(g.subZones('zone3'))
3453        ['new2', 'zone2.1', 'zone2.2']
3454        >>> g.zoneParents('zone3')
3455        set()
3456        >>> sorted(g.allDecisionsInZone('zone3'))
3457        [0, 1]
3458        >>> g.replaceZonesInHierarchy('decision', 'new3', 3)
3459        >>> sorted(g.subZones('zone3'))
3460        ['zone2.1', 'zone2.2']
3461        >>> g.subZones('new3')
3462        {'new2'}
3463        >>> g.zoneParents('zone3')
3464        set()
3465        >>> g.zoneParents('new3')
3466        set()
3467        >>> g.allDecisionsInZone('zone3')
3468        {1}
3469        >>> g.allDecisionsInZone('new3')
3470        {0}
3471        >>> g.replaceZonesInHierarchy('decision', 'new4', 5)
3472        >>> g.subZones('new4')
3473        {'new3'}
3474        >>> g.zoneHierarchyLevel('new4')
3475        5
3476
3477        Another example of level collapse when trying to replace a zone
3478        at a level above :
3479
3480        >>> g = DecisionGraph()
3481        >>> g.addDecision('A')
3482        0
3483        >>> g.addDecision('B')
3484        1
3485        >>> g.createZone('level0', 0)
3486        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
3487 annotations=[])
3488        >>> g.createZone('level1', 1)
3489        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3490 annotations=[])
3491        >>> g.createZone('level2', 2)
3492        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
3493 annotations=[])
3494        >>> g.createZone('level3', 3)
3495        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
3496 annotations=[])
3497        >>> g.addDecisionToZone('B', 'level0')
3498        >>> g.addZoneToZone('level0', 'level1')
3499        >>> g.addZoneToZone('level1', 'level2')
3500        >>> g.addZoneToZone('level2', 'level3')
3501        >>> g.addDecisionToZone('A', 'level3') # missing some zone levels
3502        >>> g.zoneHierarchyLevel('level3')
3503        3
3504        >>> g.replaceZonesInHierarchy('A', 'newFirst', 1)
3505        >>> g.zoneHierarchyLevel('newFirst')
3506        1
3507        >>> g.decisionsInZone('newFirst')
3508        {0}
3509        >>> g.decisionsInZone('level3')
3510        set()
3511        >>> sorted(g.allDecisionsInZone('level3'))
3512        [0, 1]
3513        >>> g.subZones('newFirst')
3514        set()
3515        >>> sorted(g.subZones('level3'))
3516        ['level2', 'newFirst']
3517        >>> g.zoneParents('newFirst')
3518        {'level3'}
3519        >>> g.replaceZonesInHierarchy('A', 'newSecond', 2)
3520        >>> g.zoneHierarchyLevel('newSecond')
3521        2
3522        >>> g.decisionsInZone('newSecond')
3523        set()
3524        >>> g.allDecisionsInZone('newSecond')
3525        {0}
3526        >>> g.subZones('newSecond')
3527        {'newFirst'}
3528        >>> g.zoneParents('newSecond')
3529        {'level3'}
3530        >>> g.zoneParents('newFirst')
3531        {'newSecond'}
3532        >>> sorted(g.subZones('level3'))
3533        ['level2', 'newSecond']
3534        """
3535        tID = self.resolveDecision(target)
3536
3537        if level < 0:
3538            raise InvalidLevelError(
3539                f"Target level must be positive (got {level})."
3540            )
3541
3542        info = self.getZoneInfo(zone)
3543        if info is None:
3544            info = self.createZone(zone, level)
3545        elif level != info.level:
3546            raise InvalidLevelError(
3547                f"Target level ({level}) does not match the level of"
3548                f" the target zone ({zone!r} at level {info.level})."
3549            )
3550
3551        # Collect both parents & ancestors
3552        parents = self.zoneParents(tID)
3553        ancestors = set(self.zoneAncestors(tID))
3554
3555        # Map from levels to sets of zones from the ancestors pool
3556        levelMap: Dict[int, Set[base.Zone]] = {}
3557        highest = -1
3558        for ancestor in ancestors:
3559            ancestorLevel = self.zoneHierarchyLevel(ancestor)
3560            levelMap.setdefault(ancestorLevel, set()).add(ancestor)
3561            if ancestorLevel > highest:
3562                highest = ancestorLevel
3563
3564        # Figure out if we have target zones to replace or not
3565        reparentDecision = False
3566        if level in levelMap:
3567            # If there are zones at the target level,
3568            targetZones = levelMap[level]
3569
3570            above = set()
3571            below = set()
3572
3573            for replaced in targetZones:
3574                above |= self.zoneParents(replaced)
3575                below |= self.subZones(replaced)
3576                if replaced in parents:
3577                    reparentDecision = True
3578
3579            # Only ancestors should be reparented
3580            below &= ancestors
3581
3582        else:
3583            # Find levels w/ zones in them above + below
3584            levelBelow = level - 1
3585            levelAbove = level + 1
3586            below = levelMap.get(levelBelow, set())
3587            above = levelMap.get(levelAbove, set())
3588
3589            while len(below) == 0 and levelBelow > 0:
3590                levelBelow -= 1
3591                below = levelMap.get(levelBelow, set())
3592
3593            if len(below) == 0:
3594                reparentDecision = True
3595
3596            while len(above) == 0 and levelAbove < highest:
3597                levelAbove += 1
3598                above = levelMap.get(levelAbove, set())
3599
3600        # Handle re-parenting zones below
3601        for under in below:
3602            for parent in self.zoneParents(under):
3603                if parent in ancestors:
3604                    self.removeZoneFromZone(under, parent)
3605            self.addZoneToZone(under, zone)
3606
3607        # Add this zone to each parent
3608        for parent in above:
3609            self.addZoneToZone(zone, parent)
3610
3611        # Re-parent the decision itself if necessary
3612        if reparentDecision:
3613            # (using set() here to avoid size-change-during-iteration)
3614            for parent in set(parents):
3615                self.removeDecisionFromZone(tID, parent)
3616            self.addDecisionToZone(tID, zone)
3617
3618    def getReciprocal(
3619        self,
3620        decision: base.AnyDecisionSpecifier,
3621        transition: base.Transition
3622    ) -> Optional[base.Transition]:
3623        """
3624        Returns the reciprocal edge for the specified transition from the
3625        specified decision (see `setReciprocal`). Returns
3626        `None` if no reciprocal has been established for that
3627        transition, or if that decision or transition does not exist.
3628        """
3629        dID = self.resolveDecision(decision)
3630
3631        dest = self.getDestination(dID, transition)
3632        if dest is not None:
3633            info = cast(
3634                TransitionProperties,
3635                self.edges[dID, dest, transition]  # type:ignore
3636            )
3637            recip = info.get("reciprocal")
3638            if recip is not None and not isinstance(recip, base.Transition):
3639                raise ValueError(f"Invalid reciprocal value: {repr(recip)}")
3640            return recip
3641        else:
3642            return None
3643
3644    def setReciprocal(
3645        self,
3646        decision: base.AnyDecisionSpecifier,
3647        transition: base.Transition,
3648        reciprocal: Optional[base.Transition],
3649        setBoth: bool = True,
3650        cleanup: bool = True
3651    ) -> None:
3652        """
3653        Sets the 'reciprocal' transition for a particular transition from
3654        a particular decision, and removes the reciprocal property from
3655        any old reciprocal transition.
3656
3657        Raises a `MissingDecisionError` or a `MissingTransitionError` if
3658        the specified decision or transition does not exist.
3659
3660        Raises an `InvalidDestinationError` if the reciprocal transition
3661        does not exist, or if it does exist but does not lead back to
3662        the decision the transition came from.
3663
3664        If `setBoth` is True (the default) then the transition which is
3665        being identified as a reciprocal will also have its reciprocal
3666        property set, pointing back to the primary transition being
3667        modified, and any old reciprocal of that transition will have its
3668        reciprocal set to None. If you want to create a situation with
3669        non-exclusive reciprocals, use `setBoth=False`.
3670
3671        If `cleanup` is True (the default) then abandoned reciprocal
3672        transitions (for both edges if `setBoth` was true) have their
3673        reciprocal properties removed. Set `cleanup` to false if you want
3674        to retain them, although this will result in non-exclusive
3675        reciprocal relationships.
3676
3677        If the `reciprocal` value is None, this deletes the reciprocal
3678        value entirely, and if `setBoth` is true, it does this for the
3679        previous reciprocal edge as well. No error is raised in this case
3680        when there was not already a reciprocal to delete.
3681
3682        Note that one should remove a reciprocal relationship before
3683        redirecting either edge of the pair in a way that gives it a new
3684        reciprocal, since otherwise, a later attempt to remove the
3685        reciprocal with `setBoth` set to True (the default) will end up
3686        deleting the reciprocal information from the other edge that was
3687        already modified. There is no way to reliably detect and avoid
3688        this, because two different decisions could (and often do in
3689        practice) have transitions with identical names, meaning that the
3690        reciprocal value will still be the same, but it will indicate a
3691        different edge in virtue of the destination of the edge changing.
3692
3693        ## Example
3694
3695        >>> g = DecisionGraph()
3696        >>> g.addDecision('G')
3697        0
3698        >>> g.addDecision('H')
3699        1
3700        >>> g.addDecision('I')
3701        2
3702        >>> g.addTransition('G', 'up', 'H', 'down')
3703        >>> g.addTransition('G', 'next', 'H', 'prev')
3704        >>> g.addTransition('H', 'next', 'I', 'prev')
3705        >>> g.addTransition('H', 'return', 'G')
3706        >>> g.setReciprocal('G', 'up', 'next') # Error w/ destinations
3707        Traceback (most recent call last):
3708        ...
3709        exploration.core.InvalidDestinationError...
3710        >>> g.setReciprocal('G', 'up', 'none') # Doesn't exist
3711        Traceback (most recent call last):
3712        ...
3713        exploration.core.MissingTransitionError...
3714        >>> g.getReciprocal('G', 'up')
3715        'down'
3716        >>> g.getReciprocal('H', 'down')
3717        'up'
3718        >>> g.getReciprocal('H', 'return') is None
3719        True
3720        >>> g.setReciprocal('G', 'up', 'return')
3721        >>> g.getReciprocal('G', 'up')
3722        'return'
3723        >>> g.getReciprocal('H', 'down') is None
3724        True
3725        >>> g.getReciprocal('H', 'return')
3726        'up'
3727        >>> g.setReciprocal('H', 'return', None) # remove the reciprocal
3728        >>> g.getReciprocal('G', 'up') is None
3729        True
3730        >>> g.getReciprocal('H', 'down') is None
3731        True
3732        >>> g.getReciprocal('H', 'return') is None
3733        True
3734        >>> g.setReciprocal('G', 'up', 'down', setBoth=False) # one-way
3735        >>> g.getReciprocal('G', 'up')
3736        'down'
3737        >>> g.getReciprocal('H', 'down') is None
3738        True
3739        >>> g.getReciprocal('H', 'return') is None
3740        True
3741        >>> g.setReciprocal('H', 'return', 'up', setBoth=False) # non-sym
3742        >>> g.getReciprocal('G', 'up')
3743        'down'
3744        >>> g.getReciprocal('H', 'down') is None
3745        True
3746        >>> g.getReciprocal('H', 'return')
3747        'up'
3748        >>> g.setReciprocal('H', 'down', 'up') # setBoth not needed
3749        >>> g.getReciprocal('G', 'up')
3750        'down'
3751        >>> g.getReciprocal('H', 'down')
3752        'up'
3753        >>> g.getReciprocal('H', 'return') # unchanged
3754        'up'
3755        >>> g.setReciprocal('G', 'up', 'return', cleanup=False) # no cleanup
3756        >>> g.getReciprocal('G', 'up')
3757        'return'
3758        >>> g.getReciprocal('H', 'down')
3759        'up'
3760        >>> g.getReciprocal('H', 'return') # unchanged
3761        'up'
3762        >>> # Cleanup only applies to reciprocal if setBoth is true
3763        >>> g.setReciprocal('H', 'down', 'up', setBoth=False)
3764        >>> g.getReciprocal('G', 'up')
3765        'return'
3766        >>> g.getReciprocal('H', 'down')
3767        'up'
3768        >>> g.getReciprocal('H', 'return') # not cleaned up w/out setBoth
3769        'up'
3770        >>> g.setReciprocal('H', 'down', 'up') # with cleanup and setBoth
3771        >>> g.getReciprocal('G', 'up')
3772        'down'
3773        >>> g.getReciprocal('H', 'down')
3774        'up'
3775        >>> g.getReciprocal('H', 'return') is None # cleaned up
3776        True
3777        """
3778        dID = self.resolveDecision(decision)
3779
3780        dest = self.destination(dID, transition) # possible KeyError
3781        if reciprocal is None:
3782            rDest = None
3783        else:
3784            rDest = self.getDestination(dest, reciprocal)
3785
3786        # Set or delete reciprocal property
3787        if reciprocal is None:
3788            # Delete the property
3789            info = self.edges[dID, dest, transition]  # type:ignore
3790
3791            old = info.pop('reciprocal')
3792            if setBoth:
3793                rDest = self.getDestination(dest, old)
3794                if rDest != dID:
3795                    raise RuntimeError(
3796                        f"Invalid reciprocal {old!r} for transition"
3797                        f" {transition!r} from {self.identityOf(dID)}:"
3798                        f" destination is {rDest}."
3799                    )
3800                rInfo = self.edges[dest, dID, old]  # type:ignore
3801                if 'reciprocal' in rInfo:
3802                    del rInfo['reciprocal']
3803        else:
3804            # Set the property, checking for errors first
3805            if rDest is None:
3806                raise MissingTransitionError(
3807                    f"Reciprocal transition {reciprocal!r} for"
3808                    f" transition {transition!r} from decision"
3809                    f" {self.identityOf(dID)} does not exist at"
3810                    f" decision {self.identityOf(dest)}"
3811                )
3812
3813            if rDest != dID:
3814                raise InvalidDestinationError(
3815                    f"Reciprocal transition {reciprocal!r} from"
3816                    f" decision {self.identityOf(dest)} does not lead"
3817                    f" back to decision {self.identityOf(dID)}."
3818                )
3819
3820            eProps = self.edges[dID, dest, transition]  # type:ignore [index]
3821            abandoned = eProps.get('reciprocal')
3822            eProps['reciprocal'] = reciprocal
3823            if cleanup and abandoned not in (None, reciprocal):
3824                aProps = self.edges[dest, dID, abandoned]  # type:ignore
3825                if 'reciprocal' in aProps:
3826                    del aProps['reciprocal']
3827
3828            if setBoth:
3829                rProps = self.edges[dest, dID, reciprocal]  # type:ignore
3830                revAbandoned = rProps.get('reciprocal')
3831                rProps['reciprocal'] = transition
3832                # Sever old reciprocal relationship
3833                if cleanup and revAbandoned not in (None, transition):
3834                    raProps = self.edges[
3835                        dID,  # type:ignore
3836                        dest,
3837                        revAbandoned
3838                    ]
3839                    del raProps['reciprocal']
3840
3841    def getReciprocalPair(
3842        self,
3843        decision: base.AnyDecisionSpecifier,
3844        transition: base.Transition
3845    ) -> Optional[Tuple[base.DecisionID, base.Transition]]:
3846        """
3847        Returns a tuple containing both the destination decision ID and
3848        the transition at that decision which is the reciprocal of the
3849        specified destination & transition. Returns `None` if no
3850        reciprocal has been established for that transition, or if that
3851        decision or transition does not exist.
3852
3853        >>> g = DecisionGraph()
3854        >>> g.addDecision('A')
3855        0
3856        >>> g.addDecision('B')
3857        1
3858        >>> g.addDecision('C')
3859        2
3860        >>> g.addTransition('A', 'up', 'B', 'down')
3861        >>> g.addTransition('B', 'right', 'C', 'left')
3862        >>> g.addTransition('A', 'oneway', 'C')
3863        >>> g.getReciprocalPair('A', 'up')
3864        (1, 'down')
3865        >>> g.getReciprocalPair('B', 'down')
3866        (0, 'up')
3867        >>> g.getReciprocalPair('B', 'right')
3868        (2, 'left')
3869        >>> g.getReciprocalPair('C', 'left')
3870        (1, 'right')
3871        >>> g.getReciprocalPair('C', 'up') is None
3872        True
3873        >>> g.getReciprocalPair('Q', 'up') is None
3874        True
3875        >>> g.getReciprocalPair('A', 'tunnel') is None
3876        True
3877        """
3878        try:
3879            dID = self.resolveDecision(decision)
3880        except MissingDecisionError:
3881            return None
3882
3883        reciprocal = self.getReciprocal(dID, transition)
3884        if reciprocal is None:
3885            return None
3886        else:
3887            destination = self.getDestination(dID, transition)
3888            if destination is None:
3889                return None
3890            else:
3891                return (destination, reciprocal)
3892
3893    def addDecision(
3894        self,
3895        name: base.DecisionName,
3896        domain: Optional[base.Domain] = None,
3897        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
3898        annotations: Optional[List[base.Annotation]] = None
3899    ) -> base.DecisionID:
3900        """
3901        Adds a decision to the graph, without any transitions yet. Each
3902        decision will be assigned an ID so name collisions are allowed,
3903        but it's usually best to keep names unique at least within each
3904        zone. If no domain is provided, the `DEFAULT_DOMAIN` will be
3905        used for the decision's domain. A dictionary of tags and/or a
3906        list of annotations (strings in both cases) may be provided.
3907
3908        Returns the newly-assigned `DecisionID` for the decision it
3909        created.
3910
3911        Emits a `DecisionCollisionWarning` if a decision with the
3912        provided name already exists and the `WARN_OF_NAME_COLLISIONS`
3913        global variable is set to `True`.
3914        """
3915        # Defaults
3916        if domain is None:
3917            domain = base.DEFAULT_DOMAIN
3918        if tags is None:
3919            tags = {}
3920        if annotations is None:
3921            annotations = []
3922
3923        # Error checking
3924        if name in self.nameLookup and WARN_OF_NAME_COLLISIONS:
3925            warnings.warn(
3926                (
3927                    f"Adding decision {name!r}: Another decision with"
3928                    f" that name already exists."
3929                ),
3930                DecisionCollisionWarning
3931            )
3932
3933        dID = self._assignID()
3934
3935        # Add the decision
3936        self.add_node(
3937            dID,
3938            name=name,
3939            domain=domain,
3940            tags=tags,
3941            annotations=annotations
3942        )
3943        #TODO: Elide tags/annotations if they're empty?
3944
3945        # Track it in our `nameLookup` dictionary
3946        self.nameLookup.setdefault(name, []).append(dID)
3947
3948        return dID
3949
3950    def addIdentifiedDecision(
3951        self,
3952        dID: base.DecisionID,
3953        name: base.DecisionName,
3954        domain: Optional[base.Domain] = None,
3955        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
3956        annotations: Optional[List[base.Annotation]] = None
3957    ) -> None:
3958        """
3959        Adds a new decision to the graph using a specific decision ID,
3960        rather than automatically assigning a new decision ID like
3961        `addDecision` does. Otherwise works like `addDecision`.
3962
3963        Raises a `DecisionCollisionError` if the specified decision ID
3964        is already in use.
3965        """
3966        # Defaults
3967        if domain is None:
3968            domain = base.DEFAULT_DOMAIN
3969        if tags is None:
3970            tags = {}
3971        if annotations is None:
3972            annotations = []
3973
3974        # Error checking
3975        if dID in self.nodes:
3976            raise DecisionCollisionError(
3977                f"Cannot add a node with id {dID} and name {name!r}:"
3978                f" that ID is already used by node {self.identityOf(dID)}"
3979            )
3980
3981        if name in self.nameLookup and WARN_OF_NAME_COLLISIONS:
3982            warnings.warn(
3983                (
3984                    f"Adding decision {name!r}: Another decision with"
3985                    f" that name already exists."
3986                ),
3987                DecisionCollisionWarning
3988            )
3989
3990        # Add the decision
3991        self.add_node(
3992            dID,
3993            name=name,
3994            domain=domain,
3995            tags=tags,
3996            annotations=annotations
3997        )
3998        #TODO: Elide tags/annotations if they're empty?
3999
4000        # Track it in our `nameLookup` dictionary
4001        self.nameLookup.setdefault(name, []).append(dID)
4002
4003    def addTransition(
4004        self,
4005        fromDecision: base.AnyDecisionSpecifier,
4006        name: base.Transition,
4007        toDecision: base.AnyDecisionSpecifier,
4008        reciprocal: Optional[base.Transition] = None,
4009        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
4010        annotations: Optional[List[base.Annotation]] = None,
4011        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
4012        revAnnotations: Optional[List[base.Annotation]] = None,
4013        requires: Optional[base.Requirement] = None,
4014        consequence: Optional[base.Consequence] = None,
4015        revRequires: Optional[base.Requirement] = None,
4016        revConsequece: Optional[base.Consequence] = None
4017    ) -> None:
4018        """
4019        Adds a transition connecting two decisions. A specifier for each
4020        decision is required, as is a name for the transition. If a
4021        `reciprocal` is provided, a reciprocal edge will be added in the
4022        opposite direction using that name; by default only the specified
4023        edge is added. A `TransitionCollisionError` will be raised if the
4024        `reciprocal` matches the name of an existing edge at the
4025        destination decision.
4026
4027        Both decisions must already exist, or a `MissingDecisionError`
4028        will be raised.
4029
4030        A dictionary of tags and/or a list of annotations may be
4031        provided. Tags and/or annotations for the reverse edge may also
4032        be specified if one is being added.
4033
4034        The `requires`, `consequence`, `revRequires`, and `revConsequece`
4035        arguments specify requirements and/or consequences of the new
4036        outgoing and reciprocal edges.
4037
4038        An example:
4039
4040        >>> g = DecisionGraph()
4041        >>> g.addDecision('A')
4042        0
4043        >>> g.addDecision('B')
4044        1
4045        >>> g.addDecision('C')
4046        2
4047        >>> g.addTransition('A', 'up', 'B', 'down')
4048        >>> g.destinationsFrom('A')
4049        {'up': 1}
4050        >>> g.destinationsFrom('B')
4051        {'down': 0}
4052        >>> g.addTransition('A', 'right', 'C', 'left')
4053        >>> g.destinationsFrom('A')
4054        {'up': 1, 'right': 2}
4055        >>> g.destinationsFrom('C')
4056        {'left': 0}
4057        """
4058        # Defaults
4059        if tags is None:
4060            tags = {}
4061        if annotations is None:
4062            annotations = []
4063        if revTags is None:
4064            revTags = {}
4065        if revAnnotations is None:
4066            revAnnotations = []
4067
4068        # Error checking
4069        fromID = self.resolveDecision(fromDecision)
4070        toID = self.resolveDecision(toDecision)
4071
4072        # Note: have to check this first so we don't add the forward edge
4073        # and then error out after a side effect!
4074        if (
4075            reciprocal is not None
4076        and self.getDestination(toDecision, reciprocal) is not None
4077        ):
4078            raise TransitionCollisionError(
4079                f"Cannot add a transition from"
4080                f" {self.identityOf(fromDecision)} to"
4081                f" {self.identityOf(toDecision)} with reciprocal edge"
4082                f" {reciprocal!r}: {reciprocal!r} is already used as an"
4083                f" edge name at {self.identityOf(toDecision)}."
4084            )
4085
4086        # Add the edge
4087        self.add_edge(
4088            fromID,
4089            toID,
4090            key=name,
4091            tags=tags,
4092            annotations=annotations
4093        )
4094        self.setTransitionRequirement(fromID, name, requires)
4095        if consequence is not None:
4096            self.setConsequence(fromID, name, consequence)
4097        if reciprocal is not None:
4098            # Add the reciprocal edge
4099            self.add_edge(
4100                toID,
4101                fromID,
4102                key=reciprocal,
4103                tags=revTags,
4104                annotations=revAnnotations
4105            )
4106            self.setReciprocal(fromID, name, reciprocal)
4107            self.setTransitionRequirement(
4108                toID,
4109                reciprocal,
4110                revRequires
4111            )
4112            if revConsequece is not None:
4113                self.setConsequence(toID, reciprocal, revConsequece)
4114
4115    def removeTransition(
4116        self,
4117        fromDecision: base.AnyDecisionSpecifier,
4118        transition: base.Transition,
4119        removeReciprocal=False
4120    ) -> Union[
4121        TransitionProperties,
4122        Tuple[TransitionProperties, TransitionProperties]
4123    ]:
4124        """
4125        Removes a transition. If `removeReciprocal` is true (False is the
4126        default) any reciprocal transition will also be removed (but no
4127        error will occur if there wasn't a reciprocal).
4128
4129        For each removed transition, *every* transition that targeted
4130        that transition as its reciprocal will have its reciprocal set to
4131        `None`, to avoid leaving any invalid reciprocal values.
4132
4133        Raises a `KeyError` if either the target decision or the target
4134        transition does not exist.
4135
4136        Returns a transition properties dictionary with the properties
4137        of the removed transition, or if `removeReciprocal` is true,
4138        returns a pair of such dictionaries for the target transition
4139        and its reciprocal.
4140
4141        ## Example
4142
4143        >>> g = DecisionGraph()
4144        >>> g.addDecision('A')
4145        0
4146        >>> g.addDecision('B')
4147        1
4148        >>> g.addTransition('A', 'up', 'B', 'down', tags={'wide'})
4149        >>> g.addTransition('A', 'in', 'B', 'out') # we won't touch this
4150        >>> g.addTransition('A', 'next', 'B')
4151        >>> g.setReciprocal('A', 'next', 'down', setBoth=False)
4152        >>> p = g.removeTransition('A', 'up')
4153        >>> p['tags']
4154        {'wide'}
4155        >>> g.destinationsFrom('A')
4156        {'in': 1, 'next': 1}
4157        >>> g.destinationsFrom('B')
4158        {'down': 0, 'out': 0}
4159        >>> g.getReciprocal('B', 'down') is None
4160        True
4161        >>> g.getReciprocal('A', 'next') # Asymmetrical left over
4162        'down'
4163        >>> g.getReciprocal('A', 'in') # not affected
4164        'out'
4165        >>> g.getReciprocal('B', 'out') # not affected
4166        'in'
4167        >>> # Now with removeReciprocal set to True
4168        >>> g.addTransition('A', 'up', 'B') # add this back in
4169        >>> g.setReciprocal('A', 'up', 'down') # sets both
4170        >>> p = g.removeTransition('A', 'up', removeReciprocal=True)
4171        >>> g.destinationsFrom('A')
4172        {'in': 1, 'next': 1}
4173        >>> g.destinationsFrom('B')
4174        {'out': 0}
4175        >>> g.getReciprocal('A', 'next') is None
4176        True
4177        >>> g.getReciprocal('A', 'in') # not affected
4178        'out'
4179        >>> g.getReciprocal('B', 'out') # not affected
4180        'in'
4181        >>> g.removeTransition('A', 'none')
4182        Traceback (most recent call last):
4183        ...
4184        exploration.core.MissingTransitionError...
4185        >>> g.removeTransition('Z', 'nope')
4186        Traceback (most recent call last):
4187        ...
4188        exploration.core.MissingDecisionError...
4189        """
4190        # Resolve target ID
4191        fromID = self.resolveDecision(fromDecision)
4192
4193        # raises if either is missing:
4194        destination = self.destination(fromID, transition)
4195        reciprocal = self.getReciprocal(fromID, transition)
4196
4197        # Get dictionaries of parallel & antiparallel edges to be
4198        # checked for invalid reciprocals after removing edges
4199        # Note: these will update live as we remove edges
4200        allAntiparallel = self[destination][fromID]
4201        allParallel = self[fromID][destination]
4202
4203        # Remove the target edge
4204        fProps = self.getTransitionProperties(fromID, transition)
4205        self.remove_edge(fromID, destination, transition)
4206
4207        # Clean up any dangling reciprocal values
4208        for tProps in allAntiparallel.values():
4209            if tProps.get('reciprocal') == transition:
4210                del tProps['reciprocal']
4211
4212        # Remove the reciprocal if requested
4213        if removeReciprocal and reciprocal is not None:
4214            rProps = self.getTransitionProperties(destination, reciprocal)
4215            self.remove_edge(destination, fromID, reciprocal)
4216
4217            # Clean up any dangling reciprocal values
4218            for tProps in allParallel.values():
4219                if tProps.get('reciprocal') == reciprocal:
4220                    del tProps['reciprocal']
4221
4222            return (fProps, rProps)
4223        else:
4224            return fProps
4225
4226    def addMechanism(
4227        self,
4228        name: base.MechanismName,
4229        where: Optional[base.AnyDecisionSpecifier] = None
4230    ) -> base.MechanismID:
4231        """
4232        Creates a new mechanism with the given name at the specified
4233        decision, returning its assigned ID. If `where` is `None`, it
4234        creates a global mechanism. Raises a `MechanismCollisionError`
4235        if a mechanism with the same name already exists at a specified
4236        decision (or already exists as a global mechanism).
4237
4238        Note that if the decision is deleted, the mechanism will be as
4239        well.
4240
4241        Since `MechanismState`s are not tracked by `DecisionGraph`s but
4242        instead are part of a `State`, the mechanism won't be in any
4243        particular state, which means it will be treated as being in the
4244        `base.DEFAULT_MECHANISM_STATE`.
4245        """
4246        if where is None:
4247            mechs = self.globalMechanisms
4248            dID = None
4249        else:
4250            dID = self.resolveDecision(where)
4251            mechs = self.nodes[dID].setdefault('mechanisms', {})
4252
4253        if name in mechs:
4254            if dID is None:
4255                raise MechanismCollisionError(
4256                    f"A global mechanism named {name!r} already exists."
4257                )
4258            else:
4259                raise MechanismCollisionError(
4260                    f"A mechanism named {name!r} already exists at"
4261                    f" decision {self.identityOf(dID)}."
4262                )
4263
4264        mID = self._assignMechanismID()
4265        mechs[name] = mID
4266        self.mechanisms[mID] = (dID, name)
4267        return mID
4268
4269    def mechanismsAt(
4270        self,
4271        decision: base.AnyDecisionSpecifier
4272    ) -> Dict[base.MechanismName, base.MechanismID]:
4273        """
4274        Returns a dictionary mapping mechanism names to their IDs for
4275        all mechanisms at the specified decision.
4276        """
4277        dID = self.resolveDecision(decision)
4278
4279        return self.nodes[dID]['mechanisms']
4280
4281    def mechanismDetails(
4282        self,
4283        mID: base.MechanismID
4284    ) -> Optional[Tuple[Optional[base.DecisionID], base.MechanismName]]:
4285        """
4286        Returns a tuple containing the decision ID and mechanism name
4287        for the specified mechanism. Returns `None` if there is no
4288        mechanism with that ID. For global mechanisms, `None` is used in
4289        place of a decision ID.
4290        """
4291        return self.mechanisms.get(mID)
4292
4293    def deleteMechanism(self, mID: base.MechanismID) -> None:
4294        """
4295        Deletes the specified mechanism.
4296        """
4297        name, dID = self.mechanisms.pop(mID)
4298
4299        del self.nodes[dID]['mechanisms'][name]
4300
4301    def localLookup(
4302        self,
4303        startFrom: Union[
4304            base.AnyDecisionSpecifier,
4305            Collection[base.AnyDecisionSpecifier]
4306        ],
4307        findAmong: Callable[
4308            ['DecisionGraph', Union[Set[base.DecisionID], str]],
4309            Optional[LookupResult]
4310        ],
4311        fallbackLayerName: Optional[str] = "fallback",
4312        fallbackToAllDecisions: bool = True
4313    ) -> Optional[LookupResult]:
4314        """
4315        Looks up some kind of result in the graph by starting from a
4316        base set of decisions and widening the search iteratively based
4317        on zones. This first searches for result(s) in the set of
4318        decisions given, then in the set of all decisions which are in
4319        level-0 zones containing those decisions, then in level-1 zones,
4320        etc. When it runs out of relevant zones, it will check all
4321        decisions which are in any domain that a decision from the
4322        initial search set is in, and then if `fallbackLayerName` is a
4323        string, it will provide that string instead of a set of decision
4324        IDs to the `findAmong` function as the next layer to search.
4325        After the `fallbackLayerName` is used, if
4326        `fallbackToAllDecisions` is `True` (the default) a final search
4327        will be run on all decisions in the graph. The provided
4328        `findAmong` function is called on each successive decision ID
4329        set, until it generates a non-`None` result. We stop and return
4330        that non-`None` result as soon as one is generated. But if none
4331        of the decision sets consulted generate non-`None` results, then
4332        the entire result will be `None`.
4333        """
4334        # Normalize starting decisions to a set
4335        if isinstance(startFrom, (int, str, base.DecisionSpecifier)):
4336            startFrom = set([startFrom])
4337
4338        # Resolve decision IDs; convert to set
4339        searchArea: Union[Set[base.DecisionID], str] = set(
4340            self.resolveDecision(spec) for spec in startFrom
4341        )
4342
4343        # Find all ancestor zones & all relevant domains
4344        allAncestors = set()
4345        relevantDomains = set()
4346        for startingDecision in searchArea:
4347            allAncestors |= self.zoneAncestors(startingDecision)
4348            relevantDomains.add(self.domainFor(startingDecision))
4349
4350        # Build layers dictionary
4351        ancestorLayers: Dict[int, Set[base.Zone]] = {}
4352        for zone in allAncestors:
4353            info = self.getZoneInfo(zone)
4354            assert info is not None
4355            level = info.level
4356            ancestorLayers.setdefault(level, set()).add(zone)
4357
4358        searchLayers: LookupLayersList = (
4359            cast(LookupLayersList, [None])
4360          + cast(LookupLayersList, sorted(ancestorLayers.keys()))
4361          + cast(LookupLayersList, ["domains"])
4362        )
4363        if fallbackLayerName is not None:
4364            searchLayers.append("fallback")
4365
4366        if fallbackToAllDecisions:
4367            searchLayers.append("all")
4368
4369        # Continue our search through zone layers
4370        for layer in searchLayers:
4371            # Update search area on subsequent iterations
4372            if layer == "domains":
4373                searchArea = set()
4374                for relevant in relevantDomains:
4375                    searchArea |= self.allDecisionsInDomain(relevant)
4376            elif layer == "fallback":
4377                assert fallbackLayerName is not None
4378                searchArea = fallbackLayerName
4379            elif layer == "all":
4380                searchArea = set(self.nodes)
4381            elif layer is not None:
4382                layer = cast(int, layer)  # must be an integer
4383                searchZones = ancestorLayers[layer]
4384                searchArea = set()
4385                for zone in searchZones:
4386                    searchArea |= self.allDecisionsInZone(zone)
4387            # else it's the first iteration and we use the starting
4388            # searchArea
4389
4390            try:
4391                searchResult: Optional[LookupResult] = findAmong(
4392                    self,
4393                    searchArea
4394                )
4395            except Exception as e:
4396                note = f" (Search started from: {startFrom!r})"
4397                if hasattr(e, "add_note"):
4398                    e.add_note(note)
4399                else:
4400                    e.args = (e.args[0] + note,) + e.args[1:]
4401                raise e
4402
4403            if searchResult is not None:
4404                return searchResult
4405
4406        # Didn't find any non-None results.
4407        return None
4408
4409    @staticmethod
4410    def uniqueMechanismFinder(name: base.MechanismName) -> Callable[
4411        ['DecisionGraph', Union[Set[base.DecisionID], str]],
4412        Optional[base.MechanismID]
4413    ]:
4414        """
4415        Returns a search function that looks for the given mechanism ID,
4416        suitable for use with `localLookup`. The finder will raise a
4417        `AmbiguousMechanismError` if it finds more than one mechanism
4418        with the specified name at the same level of the search.
4419        """
4420        def namedMechanismFinder(
4421            graph: 'DecisionGraph',
4422            searchIn: Union[Set[base.DecisionID], str]
4423        ) -> Optional[base.MechanismID]:
4424            """
4425            Generated finder function for `localLookup` to find a unique
4426            mechanism by name.
4427            """
4428            candidates: List[base.MechanismID] = []
4429
4430            if searchIn == "fallback":
4431                if name in graph.globalMechanisms:
4432                    candidates = [graph.globalMechanisms[name]]
4433
4434            else:
4435                assert isinstance(searchIn, set)
4436                for dID in searchIn:
4437                    mechs = graph.nodes[dID].get('mechanisms', {})
4438                    if name in mechs:
4439                        candidates.append(mechs[name])
4440
4441            if len(candidates) > 1:
4442                raise AmbiguousMechanismError(
4443                    f"There are {len(candidates)} mechanisms named {name!r}"
4444                    f" in the search area ({len(searchIn)} decisions(s))."
4445                )
4446            elif len(candidates) == 1:
4447                return candidates[0]
4448            else:
4449                return None
4450
4451        return namedMechanismFinder
4452
4453    def lookupMechanism(
4454        self,
4455        startFrom: Union[
4456            base.AnyDecisionSpecifier,
4457            Collection[base.AnyDecisionSpecifier]
4458        ],
4459        name: base.MechanismName
4460    ) -> base.MechanismID:
4461        """
4462        Looks up the mechanism with the given name 'closest' to the
4463        given decision or set of decisions. First it looks for a
4464        mechanism with that name that's at one of those decisions. Then
4465        it starts looking in level-0 zones which contain any of them,
4466        then in level-1 zones, and so on. If it finds two mechanisms
4467        with the target name during the same search pass, it raises a
4468        `AmbiguousMechanismError`, but if it finds one it returns it.
4469        Raises a `MissingMechanismError` if there is no mechanisms with
4470        that name among global mechanisms (searched after the last
4471        applicable level of zones) or anywhere in the graph (which is the
4472        final level of search after checking global mechanisms).
4473
4474        For example:
4475
4476        >>> d = DecisionGraph()
4477        >>> d.addDecision('A')
4478        0
4479        >>> d.addDecision('B')
4480        1
4481        >>> d.addDecision('C')
4482        2
4483        >>> d.addDecision('D')
4484        3
4485        >>> d.addDecision('E')
4486        4
4487        >>> d.addMechanism('switch', 'A')
4488        0
4489        >>> d.addMechanism('switch', 'B')
4490        1
4491        >>> d.addMechanism('switch', 'C')
4492        2
4493        >>> d.addMechanism('lever', 'D')
4494        3
4495        >>> d.addMechanism('lever', None)  # global
4496        4
4497        >>> d.createZone('Z1', 0)
4498        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
4499 annotations=[])
4500        >>> d.createZone('Z2', 0)
4501        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
4502 annotations=[])
4503        >>> d.createZone('Zup', 1)
4504        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
4505 annotations=[])
4506        >>> d.addDecisionToZone('A', 'Z1')
4507        >>> d.addDecisionToZone('B', 'Z1')
4508        >>> d.addDecisionToZone('C', 'Z2')
4509        >>> d.addDecisionToZone('D', 'Z2')
4510        >>> d.addDecisionToZone('E', 'Z1')
4511        >>> d.addZoneToZone('Z1', 'Zup')
4512        >>> d.addZoneToZone('Z2', 'Zup')
4513        >>> d.lookupMechanism(set(), 'switch')  # 3x among all decisions
4514        Traceback (most recent call last):
4515        ...
4516        exploration.core.AmbiguousMechanismError...
4517        >>> d.lookupMechanism(set(), 'lever')  # 1x global > 1x all
4518        4
4519        >>> d.lookupMechanism({'D'}, 'lever')  # local
4520        3
4521        >>> d.lookupMechanism({'A'}, 'lever')  # found at D via Zup
4522        3
4523        >>> d.lookupMechanism({'A', 'D'}, 'lever')  # local again
4524        3
4525        >>> d.lookupMechanism({'A'}, 'switch')  # local
4526        0
4527        >>> d.lookupMechanism({'B'}, 'switch')  # local
4528        1
4529        >>> d.lookupMechanism({'C'}, 'switch')  # local
4530        2
4531        >>> d.lookupMechanism({'A', 'B'}, 'switch')  # ambiguous
4532        Traceback (most recent call last):
4533        ...
4534        exploration.core.AmbiguousMechanismError...
4535        >>> d.lookupMechanism({'A', 'B', 'C'}, 'switch')  # ambiguous
4536        Traceback (most recent call last):
4537        ...
4538        exploration.core.AmbiguousMechanismError...
4539        >>> d.lookupMechanism({'B', 'D'}, 'switch')  # not ambiguous
4540        1
4541        >>> d.lookupMechanism({'E', 'D'}, 'switch')  # ambiguous at L0 zone
4542        Traceback (most recent call last):
4543        ...
4544        exploration.core.AmbiguousMechanismError...
4545        >>> d.lookupMechanism({'E'}, 'switch')  # ambiguous at L0 zone
4546        Traceback (most recent call last):
4547        ...
4548        exploration.core.AmbiguousMechanismError...
4549        >>> d.lookupMechanism({'D'}, 'switch')  # found at L0 zone
4550        2
4551        """
4552        result = self.localLookup(
4553            startFrom,
4554            DecisionGraph.uniqueMechanismFinder(name)
4555        )
4556        if result is None:
4557            raise MissingMechanismError(
4558                f"No mechanism named {name!r}"
4559            )
4560        else:
4561            return result
4562
4563    def resolveMechanism(
4564        self,
4565        specifier: base.AnyMechanismSpecifier,
4566        startFrom: Union[
4567            None,
4568            base.AnyDecisionSpecifier,
4569            Collection[base.AnyDecisionSpecifier]
4570        ] = None
4571    ) -> base.MechanismID:
4572        """
4573        Works like `lookupMechanism`, except it accepts a
4574        `base.AnyMechanismSpecifier` which may have position information
4575        baked in, and so the `startFrom` information is optional. If
4576        position information isn't specified in the mechanism specifier
4577        and startFrom is not provided, the mechanism is searched for at
4578        the global scope and then in the entire graph. On the other
4579        hand, if the specifier includes any position information, the
4580        startFrom value provided here will be ignored.
4581        """
4582        if isinstance(specifier, base.MechanismID):
4583            return specifier
4584
4585        elif isinstance(specifier, base.MechanismName):
4586            if startFrom is None:
4587                startFrom = set()
4588            return self.lookupMechanism(startFrom, specifier)
4589
4590        elif isinstance(specifier, base.MechanismSpecifier):
4591            domain, zone, decision, mechanism = specifier
4592            if domain is None and zone is None and decision is None:
4593                if startFrom is None:
4594                    startFrom = set()
4595                return self.lookupMechanism(startFrom, mechanism)
4596
4597            elif isinstance(decision, base.DecisionID):
4598                # Specifying a decision ID restricts the mechanism to
4599                # appear at exactly that decision and NOT be global...
4600                if domain is not None or zone is not None:
4601                    warnings.warn(
4602                        (
4603                            f"Mechanism specifier includes domain and/or"
4604                            f" zone in addition to decision-by-ID:"
4605                            f" {specifier!r}"
4606                        ),
4607                        InvalidMechanismSpecifierWarning
4608                    )
4609
4610                mechs = self.nodes[decision].get('mechanisms', {})
4611                found = mechs.get(mechanism)
4612                if found is None:
4613                    raise MissingMechanismError(
4614                        f"No mechanism named {mechanism!r} at specific"
4615                        f" decision {self.identityOf(decision)}"
4616                    )
4617                return found
4618
4619            elif decision is not None:
4620                startFrom = self.resolveDecisions(
4621                    base.DecisionSpecifier(domain, zone, decision)
4622                )
4623                return self.lookupMechanism(startFrom, mechanism)
4624
4625            else:  # decision is None but domain and/or zone aren't
4626                startFrom = set()
4627                if zone is not None:
4628                    baseStart = self.allDecisionsInZone(zone)
4629                else:
4630                    baseStart = set(self)
4631
4632                if domain is None:
4633                    startFrom = baseStart
4634                else:
4635                    for dID in baseStart:
4636                        if self.domainFor(dID) == domain:
4637                            startFrom.add(dID)
4638                return self.lookupMechanism(startFrom, mechanism)
4639
4640        else:
4641            raise TypeError(
4642                f"Invalid mechanism specifier: {repr(specifier)}"
4643                f"\n(Must be a mechanism ID, mechanism name, or"
4644                f" mechanism specifier tuple)"
4645            )
4646
4647    def legibleMechanismSpecifier(
4648        self,
4649        mID: base.MechanismID,
4650        minimal: bool = False
4651    ) -> Union[base.MechanismSpecifier, base.MechanismID]:
4652        '''
4653        Given a mechanism ID, returns an unambiguous
4654        `base.MechanismSpecifier` for that mechanism, including
4655        domain/zone/decision-name parts as necessary. If there is no
4656        unambiguous specifier for that mechanism, returns the mechanism
4657        ID as-is. Also returns the ID as-is if no mechanism with that ID
4658        exists.
4659
4660        Set `minimal` to True (default is `False`) to use a minimal
4661        unambiguous specifier (more likely to be made ambiguous by
4662        future decision/mechanism additions).
4663
4664        Some examples:
4665
4666        >>> g = DecisionGraph()
4667        >>> g.addDecision('A')
4668        0
4669        >>> g.addDecision('B')
4670        1
4671        >>> g.addDecision('C')
4672        2
4673        >>> g.addDecision('A')
4674        3
4675        >>> g.addDecision('C')
4676        4
4677        >>> g.createZone('Z', 0)
4678        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
4679 annotations=[])
4680        >>> g.createZone('Q', 0)
4681        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
4682 annotations=[])
4683        >>> g.addDecisionToZone(0, 'Z')
4684        >>> g.addDecisionToZone('B', 'Z')
4685        >>> g.addDecisionToZone(3, 'Q')
4686        >>> g.addMechanism('global')
4687        0
4688        >>> g.addMechanism('door', 0)
4689        1
4690        >>> g.addMechanism('door', 2)
4691        2
4692        >>> g.addMechanism('block', 2)
4693        3
4694        >>> g.addMechanism('lever', 0)
4695        4
4696        >>> g.addMechanism('lever', 'B')
4697        5
4698        >>> g.addMechanism('door', 3)
4699        6
4700        >>> g.addMechanism('block', 4)
4701        7
4702        >>> g.legibleMechanismSpecifier(0)
4703        MechanismSpecifier(domain=None, zone=None, decision=None, name='global')
4704        >>> g.legibleMechanismSpecifier(1)
4705        MechanismSpecifier(domain='main', zone='Z', decision='A', name='door')
4706        >>> g.legibleMechanismSpecifier(2)
4707        MechanismSpecifier(domain='main', zone=None, decision='C', name='door')
4708        >>> g.legibleMechanismSpecifier(3)  # ambiguous with 'block' at C[4]
4709        3
4710        >>> g.legibleMechanismSpecifier(4)
4711        MechanismSpecifier(domain='main', zone='Z', decision='A', name='lever')
4712        >>> g.legibleMechanismSpecifier(5)
4713        MechanismSpecifier(domain='main', zone='Z', decision='B', name='lever')
4714        >>> g.legibleMechanismSpecifier(6)
4715        MechanismSpecifier(domain='main', zone='Q', decision='A', name='door')
4716        >>> g.legibleMechanismSpecifier(7)  # ambiguous with 'block' at C[2]
4717        7
4718        '''
4719        details = self.mechanismDetails(mID)
4720        if details is None:
4721            return mID
4722        elif not minimal:
4723            # Go straight to as full a specifier as we can
4724            dID, mName = details
4725            if dID is None:
4726                maybe = base.MechanismSpecifier(None, None, None, mName)
4727                try:
4728                    resolved = self.resolveMechanism(maybe)
4729                    if resolved == mID:
4730                        return maybe
4731                    else:
4732                        return mID
4733                    # Else got wrong one without specifying more info
4734                except (
4735                    AmbiguousMechanismError,
4736                    AmbiguousDecisionSpecifierError
4737                ):
4738                    # Not specific enough
4739                    return mID
4740                except MissingMechanismError:
4741                    # Nothing findable with that name; return mID as-is
4742                    # Note: This *should* be caught by case above instead I
4743                    # think, but doesn't hurt to be defensive here
4744                    return mID
4745            else:
4746                # Look up decision's info
4747                dInfo = self.decisionInfo(dID)
4748                dName = dInfo["name"]
4749                dDomain = dInfo["domain"]
4750
4751                # Include domain and first zone we can find that's
4752                # unambiguous
4753                for zone in self.zoneParents(dID):
4754                    maybe = base.MechanismSpecifier(
4755                        dDomain,
4756                        zone,
4757                        dName,
4758                        mName
4759                    )
4760                    try:
4761                        resolved = self.resolveMechanism(maybe)
4762                        if resolved == mID:
4763                            return maybe
4764                        else:
4765                            # Got wrong one for this zone
4766                            continue
4767                    except (
4768                        AmbiguousMechanismError,
4769                        AmbiguousDecisionSpecifierError
4770                    ):
4771                        # Not specific enough
4772                        continue
4773                    except MissingMechanismError:
4774                        # Shouldn't be possible, but just in case
4775                        return mID
4776
4777                # Try without a zone
4778                maybe = base.MechanismSpecifier(
4779                    dDomain,
4780                    None,
4781                    dName,
4782                    mName
4783                )
4784                try:
4785                    resolved = self.resolveMechanism(maybe)
4786                    if resolved == mID:
4787                        return maybe
4788                    else:
4789                        # Got wrong one; no altenratives
4790                        return mID
4791                except (
4792                    AmbiguousMechanismError,
4793                    AmbiguousDecisionSpecifierError
4794                ) as e:
4795                    # Not specific enough
4796                    return mID
4797                except MissingMechanismError:
4798                    # Shouldn't be possible, but just in case
4799                    return mID
4800        else:
4801            # Minimal requested; details were available
4802            dID, mName = details
4803            # First try bare specifier with just name. Should catch
4804            # global mechanisms as well as unique locals
4805            maybe = base.MechanismSpecifier(None, None, None, mName)
4806            try:
4807                resolved = self.resolveMechanism(maybe)
4808                if resolved == mID:
4809                    return maybe
4810                # Else got wrong one without specifying more info
4811            except (
4812                AmbiguousMechanismError,
4813                AmbiguousDecisionSpecifierError
4814            ):
4815                # Not specific enough
4816                pass
4817            except MissingMechanismError:
4818                # Nothing findable with that name; return mID as-is
4819                # Note: This *should* be caught by case above instead I
4820                # think, but doesn't hurt to be defensive here
4821                return mID
4822
4823            # A global mechanism we couldn't resolve
4824            if dID is None:
4825                return mID
4826
4827            # Look up decision's info
4828            dInfo = self.decisionInfo(dID)
4829            dName = dInfo["name"]
4830            dDomain = dInfo["domain"]
4831
4832            # Try with just decision name
4833            maybe = base.MechanismSpecifier(None, None, dName, mName)
4834            try:
4835                resolved = self.resolveMechanism(maybe)
4836                if resolved == mID:
4837                    return maybe
4838                # Else got wrong one without specifying more info
4839            except (
4840                AmbiguousMechanismError,
4841                AmbiguousDecisionSpecifierError
4842            ):
4843                # Not specific enough
4844                pass
4845            except MissingMechanismError:
4846                # Shouldn't be possible, but just in case
4847                return mID
4848
4849            # Try each possible direct parent zone
4850            for zone in self.zoneParents(dID):
4851                maybe = base.MechanismSpecifier(None, zone, dName, mName)
4852                try:
4853                    resolved = self.resolveMechanism(maybe)
4854                    if resolved == mID:
4855                        return maybe
4856                    # Else got wrong one without specifying more info
4857                except (
4858                    AmbiguousMechanismError,
4859                    AmbiguousDecisionSpecifierError
4860                ):
4861                    # Not specific enough
4862                    pass
4863                except MissingMechanismError:
4864                    # Shouldn't be possible, but just in case
4865                    return mID
4866
4867            # No zones or none specific enough: try adding domain w/
4868            # each zone
4869            for zone in self.zoneParents(dID):
4870                maybe = base.MechanismSpecifier(dDomain, zone, dName, mName)
4871                try:
4872                    resolved = self.resolveMechanism(maybe)
4873                    if resolved == mID:
4874                        return maybe
4875                    # Else got wrong one without specifying more info
4876                except (
4877                    AmbiguousMechanismError,
4878                    AmbiguousDecisionSpecifierError
4879                ):
4880                    # Not specific enough
4881                    pass
4882                except MissingMechanismError:
4883                    # Shouldn't be possible, but just in case
4884                    return mID
4885
4886            # Nothing but ID is specific enough
4887            return mID
4888
4889    def walkConsequenceMechanisms(
4890        self,
4891        consequence: base.Consequence,
4892        searchFrom: Set[base.DecisionID],
4893        replaceNames: int = 0
4894    ) -> Generator[base.MechanismID, None, None]:
4895        """
4896        Yields each requirement in the given `base.Consequence`,
4897        including those in `base.Condition`s, `base.ConditionalSkill`s
4898        within `base.Challenge`s, and those set or toggled by
4899        `base.Effect`s. The `searchFrom` argument specifies where to
4900        start searching for mechanisms, since requirements include them
4901        by name, not by ID.
4902
4903        If `replaceNames` is set to 1, any mechanism names resolved
4904        during this process will be replaced by full mechanism
4905        specifiers (see `DecisionGraph.legibleMechanismSpecifier`) that
4906        include domain and a zone. Note that if the process crashes due
4907        to an ambiguous mechanism name, requirements up to that point
4908        will still have been changed.
4909        
4910        The default for `replaceNames` (0) will not change any mechanism
4911        names/specifiers. Setting it to 2 instead of 1 will cause it to
4912        use the least-specific unambiguous mechanism specifier it can
4913        find (but note that if you're going to keep adding to the graph,
4914        such specifiers are more likely to become ambiguous in the
4915        future).
4916
4917        Set `replaceNames` to 3 to replace names with mechanism IDs
4918        only.
4919        """
4920        for (index, part) in base.walkParts(consequence):
4921            if isinstance(part, dict):
4922                if 'skills' in part:  # a Challenge
4923                    part = cast(base.Challenge, part)
4924                    for cSkill in part['skills'].walk():
4925                        if isinstance(cSkill, base.ConditionalSkill):
4926                            yield from self.walkRequirementMechanisms(
4927                                cSkill.requirement,
4928                                searchFrom,
4929                                replaceNames
4930                            )
4931                elif 'condition' in part:  # a Condition
4932                    part = cast(base.Condition, part)
4933                    yield from self.walkRequirementMechanisms(
4934                        part['condition'],
4935                        searchFrom,
4936                        replaceNames
4937                    )
4938                elif 'value' in part:  # an Effect
4939                    part = cast(base.Effect, part)
4940                    val = part['value']
4941                    if part['type'] == 'set':
4942                        if (
4943                            isinstance(val, tuple)
4944                        and len(val) == 2
4945                        and isinstance(val[1], base.MechanismState)
4946                        ):
4947                            resolved = self.resolveMechanism(
4948                                cast(base.AnyMechanismSpecifier, val[0]),
4949                                searchFrom
4950                            )
4951                            if replaceNames == 1:
4952                                spec = self.legibleMechanismSpecifier(
4953                                    resolved,
4954                                    False
4955                                )
4956                            elif replaceNames == 2:
4957                                spec = self.legibleMechanismSpecifier(
4958                                    resolved,
4959                                    True
4960                                )
4961                            elif replaceNames == 3:
4962                                spec = resolved
4963                            elif replaceNames != 0:
4964                                raise ValueError(
4965                                    f"Invalid replaceNames value:"
4966                                    f" {replaceNames!r}"
4967                                )
4968                            if replaceNames > 0:
4969                                part['value'] = (spec, val[1])
4970                            yield resolved
4971                    elif part['type'] == 'toggle':
4972                        if isinstance(val, tuple):
4973                            assert len(val) == 2
4974                            between = cast(List[base.MechanismState], val[1])
4975                            resolved = self.resolveMechanism(
4976                                cast(base.AnyMechanismSpecifier, val[0]),
4977                                searchFrom
4978                            )
4979                            if replaceNames == 1:
4980                                spec = self.legibleMechanismSpecifier(
4981                                    resolved,
4982                                    False
4983                                )
4984                            elif replaceNames == 2:
4985                                spec = self.legibleMechanismSpecifier(
4986                                    resolved,
4987                                    True
4988                                )
4989                            elif replaceNames == 3:
4990                                spec = resolved
4991                            elif replaceNames != 0:
4992                                raise ValueError(
4993                                    f"Invalid replaceNames value:"
4994                                    f" {replaceNames!r}"
4995                                )
4996                            if replaceNames > 0:
4997                                part['value'] = (spec, between)
4998                            yield resolved
4999            else:
5000                # Sub-parts will get walked in separate iterations
5001                pass
5002
5003    def walkRequirementMechanisms(
5004        self,
5005        req: base.Requirement,
5006        searchFrom: Set[base.DecisionID],
5007        replaceNames: int = 0
5008    ) -> Generator[base.MechanismID, None, None]:
5009        """
5010        Given a requirement, yields any mechanisms mentioned in that
5011        requirement, in depth-first traversal order.
5012
5013        If `replaceNames` is 1, 2, or 3 (default is 0) then the
5014        requirement is actually edited to replace any mechanism names
5015        with either a specifier or their resolved IDs. See
5016        `walkConsequenceMechanisms` for `replaceNames` details.
5017        """
5018        for part in req.walk():
5019            if isinstance(part, base.ReqMechanism):
5020                mech = part.mechanism
5021                resolved = self.resolveMechanism(
5022                    mech,
5023                    startFrom=searchFrom
5024                )
5025                if replaceNames in (1, 2, 3):
5026                    if replaceNames == 1:
5027                        spec = self.legibleMechanismSpecifier(
5028                            resolved,
5029                            False
5030                        )
5031                    elif replaceNames == 2:
5032                        spec = self.legibleMechanismSpecifier(
5033                            resolved,
5034                            True
5035                        )
5036                    elif replaceNames == 3:
5037                        spec = resolved
5038                    part.mechanism = spec
5039                elif replaceNames != 0:
5040                    raise ValueError(
5041                        f"Invalid replaceNames value:"
5042                        f" {replaceNames!r}"
5043                    )
5044                yield resolved
5045
5046    def addUnexploredEdge(
5047        self,
5048        fromDecision: base.AnyDecisionSpecifier,
5049        name: base.Transition,
5050        destinationName: Optional[base.DecisionName] = None,
5051        reciprocal: Optional[base.Transition] = None,
5052        toDomain: Optional[base.Domain] = None,
5053        placeInZone: Optional[base.Zone] = None,
5054        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
5055        annotations: Optional[List[base.Annotation]] = None,
5056        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
5057        revAnnotations: Optional[List[base.Annotation]] = None,
5058        requires: Optional[base.Requirement] = None,
5059        consequence: Optional[base.Consequence] = None,
5060        revRequires: Optional[base.Requirement] = None,
5061        revConsequece: Optional[base.Consequence] = None
5062    ) -> base.DecisionID:
5063        """
5064        Adds a transition connecting to a new decision named `'_u.-n-'`
5065        where '-n-' is the number of unknown decisions (named or not)
5066        that have ever been created in this graph (or using the
5067        specified destination name if one is provided). This represents
5068        a transition to an unknown destination. The destination node
5069        gets tagged 'unconfirmed'.
5070
5071        This also adds a reciprocal transition in the reverse direction,
5072        unless `reciprocal` is left as the default `None`. The reciprocal
5073        will use the provided name. The new decision will be in the same
5074        domain as the decision it's connected to, unless `toDecision` is
5075        specified, in which case it will be in that domain.
5076
5077        The new decision will not be placed into any zones, unless
5078        `placeInZone` is specified, in which case it will be placed into
5079        that zone. If that zone needs to be created, it will be created
5080        at level 0; in that case that zone will be added to any
5081        grandparent zones of the decision we're branching off of. If
5082        `placeInZone` is set to `base.DefaultZone`, then the new
5083        decision will be placed into each parent zone of the decision
5084        we're branching off of, as long as the new decision is in the
5085        same domain as the decision we're branching from (otherwise only
5086        an explicit `placeInZone` would apply).
5087
5088        The ID of the decision that was created is returned.
5089
5090        A `MissingDecisionError` will be raised if the starting decision
5091        does not exist, a `TransitionCollisionError` will be raised if
5092        it exists but already has a transition with the given name, and a
5093        `DecisionCollisionWarning` will be issued if a decision with the
5094        specified destination name already exists (won't happen when
5095        using an automatic name).
5096
5097        Lists of tags and/or annotations (strings in both cases) may be
5098        provided. These may also be provided for the reciprocal edge.
5099
5100        Similarly, requirements and/or consequences for either edge may
5101        be provided.
5102
5103        ## Example
5104
5105        >>> g = DecisionGraph()
5106        >>> g.addDecision('A')
5107        0
5108        >>> g.addUnexploredEdge('A', 'up')
5109        1
5110        >>> g.nameFor(1)
5111        '_u.0'
5112        >>> g.decisionTags(1)
5113        {'unconfirmed': 1}
5114        >>> g.getReciprocal('A', 'up') is None
5115        True
5116        >>> g.addUnexploredEdge('A', 'right', 'B', 'left')
5117        2
5118        >>> g.nameFor(2)
5119        'B'
5120        >>> g.decisionTags(2)
5121        {'unconfirmed': 1}
5122        >>> g.getReciprocal('A', 'right')
5123        'left'
5124        >>> g.addUnexploredEdge('A', 'down', None, 'up')
5125        3
5126        >>> g.nameFor(3)
5127        '_u.2'
5128        >>> g.addUnexploredEdge(
5129        ...    '_u.0',
5130        ...    'beyond',
5131        ...    None,
5132        ...    'return',
5133        ...    toDomain='otherDomain',
5134        ...    tags={'fast':1},
5135        ...    revTags={'slow':1},
5136        ...    annotations=['comment'],
5137        ...    revAnnotations=['one', 'two'],
5138        ...    requires=base.ReqCapability('dash'),
5139        ...    revRequires=base.ReqCapability('super dash'),
5140        ...    consequence=[base.effect(gain='super dash')],
5141        ...    revConsequece=[base.effect(lose='super dash')]
5142        ... )
5143        4
5144        >>> g.nameFor(4)
5145        '_u.3'
5146        >>> g.domainFor(4)
5147        'otherDomain'
5148        >>> g.transitionTags('_u.0', 'beyond')
5149        {'fast': 1}
5150        >>> g.transitionAnnotations('_u.0', 'beyond')
5151        ['comment']
5152        >>> g.getTransitionRequirement('_u.0', 'beyond')
5153        ReqCapability('dash')
5154        >>> e = g.getConsequence('_u.0', 'beyond')
5155        >>> e == [base.effect(gain='super dash')]
5156        True
5157        >>> g.transitionTags('_u.3', 'return')
5158        {'slow': 1}
5159        >>> g.transitionAnnotations('_u.3', 'return')
5160        ['one', 'two']
5161        >>> g.getTransitionRequirement('_u.3', 'return')
5162        ReqCapability('super dash')
5163        >>> e = g.getConsequence('_u.3', 'return')
5164        >>> e == [base.effect(lose='super dash')]
5165        True
5166        """
5167        # Defaults
5168        if tags is None:
5169            tags = {}
5170        if annotations is None:
5171            annotations = []
5172        if revTags is None:
5173            revTags = {}
5174        if revAnnotations is None:
5175            revAnnotations = []
5176
5177        # Resolve ID
5178        fromID = self.resolveDecision(fromDecision)
5179        if toDomain is None:
5180            toDomain = self.domainFor(fromID)
5181
5182        if name in self.destinationsFrom(fromID):
5183            raise TransitionCollisionError(
5184                f"Cannot add a new edge {name!r}:"
5185                f" {self.identityOf(fromDecision)} already has an"
5186                f" outgoing edge with that name."
5187            )
5188
5189        if destinationName in self.nameLookup and WARN_OF_NAME_COLLISIONS:
5190            warnings.warn(
5191                (
5192                    f"Cannot add a new unexplored node"
5193                    f" {destinationName!r}: A decision with that name"
5194                    f" already exists.\n(Leave destinationName as None"
5195                    f" to use an automatic name.)"
5196                ),
5197                DecisionCollisionWarning
5198            )
5199
5200        # Create the new unexplored decision and add the edge
5201        if destinationName is None:
5202            toName = '_u.' + str(self.unknownCount)
5203        else:
5204            toName = destinationName
5205        self.unknownCount += 1
5206        newID = self.addDecision(toName, domain=toDomain)
5207        self.addTransition(
5208            fromID,
5209            name,
5210            newID,
5211            tags=tags,
5212            annotations=annotations
5213        )
5214        self.setTransitionRequirement(fromID, name, requires)
5215        if consequence is not None:
5216            self.setConsequence(fromID, name, consequence)
5217
5218        # Add it to a zone if requested
5219        if (
5220            placeInZone == base.DefaultZone
5221        and toDomain == self.domainFor(fromID)
5222        ):
5223            # Add to each parent of the from decision
5224            for parent in self.zoneParents(fromID):
5225                self.addDecisionToZone(newID, parent)
5226        elif placeInZone is not None:
5227            # Otherwise add it to one specific zone, creating that zone
5228            # at level 0 if necessary
5229            assert isinstance(placeInZone, base.Zone)
5230            if self.getZoneInfo(placeInZone) is None:
5231                self.createZone(placeInZone, 0)
5232                # Add new zone to each grandparent of the from decision
5233                for parent in self.zoneParents(fromID):
5234                    for grandparent in self.zoneParents(parent):
5235                        self.addZoneToZone(placeInZone, grandparent)
5236            self.addDecisionToZone(newID, placeInZone)
5237
5238        # Create the reciprocal edge
5239        if reciprocal is not None:
5240            self.addTransition(
5241                newID,
5242                reciprocal,
5243                fromID,
5244                tags=revTags,
5245                annotations=revAnnotations
5246            )
5247            self.setTransitionRequirement(newID, reciprocal, revRequires)
5248            if revConsequece is not None:
5249                self.setConsequence(newID, reciprocal, revConsequece)
5250            # Set as a reciprocal
5251            self.setReciprocal(fromID, name, reciprocal)
5252
5253        # Tag the destination as 'unconfirmed'
5254        self.tagDecision(newID, 'unconfirmed')
5255
5256        # Return ID of new destination
5257        return newID
5258
5259    def retargetTransition(
5260        self,
5261        fromDecision: base.AnyDecisionSpecifier,
5262        transition: base.Transition,
5263        newDestination: base.AnyDecisionSpecifier,
5264        swapReciprocal=True,
5265        errorOnNameColision=True
5266    ) -> Optional[base.Transition]:
5267        """
5268        Given a particular decision and a transition at that decision,
5269        changes that transition so that it goes to the specified new
5270        destination instead of wherever it was connected to before. If
5271        the new destination is the same as the old one, no changes are
5272        made.
5273
5274        If `swapReciprocal` is set to True (the default) then any
5275        reciprocal edge at the old destination will be deleted, and a
5276        new reciprocal edge from the new destination with equivalent
5277        properties to the original reciprocal will be created, pointing
5278        to the origin of the specified transition. If `swapReciprocal`
5279        is set to False, then the reciprocal relationship with any old
5280        reciprocal edge will be removed, but the old reciprocal edge
5281        will not be changed.
5282
5283        Note that if `errorOnNameColision` is True (the default), then
5284        if the reciprocal transition has the same name as a transition
5285        which already exists at the new destination node, a
5286        `TransitionCollisionError` will be thrown. However, if it is set
5287        to False, the reciprocal transition will be renamed with a suffix
5288        to avoid any possible name collisions. Either way, the name of
5289        the reciprocal transition (possibly just changed) will be
5290        returned, or None if there was no reciprocal transition.
5291
5292        ## Example
5293
5294        >>> g = DecisionGraph()
5295        >>> for fr, to, nm in [
5296        ...     ('A', 'B', 'up'),
5297        ...     ('A', 'B', 'up2'),
5298        ...     ('B', 'A', 'down'),
5299        ...     ('B', 'B', 'self'),
5300        ...     ('B', 'C', 'next'),
5301        ...     ('C', 'B', 'prev')
5302        ... ]:
5303        ...     if g.getDecision(fr) is None:
5304        ...        g.addDecision(fr)
5305        ...     if g.getDecision(to) is None:
5306        ...         g.addDecision(to)
5307        ...     g.addTransition(fr, nm, to)
5308        0
5309        1
5310        2
5311        >>> g.setReciprocal('A', 'up', 'down')
5312        >>> g.setReciprocal('B', 'next', 'prev')
5313        >>> g.destination('A', 'up')
5314        1
5315        >>> g.destination('B', 'down')
5316        0
5317        >>> g.retargetTransition('A', 'up', 'C')
5318        'down'
5319        >>> g.destination('A', 'up')
5320        2
5321        >>> g.getDestination('B', 'down') is None
5322        True
5323        >>> g.destination('C', 'down')
5324        0
5325        >>> g.addTransition('A', 'next', 'B')
5326        >>> g.addTransition('B', 'prev', 'A')
5327        >>> g.setReciprocal('A', 'next', 'prev')
5328        >>> # Can't swap a reciprocal in a way that would collide names
5329        >>> g.getReciprocal('C', 'prev')
5330        'next'
5331        >>> g.retargetTransition('C', 'prev', 'A')
5332        Traceback (most recent call last):
5333        ...
5334        exploration.core.TransitionCollisionError...
5335        >>> g.retargetTransition('C', 'prev', 'A', swapReciprocal=False)
5336        'next'
5337        >>> g.destination('C', 'prev')
5338        0
5339        >>> g.destination('A', 'next') # not changed
5340        1
5341        >>> # Reciprocal relationship is severed:
5342        >>> g.getReciprocal('C', 'prev') is None
5343        True
5344        >>> g.getReciprocal('B', 'next') is None
5345        True
5346        >>> # Swap back so we can do another demo
5347        >>> g.retargetTransition('C', 'prev', 'B', swapReciprocal=False)
5348        >>> # Note return value was None here because there was no reciprocal
5349        >>> g.setReciprocal('C', 'prev', 'next')
5350        >>> # Swap reciprocal by renaming it
5351        >>> g.retargetTransition('C', 'prev', 'A', errorOnNameColision=False)
5352        'next.1'
5353        >>> g.getReciprocal('C', 'prev')
5354        'next.1'
5355        >>> g.destination('C', 'prev')
5356        0
5357        >>> g.destination('A', 'next.1')
5358        2
5359        >>> g.destination('A', 'next')
5360        1
5361        >>> # Note names are the same but these are from different nodes
5362        >>> g.getReciprocal('A', 'next')
5363        'prev'
5364        >>> g.getReciprocal('A', 'next.1')
5365        'prev'
5366        """
5367        fromID = self.resolveDecision(fromDecision)
5368        newDestID = self.resolveDecision(newDestination)
5369
5370        # Figure out the old destination of the transition we're swapping
5371        oldDestID = self.destination(fromID, transition)
5372        reciprocal = self.getReciprocal(fromID, transition)
5373
5374        # If thew new destination is the same, we don't do anything!
5375        if oldDestID == newDestID:
5376            return reciprocal
5377
5378        # First figure out reciprocal business so we can error out
5379        # without making changes if we need to
5380        if swapReciprocal and reciprocal is not None:
5381            reciprocal = self.rebaseTransition(
5382                oldDestID,
5383                reciprocal,
5384                newDestID,
5385                swapReciprocal=False,
5386                errorOnNameColision=errorOnNameColision
5387            )
5388
5389        # Handle the forward transition...
5390        # Find the transition properties
5391        tProps = self.getTransitionProperties(fromID, transition)
5392
5393        # Delete the edge
5394        self.removeEdgeByKey(fromID, transition)
5395
5396        # Add the new edge
5397        self.addTransition(fromID, transition, newDestID)
5398
5399        # Reapply the transition properties
5400        self.setTransitionProperties(fromID, transition, **tProps)
5401
5402        # Handle the reciprocal transition if there is one...
5403        if reciprocal is not None:
5404            if not swapReciprocal:
5405                # Then sever the relationship, but only if that edge
5406                # still exists (we might be in the middle of a rebase)
5407                check = self.getDestination(oldDestID, reciprocal)
5408                if check is not None:
5409                    self.setReciprocal(
5410                        oldDestID,
5411                        reciprocal,
5412                        None,
5413                        setBoth=False # Other transition was deleted already
5414                    )
5415            else:
5416                # Establish new reciprocal relationship
5417                self.setReciprocal(
5418                    fromID,
5419                    transition,
5420                    reciprocal
5421                )
5422
5423        return reciprocal
5424
5425    def rebaseTransition(
5426        self,
5427        fromDecision: base.AnyDecisionSpecifier,
5428        transition: base.Transition,
5429        newBase: base.AnyDecisionSpecifier,
5430        swapReciprocal=True,
5431        errorOnNameColision=True
5432    ) -> base.Transition:
5433        """
5434        Given a particular destination and a transition at that
5435        destination, changes that transition's origin to a new base
5436        decision. If the new source is the same as the old one, no
5437        changes are made.
5438
5439        If `swapReciprocal` is set to True (the default) then any
5440        reciprocal edge at the destination will be retargeted to point
5441        to the new source so that it can remain a reciprocal. If
5442        `swapReciprocal` is set to False, then the reciprocal
5443        relationship with any old reciprocal edge will be removed, but
5444        the old reciprocal edge will not be otherwise changed.
5445
5446        Note that if `errorOnNameColision` is True (the default), then
5447        if the transition has the same name as a transition which
5448        already exists at the new source node, a
5449        `TransitionCollisionError` will be raised. However, if it is set
5450        to False, the transition will be renamed with a suffix to avoid
5451        any possible name collisions. Either way, the (possibly new) name
5452        of the transition that was rebased will be returned.
5453
5454        ## Example
5455
5456        >>> g = DecisionGraph()
5457        >>> for fr, to, nm in [
5458        ...     ('A', 'B', 'up'),
5459        ...     ('A', 'B', 'up2'),
5460        ...     ('B', 'A', 'down'),
5461        ...     ('B', 'B', 'self'),
5462        ...     ('B', 'C', 'next'),
5463        ...     ('C', 'B', 'prev')
5464        ... ]:
5465        ...     if g.getDecision(fr) is None:
5466        ...        g.addDecision(fr)
5467        ...     if g.getDecision(to) is None:
5468        ...         g.addDecision(to)
5469        ...     g.addTransition(fr, nm, to)
5470        0
5471        1
5472        2
5473        >>> g.setReciprocal('A', 'up', 'down')
5474        >>> g.setReciprocal('B', 'next', 'prev')
5475        >>> g.destination('A', 'up')
5476        1
5477        >>> g.destination('B', 'down')
5478        0
5479        >>> g.rebaseTransition('B', 'down', 'C')
5480        'down'
5481        >>> g.destination('A', 'up')
5482        2
5483        >>> g.getDestination('B', 'down') is None
5484        True
5485        >>> g.destination('C', 'down')
5486        0
5487        >>> g.addTransition('A', 'next', 'B')
5488        >>> g.addTransition('B', 'prev', 'A')
5489        >>> g.setReciprocal('A', 'next', 'prev')
5490        >>> # Can't rebase in a way that would collide names
5491        >>> g.rebaseTransition('B', 'next', 'A')
5492        Traceback (most recent call last):
5493        ...
5494        exploration.core.TransitionCollisionError...
5495        >>> g.rebaseTransition('B', 'next', 'A', errorOnNameColision=False)
5496        'next.1'
5497        >>> g.destination('C', 'prev')
5498        0
5499        >>> g.destination('A', 'next') # not changed
5500        1
5501        >>> # Collision is avoided by renaming
5502        >>> g.destination('A', 'next.1')
5503        2
5504        >>> # Swap without reciprocal
5505        >>> g.getReciprocal('A', 'next.1')
5506        'prev'
5507        >>> g.getReciprocal('C', 'prev')
5508        'next.1'
5509        >>> g.rebaseTransition('A', 'next.1', 'B', swapReciprocal=False)
5510        'next.1'
5511        >>> g.getReciprocal('C', 'prev') is None
5512        True
5513        >>> g.destination('C', 'prev')
5514        0
5515        >>> g.getDestination('A', 'next.1') is None
5516        True
5517        >>> g.destination('A', 'next')
5518        1
5519        >>> g.destination('B', 'next.1')
5520        2
5521        >>> g.getReciprocal('B', 'next.1') is None
5522        True
5523        >>> # Rebase in a way that creates a self-edge
5524        >>> g.rebaseTransition('A', 'next', 'B')
5525        'next'
5526        >>> g.getDestination('A', 'next') is None
5527        True
5528        >>> g.destination('B', 'next')
5529        1
5530        >>> g.destination('B', 'prev') # swapped as a reciprocal
5531        1
5532        >>> g.getReciprocal('B', 'next') # still reciprocals
5533        'prev'
5534        >>> g.getReciprocal('B', 'prev')
5535        'next'
5536        >>> # And rebasing of a self-edge also works
5537        >>> g.rebaseTransition('B', 'prev', 'A')
5538        'prev'
5539        >>> g.destination('A', 'prev')
5540        1
5541        >>> g.destination('B', 'next')
5542        0
5543        >>> g.getReciprocal('B', 'next') # still reciprocals
5544        'prev'
5545        >>> g.getReciprocal('A', 'prev')
5546        'next'
5547        >>> # We've effectively reversed this edge/reciprocal pair
5548        >>> # by rebasing twice
5549        """
5550        fromID = self.resolveDecision(fromDecision)
5551        newBaseID = self.resolveDecision(newBase)
5552
5553        # If thew new base is the same, we don't do anything!
5554        if newBaseID == fromID:
5555            return transition
5556
5557        # First figure out reciprocal business so we can swap it later
5558        # without making changes if we need to
5559        destination = self.destination(fromID, transition)
5560        reciprocal = self.getReciprocal(fromID, transition)
5561        # Check for an already-deleted reciprocal
5562        if (
5563            reciprocal is not None
5564        and self.getDestination(destination, reciprocal) is None
5565        ):
5566            reciprocal = None
5567
5568        # Handle the base swap...
5569        # Find the transition properties
5570        tProps = self.getTransitionProperties(fromID, transition)
5571
5572        # Check for a collision
5573        targetDestinations = self.destinationsFrom(newBaseID)
5574        if transition in targetDestinations:
5575            if errorOnNameColision:
5576                raise TransitionCollisionError(
5577                    f"Cannot rebase transition {transition!r} from"
5578                    f" {self.identityOf(fromDecision)}: it would be a"
5579                    f" duplicate transition name at the new base"
5580                    f" decision {self.identityOf(newBase)}."
5581                )
5582            else:
5583                # Figure out a good fresh name
5584                newName = utils.uniqueName(
5585                    transition,
5586                    targetDestinations
5587                )
5588        else:
5589            newName = transition
5590
5591        # Delete the edge
5592        self.removeEdgeByKey(fromID, transition)
5593
5594        # Add the new edge
5595        self.addTransition(newBaseID, newName, destination)
5596
5597        # Reapply the transition properties
5598        self.setTransitionProperties(newBaseID, newName, **tProps)
5599
5600        # Handle the reciprocal transition if there is one...
5601        if reciprocal is not None:
5602            if not swapReciprocal:
5603                # Then sever the relationship
5604                self.setReciprocal(
5605                    destination,
5606                    reciprocal,
5607                    None,
5608                    setBoth=False # Other transition was deleted already
5609                )
5610            else:
5611                # Otherwise swap the reciprocal edge
5612                self.retargetTransition(
5613                    destination,
5614                    reciprocal,
5615                    newBaseID,
5616                    swapReciprocal=False
5617                )
5618
5619                # And establish a new reciprocal relationship
5620                self.setReciprocal(
5621                    newBaseID,
5622                    newName,
5623                    reciprocal
5624                )
5625
5626        # Return the new name in case it was changed
5627        return newName
5628
5629    # TODO: zone merging!
5630
5631    # TODO: Double-check that exploration vars get updated when this is
5632    # called!
5633    def mergeDecisions(
5634        self,
5635        merge: base.AnyDecisionSpecifier,
5636        mergeInto: base.AnyDecisionSpecifier,
5637        errorOnNameColision=True
5638    ) -> Dict[base.Transition, base.Transition]:
5639        """
5640        Merges two decisions, deleting the first after transferring all
5641        of its incoming and outgoing edges to target the second one,
5642        whose name is retained. The second decision will be added to any
5643        zones that the first decision was a member of. If either decision
5644        does not exist, a `MissingDecisionError` will be raised. If
5645        `merge` and `mergeInto` are the same, then nothing will be
5646        changed.
5647
5648        Unless `errorOnNameColision` is set to False, a
5649        `TransitionCollisionError` will be raised if the two decisions
5650        have outgoing transitions with the same name. If
5651        `errorOnNameColision` is set to False, then such edges will be
5652        renamed using a suffix to avoid name collisions, with edges
5653        connected to the second decision retaining their original names
5654        and edges that were connected to the first decision getting
5655        renamed.
5656
5657        Any mechanisms located at the first decision will be moved to the
5658        merged decision.
5659
5660        The tags and annotations of the merged decision are added to the
5661        tags and annotations of the merge target. If there are shared
5662        tags, the values from the merge target will override those of
5663        the merged decision. If this is undesired behavior, clear/edit
5664        the tags/annotations of the merged decision before the merge.
5665
5666        The 'unconfirmed' tag is treated specially: if both decisions have
5667        it it will be retained, but otherwise it will be dropped even if
5668        one of the situations had it before.
5669
5670        The domain of the second decision is retained.
5671
5672        Returns a dictionary mapping each original transition name to
5673        its new name in cases where transitions get renamed; this will
5674        be empty when no re-naming occurs, including when
5675        `errorOnNameColision` is True. If there were any transitions
5676        connecting the nodes that were merged, these become self-edges
5677        of the merged node (and may be renamed if necessary).
5678        Note that all renamed transitions were originally based on the
5679        first (merged) node, since transitions of the second (merge
5680        target) node are not renamed.
5681
5682        ## Example
5683
5684        >>> g = DecisionGraph()
5685        >>> for fr, to, nm in [
5686        ...     ('A', 'B', 'up'),
5687        ...     ('A', 'B', 'up2'),
5688        ...     ('B', 'A', 'down'),
5689        ...     ('B', 'B', 'self'),
5690        ...     ('B', 'C', 'next'),
5691        ...     ('C', 'B', 'prev'),
5692        ...     ('A', 'C', 'right')
5693        ... ]:
5694        ...     if g.getDecision(fr) is None:
5695        ...        g.addDecision(fr)
5696        ...     if g.getDecision(to) is None:
5697        ...         g.addDecision(to)
5698        ...     g.addTransition(fr, nm, to)
5699        0
5700        1
5701        2
5702        >>> g.getDestination('A', 'up')
5703        1
5704        >>> g.getDestination('B', 'down')
5705        0
5706        >>> sorted(g)
5707        [0, 1, 2]
5708        >>> g.setReciprocal('A', 'up', 'down')
5709        >>> g.setReciprocal('B', 'next', 'prev')
5710        >>> g.mergeDecisions('C', 'B')
5711        {}
5712        >>> g.destinationsFrom('A')
5713        {'up': 1, 'up2': 1, 'right': 1}
5714        >>> g.destinationsFrom('B')
5715        {'down': 0, 'self': 1, 'prev': 1, 'next': 1}
5716        >>> 'C' in g
5717        False
5718        >>> g.mergeDecisions('A', 'A') # does nothing
5719        {}
5720        >>> # Can't merge non-existent decision
5721        >>> g.mergeDecisions('A', 'Z')
5722        Traceback (most recent call last):
5723        ...
5724        exploration.core.MissingDecisionError...
5725        >>> g.mergeDecisions('Z', 'A')
5726        Traceback (most recent call last):
5727        ...
5728        exploration.core.MissingDecisionError...
5729        >>> # Can't merge decisions w/ shared edge names
5730        >>> g.addDecision('D')
5731        3
5732        >>> g.addTransition('D', 'next', 'A')
5733        >>> g.addTransition('A', 'prev', 'D')
5734        >>> g.setReciprocal('D', 'next', 'prev')
5735        >>> g.mergeDecisions('D', 'B') # both have a 'next' transition
5736        Traceback (most recent call last):
5737        ...
5738        exploration.core.TransitionCollisionError...
5739        >>> # Auto-rename colliding edges
5740        >>> g.mergeDecisions('D', 'B', errorOnNameColision=False)
5741        {'next': 'next.1'}
5742        >>> g.destination('B', 'next') # merge target unchanged
5743        1
5744        >>> g.destination('B', 'next.1') # merged decision name changed
5745        0
5746        >>> g.destination('B', 'prev') # name unchanged (no collision)
5747        1
5748        >>> g.getReciprocal('B', 'next') # unchanged (from B)
5749        'prev'
5750        >>> g.getReciprocal('B', 'next.1') # from A
5751        'prev'
5752        >>> g.getReciprocal('A', 'prev') # from B
5753        'next.1'
5754
5755        ## Folding four nodes into a 2-node loop
5756
5757        >>> g = DecisionGraph()
5758        >>> g.addDecision('X')
5759        0
5760        >>> g.addDecision('Y')
5761        1
5762        >>> g.addTransition('X', 'next', 'Y', 'prev')
5763        >>> g.addDecision('preX')
5764        2
5765        >>> g.addDecision('postY')
5766        3
5767        >>> g.addTransition('preX', 'next', 'X', 'prev')
5768        >>> g.addTransition('Y', 'next', 'postY', 'prev')
5769        >>> g.mergeDecisions('preX', 'Y', errorOnNameColision=False)
5770        {'next': 'next.1'}
5771        >>> g.destinationsFrom('X')
5772        {'next': 1, 'prev': 1}
5773        >>> g.destinationsFrom('Y')
5774        {'prev': 0, 'next': 3, 'next.1': 0}
5775        >>> 2 in g
5776        False
5777        >>> g.destinationsFrom('postY')
5778        {'prev': 1}
5779        >>> g.mergeDecisions('postY', 'X', errorOnNameColision=False)
5780        {'prev': 'prev.1'}
5781        >>> g.destinationsFrom('X')
5782        {'next': 1, 'prev': 1, 'prev.1': 1}
5783        >>> g.destinationsFrom('Y') # order 'cause of 'next' re-target
5784        {'prev': 0, 'next.1': 0, 'next': 0}
5785        >>> 2 in g
5786        False
5787        >>> 3 in g
5788        False
5789        >>> # Reciprocals are tangled...
5790        >>> g.getReciprocal(0, 'prev')
5791        'next.1'
5792        >>> g.getReciprocal(0, 'prev.1')
5793        'next'
5794        >>> g.getReciprocal(1, 'next')
5795        'prev.1'
5796        >>> g.getReciprocal(1, 'next.1')
5797        'prev'
5798        >>> # Note: one merge cannot handle both extra transitions
5799        >>> # because their reciprocals are crossed (e.g., prev.1 <-> next)
5800        >>> # (It would merge both edges but the result would retain
5801        >>> # 'next.1' instead of retaining 'next'.)
5802        >>> g.mergeTransitions('X', 'prev.1', 'prev', mergeReciprocal=False)
5803        >>> g.mergeTransitions('Y', 'next.1', 'next', mergeReciprocal=True)
5804        >>> g.destinationsFrom('X')
5805        {'next': 1, 'prev': 1}
5806        >>> g.destinationsFrom('Y')
5807        {'prev': 0, 'next': 0}
5808        >>> # Reciprocals were salvaged in second merger
5809        >>> g.getReciprocal('X', 'prev')
5810        'next'
5811        >>> g.getReciprocal('Y', 'next')
5812        'prev'
5813
5814        ## Merging with tags/requirements/annotations/consequences
5815
5816        >>> g = DecisionGraph()
5817        >>> g.addDecision('X')
5818        0
5819        >>> g.addDecision('Y')
5820        1
5821        >>> g.addDecision('Z')
5822        2
5823        >>> g.addTransition('X', 'next', 'Y', 'prev')
5824        >>> g.addTransition('X', 'down', 'Z', 'up')
5825        >>> g.tagDecision('X', 'tag0', 1)
5826        >>> g.tagDecision('Y', 'tag1', 10)
5827        >>> g.tagDecision('Y', 'unconfirmed')
5828        >>> g.tagDecision('Z', 'tag1', 20)
5829        >>> g.tagDecision('Z', 'tag2', 30)
5830        >>> g.tagTransition('X', 'next', 'ttag1', 11)
5831        >>> g.tagTransition('Y', 'prev', 'ttag2', 22)
5832        >>> g.tagTransition('X', 'down', 'ttag3', 33)
5833        >>> g.tagTransition('Z', 'up', 'ttag4', 44)
5834        >>> g.annotateDecision('Y', 'annotation 1')
5835        >>> g.annotateDecision('Z', 'annotation 2')
5836        >>> g.annotateDecision('Z', 'annotation 3')
5837        >>> g.annotateTransition('Y', 'prev', 'trans annotation 1')
5838        >>> g.annotateTransition('Y', 'prev', 'trans annotation 2')
5839        >>> g.annotateTransition('Z', 'up', 'trans annotation 3')
5840        >>> g.setTransitionRequirement(
5841        ...     'X',
5842        ...     'next',
5843        ...     base.ReqCapability('power')
5844        ... )
5845        >>> g.setTransitionRequirement(
5846        ...     'Y',
5847        ...     'prev',
5848        ...     base.ReqTokens('token', 1)
5849        ... )
5850        >>> g.setTransitionRequirement(
5851        ...     'X',
5852        ...     'down',
5853        ...     base.ReqCapability('power2')
5854        ... )
5855        >>> g.setTransitionRequirement(
5856        ...     'Z',
5857        ...     'up',
5858        ...     base.ReqTokens('token2', 2)
5859        ... )
5860        >>> g.setConsequence(
5861        ...     'Y',
5862        ...     'prev',
5863        ...     [base.effect(gain="power2")]
5864        ... )
5865        >>> g.mergeDecisions('Y', 'Z')
5866        {}
5867        >>> g.destination('X', 'next')
5868        2
5869        >>> g.destination('X', 'down')
5870        2
5871        >>> g.destination('Z', 'prev')
5872        0
5873        >>> g.destination('Z', 'up')
5874        0
5875        >>> g.decisionTags('X')
5876        {'tag0': 1}
5877        >>> g.decisionTags('Z')  # note that 'unconfirmed' is removed
5878        {'tag1': 20, 'tag2': 30}
5879        >>> g.transitionTags('X', 'next')
5880        {'ttag1': 11}
5881        >>> g.transitionTags('X', 'down')
5882        {'ttag3': 33}
5883        >>> g.transitionTags('Z', 'prev')
5884        {'ttag2': 22}
5885        >>> g.transitionTags('Z', 'up')
5886        {'ttag4': 44}
5887        >>> g.decisionAnnotations('Z')
5888        ['annotation 2', 'annotation 3', 'annotation 1']
5889        >>> g.transitionAnnotations('Z', 'prev')
5890        ['trans annotation 1', 'trans annotation 2']
5891        >>> g.transitionAnnotations('Z', 'up')
5892        ['trans annotation 3']
5893        >>> g.getTransitionRequirement('X', 'next')
5894        ReqCapability('power')
5895        >>> g.getTransitionRequirement('Z', 'prev')
5896        ReqTokens('token', 1)
5897        >>> g.getTransitionRequirement('X', 'down')
5898        ReqCapability('power2')
5899        >>> g.getTransitionRequirement('Z', 'up')
5900        ReqTokens('token2', 2)
5901        >>> g.getConsequence('Z', 'prev') == [
5902        ...     {
5903        ...         'type': 'gain',
5904        ...         'applyTo': 'active',
5905        ...         'value': 'power2',
5906        ...         'charges': None,
5907        ...         'delay': None,
5908        ...         'hidden': False
5909        ...     }
5910        ... ]
5911        True
5912
5913        ## Merging into node without tags
5914
5915        >>> g = DecisionGraph()
5916        >>> g.addDecision('X')
5917        0
5918        >>> g.addDecision('Y')
5919        1
5920        >>> g.tagDecision('Y', 'unconfirmed')  # special handling
5921        >>> g.tagDecision('Y', 'tag', 'value')
5922        >>> g.mergeDecisions('Y', 'X')
5923        {}
5924        >>> g.decisionTags('X')
5925        {'tag': 'value'}
5926        >>> 0 in g  # Second argument remains
5927        True
5928        >>> 1 in g  # First argument is deleted
5929        False
5930        """
5931        # Resolve IDs
5932        mergeID = self.resolveDecision(merge)
5933        mergeIntoID = self.resolveDecision(mergeInto)
5934
5935        # Create our result as an empty dictionary
5936        result: Dict[base.Transition, base.Transition] = {}
5937
5938        # Short-circuit if the two decisions are the same
5939        if mergeID == mergeIntoID:
5940            return result
5941
5942        # MissingDecisionErrors from here if either doesn't exist
5943        allNewOutgoing = set(self.destinationsFrom(mergeID))
5944        allOldOutgoing = set(self.destinationsFrom(mergeIntoID))
5945        # Find colliding transition names
5946        collisions = allNewOutgoing & allOldOutgoing
5947        if len(collisions) > 0 and errorOnNameColision:
5948            raise TransitionCollisionError(
5949                f"Cannot merge decision {self.identityOf(merge)} into"
5950                f" decision {self.identityOf(mergeInto)}: the decisions"
5951                f" share {len(collisions)} transition names:"
5952                f" {collisions}\n(Note that errorOnNameColision was set"
5953                f" to True, set it to False to allow the operation by"
5954                f" renaming half of those transitions.)"
5955            )
5956
5957        # Record zones that will have to change after the merge
5958        zoneParents = self.zoneParents(mergeID)
5959
5960        # First, swap all incoming edges, along with their reciprocals
5961        # This will include self-edges, which will be retargeted and
5962        # whose reciprocals will be rebased in the process, leading to
5963        # the possibility of a missing edge during the loop
5964        for source, incoming in self.allEdgesTo(mergeID):
5965            # Skip this edge if it was already swapped away because it's
5966            # a self-loop with a reciprocal whose reciprocal was
5967            # processed earlier in the loop
5968            if incoming not in self.destinationsFrom(source):
5969                continue
5970
5971            # Find corresponding outgoing edge
5972            outgoing = self.getReciprocal(source, incoming)
5973
5974            # Swap both edges to new destination
5975            newOutgoing = self.retargetTransition(
5976                source,
5977                incoming,
5978                mergeIntoID,
5979                swapReciprocal=True,
5980                errorOnNameColision=False # collisions were detected above
5981            )
5982            # Add to our result if the name of the reciprocal was
5983            # changed
5984            if (
5985                outgoing is not None
5986            and newOutgoing is not None
5987            and outgoing != newOutgoing
5988            ):
5989                result[outgoing] = newOutgoing
5990
5991        # Next, swap any remaining outgoing edges (which didn't have
5992        # reciprocals, or they'd already be swapped, unless they were
5993        # self-edges previously). Note that in this loop, there can't be
5994        # any self-edges remaining, although there might be connections
5995        # between the merging nodes that need to become self-edges
5996        # because they used to be a self-edge that was half-retargeted
5997        # by the previous loop.
5998        # Note: a copy is used here to avoid iterating over a changing
5999        # dictionary
6000        for stillOutgoing in copy.copy(self.destinationsFrom(mergeID)):
6001            newOutgoing = self.rebaseTransition(
6002                mergeID,
6003                stillOutgoing,
6004                mergeIntoID,
6005                swapReciprocal=True,
6006                errorOnNameColision=False # collisions were detected above
6007            )
6008            if stillOutgoing != newOutgoing:
6009                result[stillOutgoing] = newOutgoing
6010
6011        # At this point, there shouldn't be any remaining incoming or
6012        # outgoing edges!
6013        assert self.degree(mergeID) == 0
6014
6015        # Merge tags & annotations
6016        # Note that these operations affect the underlying graph
6017        destTags = self.decisionTags(mergeIntoID)
6018        destUnvisited = 'unconfirmed' in destTags
6019        sourceTags = self.decisionTags(mergeID)
6020        sourceUnvisited = 'unconfirmed' in sourceTags
6021        # Copy over only new tags, leaving existing tags alone
6022        for key in sourceTags:
6023            if key not in destTags:
6024                destTags[key] = sourceTags[key]
6025
6026        if int(destUnvisited) + int(sourceUnvisited) == 1:
6027            del destTags['unconfirmed']
6028
6029        self.decisionAnnotations(mergeIntoID).extend(
6030            self.decisionAnnotations(mergeID)
6031        )
6032
6033        # Transfer zones
6034        for zone in zoneParents:
6035            self.addDecisionToZone(mergeIntoID, zone)
6036
6037        # Delete the old node
6038        self.removeDecision(mergeID)
6039
6040        return result
6041
6042    def removeDecision(self, decision: base.AnyDecisionSpecifier) -> None:
6043        """
6044        Deletes the specified decision from the graph, updating
6045        attendant structures like zones. Note that the ID of the deleted
6046        node will NOT be reused, unless it's specifically provided to
6047        `addIdentifiedDecision`.
6048
6049        For example:
6050
6051        >>> dg = DecisionGraph()
6052        >>> dg.addDecision('A')
6053        0
6054        >>> dg.addDecision('B')
6055        1
6056        >>> list(dg)
6057        [0, 1]
6058        >>> 1 in dg
6059        True
6060        >>> 'B' in dg.nameLookup
6061        True
6062        >>> dg.removeDecision('B')
6063        >>> 1 in dg
6064        False
6065        >>> list(dg)
6066        [0]
6067        >>> 'B' in dg.nameLookup
6068        False
6069        >>> dg.addDecision('C')  # doesn't re-use ID
6070        2
6071        """
6072        dID = self.resolveDecision(decision)
6073
6074        # Remove the target from all zones:
6075        for zone in self.zones:
6076            self.removeDecisionFromZone(dID, zone)
6077
6078        # Remove the node but record the current name
6079        name = self.nodes[dID]['name']
6080        self.remove_node(dID)
6081
6082        # Clean up the nameLookup entry
6083        luInfo = self.nameLookup[name]
6084        luInfo.remove(dID)
6085        if len(luInfo) == 0:
6086            self.nameLookup.pop(name)
6087
6088        # TODO: Clean up edges?
6089
6090    def renameDecision(
6091        self,
6092        decision: base.AnyDecisionSpecifier,
6093        newName: base.DecisionName
6094    ):
6095        """
6096        Renames a decision. The decision retains its old ID.
6097
6098        Generates a `DecisionCollisionWarning` if a decision using the new
6099        name already exists and `WARN_OF_NAME_COLLISIONS` is enabled.
6100
6101        Example:
6102
6103        >>> g = DecisionGraph()
6104        >>> g.addDecision('one')
6105        0
6106        >>> g.addDecision('three')
6107        1
6108        >>> g.addTransition('one', '>', 'three')
6109        >>> g.addTransition('three', '<', 'one')
6110        >>> g.tagDecision('three', 'hi')
6111        >>> g.annotateDecision('three', 'note')
6112        >>> g.destination('one', '>')
6113        1
6114        >>> g.destination('three', '<')
6115        0
6116        >>> g.renameDecision('three', 'two')
6117        >>> g.resolveDecision('one')
6118        0
6119        >>> g.resolveDecision('two')
6120        1
6121        >>> g.resolveDecision('three')
6122        Traceback (most recent call last):
6123        ...
6124        exploration.core.MissingDecisionError...
6125        >>> g.destination('one', '>')
6126        1
6127        >>> g.nameFor(1)
6128        'two'
6129        >>> g.getDecision('three') is None
6130        True
6131        >>> g.destination('two', '<')
6132        0
6133        >>> g.decisionTags('two')
6134        {'hi': 1}
6135        >>> g.decisionAnnotations('two')
6136        ['note']
6137        """
6138        dID = self.resolveDecision(decision)
6139
6140        if newName in self.nameLookup and WARN_OF_NAME_COLLISIONS:
6141            warnings.warn(
6142                (
6143                    f"Can't rename {self.identityOf(decision)} as"
6144                    f" {newName!r} because a decision with that name"
6145                    f" already exists."
6146                ),
6147                DecisionCollisionWarning
6148            )
6149
6150        # Update name in node
6151        oldName = self.nodes[dID]['name']
6152        self.nodes[dID]['name'] = newName
6153
6154        # Update nameLookup entries
6155        oldNL = self.nameLookup[oldName]
6156        oldNL.remove(dID)
6157        if len(oldNL) == 0:
6158            self.nameLookup.pop(oldName)
6159        self.nameLookup.setdefault(newName, []).append(dID)
6160
6161    def renameTransition(
6162        self,
6163        fromDecision: base.AnyDecisionSpecifier,
6164        oldName: base.Transition,
6165        newName: base.Transition
6166    ):
6167        """
6168        Renames a transition. The transition retains its reciprocal
6169        association if it had one. The new name must not already exist as
6170        a transition name at the specified decision (see
6171        `mergeTransitions` for an alternative), or a
6172        `TransitionCollisionError` will be raised. Renaming to the same
6173        name does nothing.
6174
6175        Example:
6176
6177        >>> g = DecisionGraph()
6178        >>> g.addDecision('A')
6179        0
6180        >>> g.addDecision('B')
6181        1
6182        >>> g.addTransition('A', 'right', 'B', 'left')
6183        >>> g.getDestination('A', 'right')
6184        1
6185        >>> g.renameTransition('A', 'right', 'up')
6186        >>> g.getDestination('A', 'right') is None
6187        True
6188        >>> g.getDestination('A', 'up')
6189        1
6190        >>> g.getReciprocal('A', 'up')
6191        'left'
6192        >>> g.renameTransition('B', 'left', 'left')
6193        >>> g.getDestination('B', 'left')
6194        0
6195        >>> g.addTransition('B', 'down', 'A')
6196        >>> g.renameTransition('B', 'left', 'down')
6197        Traceback (most recent call last):
6198        ...
6199        exploration.core.TransitionCollisionError...
6200        >>> g.renameTransition('A', 'madeup', 'any')
6201        Traceback (most recent call last):
6202        ...
6203        exploration.core.MissingTransitionError...
6204        """
6205        if oldName == newName:
6206            return
6207
6208        dID = self.resolveDecision(fromDecision)
6209        dest = self.destination(dID, oldName)
6210          # this will raise MissingTransitionError if necessary
6211        if self.getDestination(dID, newName) is not None:
6212            raise TransitionCollisionError(
6213                f"Decision {self.shortIdentity(dID)} already has an"
6214                f" outgoing transition named {newName!r} so you cannot"
6215                f" rename transition {oldName!r} to that name."
6216            )
6217
6218        # Add a new transition without a reciprocal or any properties
6219        self.addTransition(dID, newName, dest)
6220
6221        # Merge old one into new one, setting new's reciprocal to old's
6222        self.mergeTransitions(dID, oldName, newName, mergeReciprocal=True)
6223
6224    def mergeTransitions(
6225        self,
6226        fromDecision: base.AnyDecisionSpecifier,
6227        merge: base.Transition,
6228        mergeInto: base.Transition,
6229        mergeReciprocal=True
6230    ) -> None:
6231        """
6232        Given a decision and two transitions that start at that decision,
6233        merges the first transition into the second transition, combining
6234        their transition properties (using `mergeProperties`) and
6235        deleting the first transition. By default any reciprocal of the
6236        first transition is also merged into the reciprocal of the
6237        second, although you can set `mergeReciprocal` to `False` to
6238        disable this in which case the old reciprocal will lose its
6239        reciprocal relationship, even if the transition that was merged
6240        into does not have a reciprocal.
6241
6242        If the two names provided are the same, nothing will happen.
6243
6244        If the two transitions do not share the same destination, they
6245        cannot be merged, and an `InvalidDestinationError` will result.
6246        Use `retargetTransition` beforehand to ensure that they do if you
6247        want to merge transitions with different destinations.
6248
6249        A `MissingDecisionError` or `MissingTransitionError` will result
6250        if the decision or either transition does not exist.
6251
6252        If merging reciprocal properties was requested and the first
6253        transition does not have a reciprocal, then no reciprocal
6254        properties change. However, if the second transition does not
6255        have a reciprocal and the first does, the first transition's
6256        reciprocal will be set as the reciprocal of the second
6257        transition, and that transition will not be deleted as usual.
6258
6259        ## Example
6260
6261        >>> g = DecisionGraph()
6262        >>> g.addDecision('A')
6263        0
6264        >>> g.addDecision('B')
6265        1
6266        >>> g.addTransition('A', 'up', 'B')
6267        >>> g.addTransition('B', 'down', 'A')
6268        >>> g.setReciprocal('A', 'up', 'down')
6269        >>> # Merging a transition with no reciprocal
6270        >>> g.addTransition('A', 'up2', 'B')
6271        >>> g.mergeTransitions('A', 'up2', 'up')
6272        >>> g.getDestination('A', 'up2') is None
6273        True
6274        >>> g.getDestination('A', 'up')
6275        1
6276        >>> # Merging a transition with a reciprocal & tags
6277        >>> g.addTransition('A', 'up2', 'B')
6278        >>> g.addTransition('B', 'down2', 'A')
6279        >>> g.setReciprocal('A', 'up2', 'down2')
6280        >>> g.tagTransition('A', 'up2', 'one')
6281        >>> g.tagTransition('B', 'down2', 'two')
6282        >>> g.mergeTransitions('B', 'down2', 'down')
6283        >>> g.getDestination('A', 'up2') is None
6284        True
6285        >>> g.getDestination('A', 'up')
6286        1
6287        >>> g.getDestination('B', 'down2') is None
6288        True
6289        >>> g.getDestination('B', 'down')
6290        0
6291        >>> # Merging requirements uses ReqAll (i.e., 'and' logic)
6292        >>> g.addTransition('A', 'up2', 'B')
6293        >>> g.setTransitionProperties(
6294        ...     'A',
6295        ...     'up2',
6296        ...     requirement=base.ReqCapability('dash')
6297        ... )
6298        >>> g.setTransitionProperties('A', 'up',
6299        ...     requirement=base.ReqCapability('slide'))
6300        >>> g.mergeTransitions('A', 'up2', 'up')
6301        >>> g.getDestination('A', 'up2') is None
6302        True
6303        >>> repr(g.getTransitionRequirement('A', 'up'))
6304        "ReqAll([ReqCapability('dash'), ReqCapability('slide')])"
6305        >>> # Errors if destinations differ, or if something is missing
6306        >>> g.mergeTransitions('A', 'down', 'up')
6307        Traceback (most recent call last):
6308        ...
6309        exploration.core.MissingTransitionError...
6310        >>> g.mergeTransitions('Z', 'one', 'two')
6311        Traceback (most recent call last):
6312        ...
6313        exploration.core.MissingDecisionError...
6314        >>> g.addDecision('C')
6315        2
6316        >>> g.addTransition('A', 'down', 'C')
6317        >>> g.mergeTransitions('A', 'down', 'up')
6318        Traceback (most recent call last):
6319        ...
6320        exploration.core.InvalidDestinationError...
6321        >>> # Merging a reciprocal onto an edge that doesn't have one
6322        >>> g.addTransition('A', 'down2', 'C')
6323        >>> g.addTransition('C', 'up2', 'A')
6324        >>> g.setReciprocal('A', 'down2', 'up2')
6325        >>> g.tagTransition('C', 'up2', 'narrow')
6326        >>> g.getReciprocal('A', 'down') is None
6327        True
6328        >>> g.mergeTransitions('A', 'down2', 'down')
6329        >>> g.getDestination('A', 'down2') is None
6330        True
6331        >>> g.getDestination('A', 'down')
6332        2
6333        >>> g.getDestination('C', 'up2')
6334        0
6335        >>> g.getReciprocal('A', 'down')
6336        'up2'
6337        >>> g.getReciprocal('C', 'up2')
6338        'down'
6339        >>> g.transitionTags('C', 'up2')
6340        {'narrow': 1}
6341        >>> # Merging without a reciprocal
6342        >>> g.addTransition('C', 'up', 'A')
6343        >>> g.mergeTransitions('C', 'up2', 'up', mergeReciprocal=False)
6344        >>> g.getDestination('C', 'up2') is None
6345        True
6346        >>> g.getDestination('C', 'up')
6347        0
6348        >>> g.transitionTags('C', 'up') # tag gets merged
6349        {'narrow': 1}
6350        >>> g.getDestination('A', 'down')
6351        2
6352        >>> g.getReciprocal('A', 'down') is None
6353        True
6354        >>> g.getReciprocal('C', 'up') is None
6355        True
6356        >>> # Merging w/ normal reciprocals
6357        >>> g.addDecision('D')
6358        3
6359        >>> g.addDecision('E')
6360        4
6361        >>> g.addTransition('D', 'up', 'E', 'return')
6362        >>> g.addTransition('E', 'down', 'D')
6363        >>> g.mergeTransitions('E', 'return', 'down')
6364        >>> g.getDestination('D', 'up')
6365        4
6366        >>> g.getDestination('E', 'down')
6367        3
6368        >>> g.getDestination('E', 'return') is None
6369        True
6370        >>> g.getReciprocal('D', 'up')
6371        'down'
6372        >>> g.getReciprocal('E', 'down')
6373        'up'
6374        >>> # Merging w/ weird reciprocals
6375        >>> g.addTransition('E', 'return', 'D')
6376        >>> g.setReciprocal('E', 'return', 'up', setBoth=False)
6377        >>> g.getReciprocal('D', 'up')
6378        'down'
6379        >>> g.getReciprocal('E', 'down')
6380        'up'
6381        >>> g.getReciprocal('E', 'return') # shared
6382        'up'
6383        >>> g.mergeTransitions('E', 'return', 'down')
6384        >>> g.getDestination('D', 'up')
6385        4
6386        >>> g.getDestination('E', 'down')
6387        3
6388        >>> g.getDestination('E', 'return') is None
6389        True
6390        >>> g.getReciprocal('D', 'up')
6391        'down'
6392        >>> g.getReciprocal('E', 'down')
6393        'up'
6394        """
6395        fromID = self.resolveDecision(fromDecision)
6396
6397        # Short-circuit in the no-op case
6398        if merge == mergeInto:
6399            return
6400
6401        # These lines will raise a MissingDecisionError or
6402        # MissingTransitionError if needed
6403        dest1 = self.destination(fromID, merge)
6404        dest2 = self.destination(fromID, mergeInto)
6405
6406        if dest1 != dest2:
6407            raise InvalidDestinationError(
6408                f"Cannot merge transition {merge!r} into transition"
6409                f" {mergeInto!r} from decision"
6410                f" {self.identityOf(fromDecision)} because their"
6411                f" destinations are different ({self.identityOf(dest1)}"
6412                f" and {self.identityOf(dest2)}).\nNote: you can use"
6413                f" `retargetTransition` to change the destination of a"
6414                f" transition."
6415            )
6416
6417        # Find and the transition properties
6418        props1 = self.getTransitionProperties(fromID, merge)
6419        props2 = self.getTransitionProperties(fromID, mergeInto)
6420        merged = mergeProperties(props1, props2)
6421        # Note that this doesn't change the reciprocal:
6422        self.setTransitionProperties(fromID, mergeInto, **merged)
6423
6424        # Merge the reciprocal properties if requested
6425        # Get reciprocal to merge into
6426        reciprocal = self.getReciprocal(fromID, mergeInto)
6427        # Get reciprocal that needs cleaning up
6428        altReciprocal = self.getReciprocal(fromID, merge)
6429        # If the reciprocal to be merged actually already was the
6430        # reciprocal to merge into, there's nothing to do here
6431        if altReciprocal != reciprocal:
6432            if not mergeReciprocal:
6433                # In this case, we sever the reciprocal relationship if
6434                # there is a reciprocal
6435                if altReciprocal is not None:
6436                    self.setReciprocal(dest1, altReciprocal, None)
6437                    # By default setBoth takes care of the other half
6438            else:
6439                # In this case, we try to merge reciprocals
6440                # If altReciprocal is None, we don't need to do anything
6441                if altReciprocal is not None:
6442                    # Was there already a reciprocal or not?
6443                    if reciprocal is None:
6444                        # altReciprocal becomes the new reciprocal and is
6445                        # not deleted
6446                        self.setReciprocal(
6447                            fromID,
6448                            mergeInto,
6449                            altReciprocal
6450                        )
6451                    else:
6452                        # merge reciprocal properties
6453                        props1 = self.getTransitionProperties(
6454                            dest1,
6455                            altReciprocal
6456                        )
6457                        props2 = self.getTransitionProperties(
6458                            dest2,
6459                            reciprocal
6460                        )
6461                        merged = mergeProperties(props1, props2)
6462                        self.setTransitionProperties(
6463                            dest1,
6464                            reciprocal,
6465                            **merged
6466                        )
6467
6468                        # delete the old reciprocal transition
6469                        self.remove_edge(dest1, fromID, altReciprocal)
6470
6471        # Delete the old transition (reciprocal deletion/severance is
6472        # handled above if necessary)
6473        self.remove_edge(fromID, dest1, merge)
6474
6475    def renameZone(self, oldName: base.Zone, newName: base.Zone):
6476        """
6477        Renames the specified zone. Raises a `ZoneCollisionError` if the
6478        new name is already taken.
6479
6480        Example:
6481
6482        >>> g = DecisionGraph()
6483        >>> g.addDecision("A")
6484        0
6485        >>> g.addDecision("B")
6486        1
6487        >>> g.createZone('Z', 0)
6488        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
6489 annotations=[])
6490        >>> g.createZone('ZZ', 1)
6491        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
6492 annotations=[])
6493        >>> g.addZoneToZone("Z", "ZZ")
6494        >>> g.addDecisionToZone("A", "Z")
6495        >>> g.renameZone("Z", "Q")
6496        >>> sorted(g.zoneAncestors(0))
6497        ['Q', 'ZZ']
6498        >>> g.decisionsInZone('Z')
6499        Traceback (most recent call last):
6500        ...
6501        exploration.core.MissingZoneError...
6502        >>> g.decisionsInZone('Q')
6503        {0}
6504        """
6505        if newName in self.zones:
6506            raise ZoneCollisionError(
6507               f"Cannot rename zone {oldName!r} to {newName!r} because"
6508               f" a zone with that new name already exists."
6509            )
6510        # Transfer zone info & delete old entry
6511        self.zones[newName] = self.zones[oldName]
6512        del self.zones[oldName]
6513
6514        # Fix up child/contents info in ALL zones
6515        for zoneInfo in self.zones.values():
6516            if oldName in zoneInfo.parents:
6517                zoneInfo.parents.remove(oldName)
6518                zoneInfo.parents.add(newName)
6519            if oldName in zoneInfo.contents:
6520                zoneInfo.contents.remove(oldName)
6521                zoneInfo.contents.add(newName)
6522
6523        # Fix up decision parent info
6524        for n in self.nodes():
6525            zones = self.nodes[n].get('zones')
6526            if zones is not None:
6527                if oldName in zones:
6528                    zones.remove(oldName)
6529                    zones.add(newName)
6530
6531    def isConfirmed(self, decision: base.AnyDecisionSpecifier) -> bool:
6532        """
6533        Returns `True` or `False` depending on whether or not the
6534        specified decision has been confirmed. Uses the presence or
6535        absence of the 'unconfirmed' tag to determine this.
6536
6537        Note: 'unconfirmed' is used instead of 'confirmed' so that large
6538        graphs with many confirmed nodes will be smaller when saved.
6539        """
6540        dID = self.resolveDecision(decision)
6541
6542        return 'unconfirmed' not in self.nodes[dID]['tags']
6543
6544    def replaceUnconfirmed(
6545        self,
6546        fromDecision: base.AnyDecisionSpecifier,
6547        transition: base.Transition,
6548        connectTo: Optional[base.AnyDecisionSpecifier] = None,
6549        reciprocal: Optional[base.Transition] = None,
6550        requirement: Optional[base.Requirement] = None,
6551        applyConsequence: Optional[base.Consequence] = None,
6552        placeInZone: Optional[base.Zone] = None,
6553        forceNew: bool = False,
6554        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
6555        annotations: Optional[List[base.Annotation]] = None,
6556        revRequires: Optional[base.Requirement] = None,
6557        revConsequence: Optional[base.Consequence] = None,
6558        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
6559        revAnnotations: Optional[List[base.Annotation]] = None,
6560        decisionTags: Optional[Dict[base.Tag, base.TagValue]] = None,
6561        decisionAnnotations: Optional[List[base.Annotation]] = None
6562    ) -> Tuple[
6563        Dict[base.Transition, base.Transition],
6564        Dict[base.Transition, base.Transition]
6565    ]:
6566        """
6567        Given a decision and an edge name in that decision, where the
6568        named edge leads to a decision with an unconfirmed exploration
6569        state (see `isConfirmed`), renames the unexplored decision on
6570        the other end of that edge using the given `connectTo` name, or
6571        if a decision using that name already exists, merges the
6572        unexplored decision into that decision. If `connectTo` is a
6573        `DecisionSpecifier` whose target doesn't exist, it will be
6574        treated as just a name, but if it's an ID and it doesn't exist,
6575        you'll get a `MissingDecisionError`. If a `reciprocal` is provided,
6576        a reciprocal edge will be added using that name connecting the
6577        `connectTo` decision back to the original decision. If this
6578        transition already exists, it must also point to a node which is
6579        also unexplored, and which will also be merged into the
6580        `fromDecision` node.
6581
6582        If `connectTo` is not given (or is set to `None` explicitly)
6583        then the name of the unexplored decision will not be changed,
6584        unless that name has the form `'_u.-n-'` where `-n-` is a positive
6585        integer (i.e., the form given to automatically-named unknown
6586        nodes). In that case, the name will be changed to `'_x.-n-'` using
6587        the same number, or a higher number if that name is already taken.
6588
6589        If the destination is being renamed or if the destination's
6590        exploration state counts as unexplored, the exploration state of
6591        the destination will be set to 'exploring'.
6592
6593        If a `placeInZone` is specified, the destination will be placed
6594        directly into that zone (even if it already existed and has zone
6595        information), and it will be removed from any other zones it had
6596        been a direct member of. If `placeInZone` is set to
6597        `base.DefaultZone`, then the destination will be placed into
6598        each zone which is a direct parent of the origin, but only if
6599        the destination is not an already-explored existing decision AND
6600        it is not already in any zones (in those cases no zone changes
6601        are made). This will also remove it from any previous zones it
6602        had been a part of. If `placeInZone` is left as `None` (the
6603        default) no zone changes are made.
6604
6605        If `placeInZone` is specified and that zone didn't already exist,
6606        it will be created as a new level-0 zone and will be added as a
6607        sub-zone of each zone that's a direct parent of any level-0 zone
6608        that the origin is a member of.
6609
6610        If `forceNew` is specified, then the destination will just be
6611        renamed, even if another decision with the same name already
6612        exists. It's an error to use `forceNew` with a decision ID as
6613        the destination.
6614
6615        Any additional edges pointing to or from the unknown node(s)
6616        being replaced will also be re-targeted at the now-discovered
6617        known destination(s) if necessary. These edges will retain their
6618        reciprocal names, or if this would cause a name clash, they will
6619        be renamed with a suffix (see `retargetTransition`).
6620
6621        The return value is a pair of dictionaries mapping old names to
6622        new ones that just includes the names which were changed. The
6623        first dictionary contains renamed transitions that are outgoing
6624        from the new destination node (which used to be outgoing from
6625        the unexplored node). The second dictionary contains renamed
6626        transitions that are outgoing from the source node (which used
6627        to be outgoing from the unexplored node attached to the
6628        reciprocal transition; if there was no reciprocal transition
6629        specified then this will always be an empty dictionary).
6630
6631        An `ExplorationStatusError` will be raised if the destination
6632        of the specified transition counts as visited (see
6633        `hasBeenVisited`). An `ExplorationStatusError` will also be
6634        raised if the `connectTo`'s `reciprocal` transition does not lead
6635        to an unconfirmed decision (it's okay if this second transition
6636        doesn't exist). A `TransitionCollisionError` will be raised if
6637        the unconfirmed destination decision already has an outgoing
6638        transition with the specified `reciprocal` which does not lead
6639        back to the `fromDecision`.
6640
6641        The transition properties (requirement, consequences, tags,
6642        and/or annotations) of the replaced transition will be copied
6643        over to the new transition. Transition properties from the
6644        reciprocal transition will also be copied for the newly created
6645        reciprocal edge. Properties for any additional edges to/from the
6646        unknown node will also be copied.
6647
6648        Also, any transition properties on existing forward or reciprocal
6649        edges from the destination node with the indicated reverse name
6650        will be merged with those from the target transition. Note that
6651        this merging process may introduce corruption of complex
6652        transition consequences. TODO: Fix that!
6653
6654        Any tags and annotations are added to copied tags/annotations,
6655        but specified requirements, and/or consequences will replace
6656        previous requirements/consequences, rather than being added to
6657        them.
6658
6659        ## Example
6660
6661        >>> g = DecisionGraph()
6662        >>> g.addDecision('A')
6663        0
6664        >>> g.addUnexploredEdge('A', 'up')
6665        1
6666        >>> g.destination('A', 'up')
6667        1
6668        >>> g.degree(1)
6669        1
6670        >>> g.replaceUnconfirmed('A', 'up', 'B', 'down')
6671        ({}, {})
6672        >>> g.destination('A', 'up')
6673        1
6674        >>> g.nameFor(1)
6675        'B'
6676        >>> g.destination('B', 'down')
6677        0
6678        >>> g.getDestination('B', 'return') is None
6679        True
6680        >>> '_u.0' in g.nameLookup
6681        False
6682        >>> g.getReciprocal('A', 'up')
6683        'down'
6684        >>> g.getReciprocal('B', 'down')
6685        'up'
6686        >>> # Two unexplored edges to the same node:
6687        >>> g.addDecision('C')
6688        2
6689        >>> g.addTransition('B', 'next', 'C')
6690        >>> g.addTransition('C', 'prev', 'B')
6691        >>> g.setReciprocal('B', 'next', 'prev')
6692        >>> g.addUnexploredEdge('A', 'next', 'D', 'prev')
6693        3
6694        >>> g.addTransition('C', 'down', 'D')
6695        >>> g.addTransition('D', 'up', 'C')
6696        >>> g.setReciprocal('C', 'down', 'up')
6697        >>> g.replaceUnconfirmed('C', 'down')
6698        ({}, {})
6699        >>> g.destination('C', 'down')
6700        3
6701        >>> g.destination('A', 'next')
6702        3
6703        >>> g.destinationsFrom('D')
6704        {'prev': 0, 'up': 2}
6705        >>> g.decisionTags('D')
6706        {}
6707        >>> # An unexplored transition which turns out to connect to a
6708        >>> # known decision, with name collisions
6709        >>> g.addUnexploredEdge('D', 'next', reciprocal='prev')
6710        4
6711        >>> g.tagDecision('_u.2', 'wet')
6712        >>> g.addUnexploredEdge('B', 'next', reciprocal='prev') # edge taken
6713        Traceback (most recent call last):
6714        ...
6715        exploration.core.TransitionCollisionError...
6716        >>> g.addUnexploredEdge('A', 'prev', reciprocal='next')
6717        5
6718        >>> g.tagDecision('_u.3', 'dry')
6719        >>> # Add transitions that will collide when merged
6720        >>> g.addUnexploredEdge('_u.2', 'up') # collides with A/up
6721        6
6722        >>> g.addUnexploredEdge('_u.3', 'prev') # collides with D/prev
6723        7
6724        >>> g.getReciprocal('A', 'prev')
6725        'next'
6726        >>> g.replaceUnconfirmed('A', 'prev', 'D', 'next') # two gone
6727        ({'prev': 'prev.1'}, {'up': 'up.1'})
6728        >>> g.destination('A', 'prev')
6729        3
6730        >>> g.destination('D', 'next')
6731        0
6732        >>> g.getReciprocal('A', 'prev')
6733        'next'
6734        >>> g.getReciprocal('D', 'next')
6735        'prev'
6736        >>> # Note that further unexplored structures are NOT merged
6737        >>> # even if they match against existing structures...
6738        >>> g.destination('A', 'up.1')
6739        6
6740        >>> g.destination('D', 'prev.1')
6741        7
6742        >>> '_u.2' in g.nameLookup
6743        False
6744        >>> '_u.3' in g.nameLookup
6745        False
6746        >>> g.decisionTags('D') # tags are merged
6747        {'dry': 1}
6748        >>> g.decisionTags('A')
6749        {'wet': 1}
6750        >>> # Auto-renaming an anonymous unexplored node
6751        >>> g.addUnexploredEdge('B', 'out')
6752        8
6753        >>> g.replaceUnconfirmed('B', 'out', None, 'return')
6754        ({}, {})
6755        >>> '_u.6' in g
6756        False
6757        >>> g.destination('B', 'out')
6758        8
6759        >>> g.nameFor(8)
6760        '_x.6'
6761        >>> g.destination('_x.6', 'return')
6762        1
6763        >>> # Placing a node into a zone
6764        >>> g.addUnexploredEdge('B', 'through')
6765        9
6766        >>> g.getDecision('E') is None
6767        True
6768        >>> g.replaceUnconfirmed(
6769        ...     'B',
6770        ...     'through',
6771        ...     'E',
6772        ...     'back',
6773        ...     placeInZone='Zone'
6774        ... )
6775        ({}, {})
6776        >>> g.getDecision('E')
6777        9
6778        >>> g.destination('B', 'through')
6779        9
6780        >>> g.destination('E', 'back')
6781        1
6782        >>> g.zoneParents(9)
6783        {'Zone'}
6784        >>> g.addUnexploredEdge('E', 'farther')
6785        10
6786        >>> g.replaceUnconfirmed(
6787        ...     'E',
6788        ...     'farther',
6789        ...     'F',
6790        ...     'closer',
6791        ...     placeInZone=base.DefaultZone
6792        ... )
6793        ({}, {})
6794        >>> g.destination('E', 'farther')
6795        10
6796        >>> g.destination('F', 'closer')
6797        9
6798        >>> g.zoneParents(10)
6799        {'Zone'}
6800        >>> g.addUnexploredEdge('F', 'backwards', placeInZone='Enoz')
6801        11
6802        >>> g.replaceUnconfirmed(
6803        ...     'F',
6804        ...     'backwards',
6805        ...     'G',
6806        ...     'forwards',
6807        ...     placeInZone=base.DefaultZone
6808        ... )
6809        ({}, {})
6810        >>> g.destination('F', 'backwards')
6811        11
6812        >>> g.destination('G', 'forwards')
6813        10
6814        >>> g.zoneParents(11)  # not changed since it already had a zone
6815        {'Enoz'}
6816        >>> # TODO: forceNew example
6817        """
6818
6819        # Defaults
6820        if tags is None:
6821            tags = {}
6822        if annotations is None:
6823            annotations = []
6824        if revTags is None:
6825            revTags = {}
6826        if revAnnotations is None:
6827            revAnnotations = []
6828        if decisionTags is None:
6829            decisionTags = {}
6830        if decisionAnnotations is None:
6831            decisionAnnotations = []
6832
6833        # Resolve source
6834        fromID = self.resolveDecision(fromDecision)
6835
6836        # Figure out destination decision
6837        oldUnexplored = self.destination(fromID, transition)
6838        if self.isConfirmed(oldUnexplored):
6839            raise ExplorationStatusError(
6840                f"Transition {transition!r} from"
6841                f" {self.identityOf(fromDecision)} does not lead to an"
6842                f" unconfirmed decision (it leads to"
6843                f" {self.identityOf(oldUnexplored)} which is not tagged"
6844                f" 'unconfirmed')."
6845            )
6846
6847        # Resolve destination
6848        newName: Optional[base.DecisionName] = None
6849        connectID: Optional[base.DecisionID] = None
6850        if forceNew:
6851            if isinstance(connectTo, base.DecisionID):
6852                raise TypeError(
6853                    f"connectTo cannot be a decision ID when forceNew"
6854                    f" is True. Got: {self.identityOf(connectTo)}"
6855                )
6856            elif isinstance(connectTo, base.DecisionSpecifier):
6857                newName = connectTo.name
6858            elif isinstance(connectTo, base.DecisionName):
6859                newName = connectTo
6860            elif connectTo is None:
6861                oldName = self.nameFor(oldUnexplored)
6862                if (
6863                    oldName.startswith('_u.')
6864                and oldName[3:].isdigit()
6865                ):
6866                    newName = utils.uniqueName('_x.' + oldName[3:], self)
6867                else:
6868                    newName = oldName
6869            else:
6870                raise TypeError(
6871                    f"Invalid connectTo value: {connectTo!r}"
6872                )
6873        elif connectTo is not None:
6874            try:
6875                connectID = self.resolveDecision(connectTo)
6876                # leave newName as None
6877            except MissingDecisionError:
6878                if isinstance(connectTo, int):
6879                    raise
6880                elif isinstance(connectTo, base.DecisionSpecifier):
6881                    newName = connectTo.name
6882                    # The domain & zone are ignored here
6883                else:  # Must just be a string
6884                    assert isinstance(connectTo, str)
6885                    newName = connectTo
6886        else:
6887            # If connectTo name wasn't specified, use current name of
6888            # unknown node unless it's a default name
6889            oldName = self.nameFor(oldUnexplored)
6890            if (
6891                oldName.startswith('_u.')
6892            and oldName[3:].isdigit()
6893            ):
6894                newName = utils.uniqueName('_x.' + oldName[3:], self)
6895            else:
6896                newName = oldName
6897
6898        # One or the other should be valid at this point
6899        assert connectID is not None or newName is not None
6900
6901        # Check that the old unknown doesn't have a reciprocal edge that
6902        # would collide with the specified return edge
6903        if reciprocal is not None:
6904            revFromUnknown = self.getDestination(oldUnexplored, reciprocal)
6905            if revFromUnknown not in (None, fromID):
6906                raise TransitionCollisionError(
6907                    f"Transition {reciprocal!r} from"
6908                    f" {self.identityOf(oldUnexplored)} exists and does"
6909                    f" not lead back to {self.identityOf(fromDecision)}"
6910                    f" (it leads to {self.identityOf(revFromUnknown)})."
6911                )
6912
6913        # Remember old reciprocal edge for future merging in case
6914        # it's not reciprocal
6915        oldReciprocal = self.getReciprocal(fromID, transition)
6916
6917        # Apply any new tags or annotations, or create a new node
6918        needsZoneInfo = False
6919        if connectID is not None:
6920            # Before applying tags, check if we need to error out
6921            # because of a reciprocal edge that points to a known
6922            # destination:
6923            if reciprocal is not None:
6924                otherOldUnknown: Optional[
6925                    base.DecisionID
6926                ] = self.getDestination(
6927                    connectID,
6928                    reciprocal
6929                )
6930                if (
6931                    otherOldUnknown is not None
6932                and self.isConfirmed(otherOldUnknown)
6933                ):
6934                    raise ExplorationStatusError(
6935                        f"Reciprocal transition {reciprocal!r} from"
6936                        f" {self.identityOf(connectTo)} does not lead"
6937                        f" to an unconfirmed decision (it leads to"
6938                        f" {self.identityOf(otherOldUnknown)})."
6939                    )
6940            self.tagDecision(connectID, decisionTags)
6941            self.annotateDecision(connectID, decisionAnnotations)
6942            # Still needs zone info if the place we're connecting to was
6943            # unconfirmed up until now, since unconfirmed nodes don't
6944            # normally get zone info when they're created.
6945            if not self.isConfirmed(connectID):
6946                needsZoneInfo = True
6947
6948            # First, merge the old unknown with the connectTo node...
6949            destRenames = self.mergeDecisions(
6950                oldUnexplored,
6951                connectID,
6952                errorOnNameColision=False
6953            )
6954        else:
6955            needsZoneInfo = True
6956            if len(self.zoneParents(oldUnexplored)) > 0:
6957                needsZoneInfo = False
6958            assert newName is not None
6959            self.renameDecision(oldUnexplored, newName)
6960            connectID = oldUnexplored
6961            # In this case there can't be an other old unknown
6962            otherOldUnknown = None
6963            destRenames = {}  # empty
6964
6965        # Check for domain mismatch to stifle zone updates:
6966        fromDomain = self.domainFor(fromID)
6967        if connectID is None:
6968            destDomain = self.domainFor(oldUnexplored)
6969        else:
6970            destDomain = self.domainFor(connectID)
6971
6972        # Stifle zone updates if there's a mismatch
6973        if fromDomain != destDomain:
6974            needsZoneInfo = False
6975
6976        # Records renames that happen at the source (from node)
6977        sourceRenames = {}  # empty for now
6978
6979        assert connectID is not None
6980
6981        # Apply the new zone if there is one
6982        if placeInZone is not None:
6983            if placeInZone == base.DefaultZone:
6984                # When using DefaultZone, changes are only made for new
6985                # destinations which don't already have any zones and
6986                # which are in the same domain as the departing node:
6987                # they get placed into each zone parent of the source
6988                # decision.
6989                if needsZoneInfo:
6990                    # Remove destination from all current parents
6991                    removeFrom = set(self.zoneParents(connectID))  # copy
6992                    for parent in removeFrom:
6993                        self.removeDecisionFromZone(connectID, parent)
6994                    # Add it to parents of origin
6995                    for parent in self.zoneParents(fromID):
6996                        self.addDecisionToZone(connectID, parent)
6997            else:
6998                placeInZone = cast(base.Zone, placeInZone)
6999                # Create the zone if it doesn't already exist
7000                if self.getZoneInfo(placeInZone) is None:
7001                    self.createZone(placeInZone, 0)
7002                    # Add it to each grandparent of the from decision
7003                    for parent in self.zoneParents(fromID):
7004                        for grandparent in self.zoneParents(parent):
7005                            self.addZoneToZone(placeInZone, grandparent)
7006                # Remove destination from all current parents
7007                for parent in set(self.zoneParents(connectID)):
7008                    self.removeDecisionFromZone(connectID, parent)
7009                # Add it to the specified zone
7010                self.addDecisionToZone(connectID, placeInZone)
7011
7012        # Next, if there is a reciprocal name specified, we do more...
7013        if reciprocal is not None:
7014            # Figure out what kind of merging needs to happen
7015            if otherOldUnknown is None:
7016                if revFromUnknown is None:
7017                    # Just create the desired reciprocal transition, which
7018                    # we know does not already exist
7019                    self.addTransition(connectID, reciprocal, fromID)
7020                    otherOldReciprocal = None
7021                else:
7022                    # Reciprocal exists, as revFromUnknown
7023                    otherOldReciprocal = None
7024            else:
7025                otherOldReciprocal = self.getReciprocal(
7026                    connectID,
7027                    reciprocal
7028                )
7029                # we need to merge otherOldUnknown into our fromDecision
7030                sourceRenames = self.mergeDecisions(
7031                    otherOldUnknown,
7032                    fromID,
7033                    errorOnNameColision=False
7034                )
7035                # Unvisited tag after merge only if both were
7036
7037            # No matter what happened we ensure the reciprocal
7038            # relationship is set up:
7039            self.setReciprocal(fromID, transition, reciprocal)
7040
7041            # Now we might need to merge some transitions:
7042            # - Any reciprocal of the target transition should be merged
7043            #   with reciprocal (if it was already reciprocal, that's a
7044            #   no-op).
7045            # - Any reciprocal of the reciprocal transition from the target
7046            #   node (leading to otherOldUnknown) should be merged with
7047            #   the target transition, even if it shared a name and was
7048            #   renamed as a result.
7049            # - If reciprocal was renamed during the initial merge, those
7050            #   transitions should be merged.
7051
7052            # Merge old reciprocal into reciprocal
7053            if oldReciprocal is not None:
7054                oldRev = destRenames.get(oldReciprocal, oldReciprocal)
7055                if self.getDestination(connectID, oldRev) is not None:
7056                    # Note that we don't want to auto-merge the reciprocal,
7057                    # which is the target transition
7058                    self.mergeTransitions(
7059                        connectID,
7060                        oldRev,
7061                        reciprocal,
7062                        mergeReciprocal=False
7063                    )
7064                    # Remove it from the renames map
7065                    if oldReciprocal in destRenames:
7066                        del destRenames[oldReciprocal]
7067
7068            # Merge reciprocal reciprocal from otherOldUnknown
7069            if otherOldReciprocal is not None:
7070                otherOldRev = sourceRenames.get(
7071                    otherOldReciprocal,
7072                    otherOldReciprocal
7073                )
7074                # Note that the reciprocal is reciprocal, which we don't
7075                # need to merge
7076                self.mergeTransitions(
7077                    fromID,
7078                    otherOldRev,
7079                    transition,
7080                    mergeReciprocal=False
7081                )
7082                # Remove it from the renames map
7083                if otherOldReciprocal in sourceRenames:
7084                    del sourceRenames[otherOldReciprocal]
7085
7086            # Merge any renamed reciprocal onto reciprocal
7087            if reciprocal in destRenames:
7088                extraRev = destRenames[reciprocal]
7089                self.mergeTransitions(
7090                    connectID,
7091                    extraRev,
7092                    reciprocal,
7093                    mergeReciprocal=False
7094                )
7095                # Remove it from the renames map
7096                del destRenames[reciprocal]
7097
7098        # Accumulate new tags & annotations for the transitions
7099        self.tagTransition(fromID, transition, tags)
7100        self.annotateTransition(fromID, transition, annotations)
7101
7102        if reciprocal is not None:
7103            self.tagTransition(connectID, reciprocal, revTags)
7104            self.annotateTransition(connectID, reciprocal, revAnnotations)
7105
7106        # Override copied requirement/consequences for the transitions
7107        if requirement is not None:
7108            self.setTransitionRequirement(
7109                fromID,
7110                transition,
7111                requirement
7112            )
7113        if applyConsequence is not None:
7114            self.setConsequence(
7115                fromID,
7116                transition,
7117                applyConsequence
7118            )
7119
7120        if reciprocal is not None:
7121            if revRequires is not None:
7122                self.setTransitionRequirement(
7123                    connectID,
7124                    reciprocal,
7125                    revRequires
7126                )
7127            if revConsequence is not None:
7128                self.setConsequence(
7129                    connectID,
7130                    reciprocal,
7131                    revConsequence
7132                )
7133
7134        # Remove 'unconfirmed' tag if it was present
7135        self.untagDecision(connectID, 'unconfirmed')
7136
7137        # Final checks
7138        assert self.getDestination(fromDecision, transition) == connectID
7139        useConnect: base.AnyDecisionSpecifier
7140        useRev: Optional[str]
7141        if connectTo is None:
7142            useConnect = connectID
7143        else:
7144            useConnect = connectTo
7145        if reciprocal is None:
7146            useRev = self.getReciprocal(fromDecision, transition)
7147        else:
7148            useRev = reciprocal
7149        if useRev is not None:
7150            try:
7151                assert self.getDestination(useConnect, useRev) == fromID
7152            except AmbiguousDecisionSpecifierError:
7153                assert self.getDestination(connectID, useRev) == fromID
7154
7155        # Return our final rename dictionaries
7156        return (destRenames, sourceRenames)
7157
7158    def endingID(self, name: base.DecisionName) -> base.DecisionID:
7159        """
7160        Returns the decision ID for the ending with the specified name.
7161        Endings are disconnected decisions in the `ENDINGS_DOMAIN`; they
7162        don't normally include any zone information. If no ending with
7163        the specified name already existed, then a new ending with that
7164        name will be created and its Decision ID will be returned.
7165
7166        If a new decision is created, it will be tagged as unconfirmed.
7167
7168        Note that endings mostly aren't special: they're normal
7169        decisions in a separate singular-focalized domain. However, some
7170        parts of the exploration and journal machinery treat them
7171        differently (in particular, taking certain actions via
7172        `advanceSituation` while any decision in the `ENDINGS_DOMAIN` is
7173        active is an error.
7174        """
7175        # Create our new ending decision if we need to
7176        try:
7177            endID = self.resolveDecision(
7178                base.DecisionSpecifier(ENDINGS_DOMAIN, None, name)
7179            )
7180        except MissingDecisionError:
7181            # Create a new decision for the ending
7182            endID = self.addDecision(name, domain=ENDINGS_DOMAIN)
7183            # Tag it as unconfirmed
7184            self.tagDecision(endID, 'unconfirmed')
7185
7186        return endID
7187
7188    def triggerGroupID(self, name: base.DecisionName) -> base.DecisionID:
7189        """
7190        Given the name of a trigger group, returns the ID of the special
7191        node representing that trigger group in the `TRIGGERS_DOMAIN`.
7192        If the specified group didn't already exist, it will be created.
7193
7194        Trigger group decisions are not special: they just exist in a
7195        separate spreading-focalized domain and have a few API methods to
7196        access them, but all the normal decision-related API methods
7197        still work. Their intended use is for sets of global triggers,
7198        by attaching actions with the 'trigger' tag to them and then
7199        activating or deactivating them as needed.
7200        """
7201        result = self.getDecision(
7202            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
7203        )
7204        if result is None:
7205            return self.addDecision(name, domain=TRIGGERS_DOMAIN)
7206        else:
7207            return result
7208
7209    @staticmethod
7210    def example(which: Literal['simple', 'abc']) -> 'DecisionGraph':
7211        """
7212        Returns one of a number of example decision graphs, depending on
7213        the string given. It returns a fresh copy each time. The graphs
7214        are:
7215
7216        - 'simple': Three nodes named 'A', 'B', and 'C' with IDs 0, 1,
7217            and 2, each connected to the next in the sequence by a
7218            'next' transition with reciprocal 'prev'. In other words, a
7219            simple little triangle. There are no tags, annotations,
7220            requirements, consequences, mechanisms, or equivalences.
7221        - 'abc': A more complicated 3-node setup that introduces a
7222            little bit of everything. In this graph, we have the same
7223            three nodes, but different transitions:
7224
7225                * From A you can go 'left' to B with reciprocal 'right'.
7226                * From A you can also go 'up_left' to B with reciprocal
7227                    'up_right'. These transitions both require the
7228                    'grate' mechanism (which is at decision A) to be in
7229                    state 'open'.
7230                * From A you can go 'down' to C with reciprocal 'up'.
7231
7232            (In this graph, B and C are not directly connected to each
7233            other.)
7234
7235            The graph has two level-0 zones 'zoneA' and 'zoneB', along
7236            with a level-1 zone 'upZone'. Decisions A and C are in
7237            zoneA while B is in zoneB; zoneA is in upZone, but zoneB is
7238            not.
7239
7240            The decision A has annotation:
7241
7242                'This is a multi-word "annotation."'
7243
7244            The transition 'down' from A has annotation:
7245
7246                "Transition 'annotation.'"
7247
7248            Decision B has tags 'b' with value 1 and 'tag2' with value
7249            '"value"'.
7250
7251            Decision C has tag 'aw"ful' with value "ha'ha'".
7252
7253            Transition 'up' from C has tag 'fast' with value 1.
7254
7255            At decision C there are actions 'grab_helmet' and
7256            'pull_lever'.
7257
7258            The 'grab_helmet' transition requires that you don't have
7259            the 'helmet' capability, and gives you that capability,
7260            deactivating with delay 3.
7261
7262            The 'pull_lever' transition requires that you do have the
7263            'helmet' capability, and takes away that capability, but it
7264            also gives you 1 'token' token, and if you have 2 tokens
7265            (before getting the one extra), it sets the 'grate' mechanism
7266            (which is a decision A) to state 'open' and deactivates.
7267
7268            The graph has an equivalence: having the 'helmet' capability
7269            satisfies requirements for the 'grate' mechanism to be in the
7270            'open' state.
7271        """
7272        result = DecisionGraph()
7273        if which == 'simple':
7274            result.addDecision('A')  # id 0
7275            result.addDecision('B')  # id 1
7276            result.addDecision('C')  # id 2
7277            result.addTransition('A', 'next', 'B', 'prev')
7278            result.addTransition('B', 'next', 'C', 'prev')
7279            result.addTransition('C', 'next', 'A', 'prev')
7280        elif which == 'abc':
7281            result.addDecision('A')  # id 0
7282            result.addDecision('B')  # id 1
7283            result.addDecision('C')  # id 2
7284            result.createZone('zoneA', 0)
7285            result.createZone('zoneB', 0)
7286            result.createZone('upZone', 1)
7287            result.addZoneToZone('zoneA', 'upZone')
7288            result.addDecisionToZone('A', 'zoneA')
7289            result.addDecisionToZone('B', 'zoneB')
7290            result.addDecisionToZone('C', 'zoneA')
7291            result.addTransition('A', 'left', 'B', 'right')
7292            result.addTransition('A', 'up_left', 'B', 'up_right')
7293            result.addTransition('A', 'down', 'C', 'up')
7294            result.setTransitionRequirement(
7295                'A',
7296                'up_left',
7297                base.ReqMechanism('grate', 'open')
7298            )
7299            result.setTransitionRequirement(
7300                'B',
7301                'up_right',
7302                base.ReqMechanism('grate', 'open')
7303            )
7304            result.annotateDecision('A', 'This is a multi-word "annotation."')
7305            result.annotateTransition('A', 'down', "Transition 'annotation.'")
7306            result.tagDecision('B', 'b')
7307            result.tagDecision('B', 'tag2', '"value"')
7308            result.tagDecision('C', 'aw"ful', "ha'ha")
7309            result.tagTransition('C', 'up', 'fast')
7310            result.addMechanism('grate', 'A')
7311            result.addAction(
7312                'C',
7313                'grab_helmet',
7314                base.ReqNot(base.ReqCapability('helmet')),
7315                [
7316                    base.effect(gain='helmet'),
7317                    base.effect(deactivate=True, delay=3)
7318                ]
7319            )
7320            result.addAction(
7321                'C',
7322                'pull_lever',
7323                base.ReqCapability('helmet'),
7324                [
7325                    base.effect(lose='helmet'),
7326                    base.effect(gain=('token', 1)),
7327                    base.condition(
7328                        base.ReqTokens('token', 2),
7329                        [
7330                            base.effect(set=('grate', 'open')),
7331                            base.effect(deactivate=True)
7332                        ]
7333                    )
7334                ]
7335            )
7336            result.addEquivalence(
7337                base.ReqCapability('helmet'),
7338                (0, 'open')
7339            )
7340        else:
7341            raise ValueError(f"Invalid example name: {which!r}")
7342
7343        return result

Represents a view of the world as a topological graph at a moment in time. It derives from networkx.MultiDiGraph.

Each node (a Decision) represents a place in the world where there are multiple opportunities for travel/action, or a dead end where you must turn around and go back; typically this is a single room in a game, but sometimes one room has multiple decision points. Edges (Transitions) represent choices that can be made to travel to other decision points (e.g., taking the left door), or when they are self-edges, they represent actions that can be taken within a location that affect the world or the game state.

Each Transition includes a Effects dictionary indicating the effects that it has. Other effects of the transition that are not simple enough to be included in this format may be represented in an DiscreteExploration by changing the graph in the next step to reflect further effects of a transition.

In addition to normal transitions between decisions, a DecisionGraph can represent potential transitions which lead to unknown destinations. These are represented by adding decisions with the 'unconfirmed' tag (whose names where not specified begin with '_u.') with a separate unconfirmed decision for each transition (although where it's known that two transitions lead to the same unconfirmed decision, this can be represented as well).

Both nodes and edges can have Annotations associated with them that include extra details about the explorer's perception of the situation. They can also have Tags, which represent specific categories a transition or decision falls into.

Nodes can also be part of one or more Zones, and zones can also be part of other zones, allowing for a hierarchical description of the underlying space.

Equivalences can be specified to mark that some combination of capabilities can stand in for another capability.

DecisionGraph()
475    def __init__(self) -> None:
476        super().__init__()
477
478        self.zones: Dict[base.Zone, base.ZoneInfo] = {}
479        """
480        Mapping from zone names to zone info
481        """
482
483        self.unknownCount: int = 0
484        """
485        Number of unknown decisions that have been created (not number
486        of current unknown decisions, which is likely lower)
487        """
488
489        self.equivalences: base.Equivalences = {}
490        """
491        See `base.Equivalences`. Determines what capabilities and/or
492        mechanism states can count as active based on alternate
493        requirements.
494        """
495
496        self.reversionTypes: Dict[str, Set[str]] = {}
497        """
498        This tracks shorthand reversion types. See `base.revertedState`
499        for how these are applied. Keys are custom names and values are
500        reversion type strings that `base.revertedState` could access.
501        """
502
503        self.nextID: base.DecisionID = 0
504        """
505        The ID to use for the next new decision we create.
506        """
507
508        self.nextMechanismID: base.MechanismID = 0
509        """
510        ID for the next mechanism.
511        """
512
513        self.mechanisms: Dict[
514            base.MechanismID,
515            Tuple[Optional[base.DecisionID], base.MechanismName]
516        ] = {}
517        """
518        Mapping from `MechanismID`s to (`DecisionID`, `MechanismName`)
519        pairs. For global mechanisms, the `DecisionID` is None.
520        """
521
522        self.globalMechanisms: Dict[
523            base.MechanismName,
524            base.MechanismID
525        ] = {}
526        """
527        Global mechanisms
528        """
529
530        self.nameLookup: Dict[base.DecisionName, List[base.DecisionID]] = {}
531        """
532        A cache for name -> ID lookups
533        """

Initialize a graph with edges, name, or graph attributes.

Parameters

incoming_graph_data : input graph Data to initialize graph. If incoming_graph_data=None (default) an empty graph is created. The data can be an edge list, or any NetworkX graph object. If the corresponding optional Python packages are installed the data can also be a 2D NumPy array, a SciPy sparse array, or a PyGraphviz graph.

multigraph_input : bool or None (default None) Note: Only used when incoming_graph_data is a dict. If True, incoming_graph_data is assumed to be a dict-of-dict-of-dict-of-dict structure keyed by node to neighbor to edge keys to edge data for multi-edges. A NetworkXError is raised if this is not the case. If False, to_networkx_graph() is used to try to determine the dict's graph data structure as either a dict-of-dict-of-dict keyed by node to neighbor to edge data, or a dict-of-iterable keyed by node to neighbors. If None, the treatment for True is tried, but if it fails, the treatment for False is tried.

attr : keyword arguments, optional (default= no attributes) Attributes to add to graph as key=value pairs.

See Also

convert

Examples

>>> G = nx.Graph()  # or DiGraph, MultiGraph, MultiDiGraph, etc
>>> G = nx.Graph(name="my graph")
>>> e = [(1, 2), (2, 3), (3, 4)]  # list of edges
>>> G = nx.Graph(e)

Arbitrary graph attribute pairs (key=value) may be assigned

>>> G = nx.Graph(e, day="Friday")
>>> G.graph
{'day': 'Friday'}
zones: Dict[str, exploration.base.ZoneInfo]

Mapping from zone names to zone info

unknownCount: int

Number of unknown decisions that have been created (not number of current unknown decisions, which is likely lower)

equivalences: Dict[Union[str, Tuple[int, str]], Set[exploration.base.Requirement]]

See base.Equivalences. Determines what capabilities and/or mechanism states can count as active based on alternate requirements.

reversionTypes: Dict[str, Set[str]]

This tracks shorthand reversion types. See base.revertedState for how these are applied. Keys are custom names and values are reversion type strings that base.revertedState could access.

nextID: int

The ID to use for the next new decision we create.

nextMechanismID: int

ID for the next mechanism.

mechanisms: Dict[int, Tuple[Optional[int], str]]

Mapping from MechanismIDs to (DecisionID, MechanismName) pairs. For global mechanisms, the DecisionID is None.

globalMechanisms: Dict[str, int]

Global mechanisms

nameLookup: Dict[str, List[int]]

A cache for name -> ID lookups

def listDifferences( self, other: DecisionGraph) -> Generator[str, NoneType, NoneType]:
581    def listDifferences(
582        self,
583        other: 'DecisionGraph'
584    ) -> Generator[str, None, None]:
585        """
586        Generates strings describing differences between this graph and
587        another graph. This does NOT perform graph matching, so it will
588        consider graphs different even if they have identical structures
589        but use different IDs for the nodes in those structures.
590        """
591        if not isinstance(other, DecisionGraph):
592            yield "other is not a graph"
593        else:
594            suppress = False
595            myNodes = set(self.nodes)
596            theirNodes = set(other.nodes)
597            for n in myNodes:
598                if n not in theirNodes:
599                    suppress = True
600                    yield (
601                        f"other graph missing node {n}"
602                    )
603                else:
604                    if self.nodes[n] != other.nodes[n]:
605                        suppress = True
606                        yield (
607                            f"other graph has differences at node {n}:"
608                            f"\n  Ours:  {self.nodes[n]}"
609                            f"\nTheirs:  {other.nodes[n]}"
610                        )
611                    myDests = self.destinationsFrom(n)
612                    theirDests = other.destinationsFrom(n)
613                    for tr in myDests:
614                        myTo = myDests[tr]
615                        if tr not in theirDests:
616                            suppress = True
617                            yield (
618                                f"at {self.identityOf(n)}: other graph"
619                                f" missing transition {tr!r}"
620                            )
621                        else:
622                            theirTo = theirDests[tr]
623                            if myTo != theirTo:
624                                suppress = True
625                                yield (
626                                    f"at {self.identityOf(n)}: other"
627                                    f" graph transition {tr!r} leads to"
628                                    f" {theirTo} instead of {myTo}"
629                                )
630                            else:
631                                myProps = self.edges[n, myTo, tr]  # type:ignore [index] # noqa
632                                theirProps = other.edges[n, myTo, tr]  # type:ignore [index] # noqa
633                                if myProps != theirProps:
634                                    suppress = True
635                                    yield (
636                                        f"at {self.identityOf(n)}: other"
637                                        f" graph transition {tr!r} has"
638                                        f" different properties:"
639                                        f"\n  Ours:  {myProps}"
640                                        f"\nTheirs:  {theirProps}"
641                                    )
642            for extra in theirNodes - myNodes:
643                suppress = True
644                yield (
645                    f"other graph has extra node {extra}"
646                )
647
648            # TODO: Fix networkx stubs!
649            if self.graph != other.graph:  # type:ignore [attr-defined]
650                suppress = True
651                yield (
652                    " different graph attributes:"  # type:ignore [attr-defined]  # noqa
653                    f"\n  Ours:  {self.graph}"
654                    f"\nTheirs:  {other.graph}"
655                )
656
657            # Checks any other graph data we might have missed
658            if not super().__eq__(other) and not suppress:
659                for attr in dir(self):
660                    if attr.startswith('__') and attr.endswith('__'):
661                        continue
662                    if not hasattr(other, attr):
663                        yield f"other graph missing attribute: {attr!r}"
664                    else:
665                        myVal = getattr(self, attr)
666                        theirVal = getattr(other, attr)
667                        if (
668                            myVal != theirVal
669                        and not ((callable(myVal) and callable(theirVal)))
670                        ):
671                            yield (
672                                f"other has different val for {attr!r}:"
673                                f"\n  Ours:  {myVal}"
674                                f"\nTheirs:  {theirVal}"
675                            )
676                for attr in sorted(set(dir(other)) - set(dir(self))):
677                    yield f"other has extra attribute: {attr!r}"
678                yield "graph data is different"
679                # TODO: More detail here!
680
681            # Check unknown count
682            if self.unknownCount != other.unknownCount:
683                yield "unknown count is different"
684
685            # Check zones
686            if self.zones != other.zones:
687                yield "zones are different"
688
689            # Check equivalences
690            if self.equivalences != other.equivalences:
691                yield "equivalences are different"
692
693            # Check reversion types
694            if self.reversionTypes != other.reversionTypes:
695                yield "reversionTypes are different"
696
697            # Check mechanisms
698            if self.nextMechanismID != other.nextMechanismID:
699                yield "nextMechanismID is different"
700
701            if self.mechanisms != other.mechanisms:
702                yield "mechanisms are different"
703
704            if self.globalMechanisms != other.globalMechanisms:
705                yield "global mechanisms are different"
706
707            # Check names:
708            if self.nameLookup != other.nameLookup:
709                for name in self.nameLookup:
710                    if name not in other.nameLookup:
711                        yield (
712                            f"other graph is missing name lookup entry"
713                            f" for {name!r}"
714                        )
715                    else:
716                        mine = self.nameLookup[name]
717                        theirs = other.nameLookup[name]
718                        if theirs != mine:
719                            yield (
720                                f"name lookup for {name!r} is {theirs}"
721                                f" instead of {mine}"
722                            )
723                extras = set(other.nameLookup) - set(self.nameLookup)
724                if extras:
725                    yield (
726                        f"other graph has extra name lookup entries:"
727                        f" {extras}"
728                    )

Generates strings describing differences between this graph and another graph. This does NOT perform graph matching, so it will consider graphs different even if they have identical structures but use different IDs for the nodes in those structures.

def decisionInfo(self, dID: int) -> DecisionInfo:
748    def decisionInfo(self, dID: base.DecisionID) -> DecisionInfo:
749        """
750        Retrieves the decision info for the specified decision, as a
751        live editable dictionary.
752
753        For example:
754
755        >>> g = DecisionGraph()
756        >>> g.addDecision('A')
757        0
758        >>> g.annotateDecision('A', 'note')
759        >>> g.decisionInfo(0)
760        {'name': 'A', 'domain': 'main', 'tags': {}, 'annotations': ['note']}
761        """
762        return cast(DecisionInfo, self.nodes[dID])

Retrieves the decision info for the specified decision, as a live editable dictionary.

For example:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.annotateDecision('A', 'note')
>>> g.decisionInfo(0)
{'name': 'A', 'domain': 'main', 'tags': {}, 'annotations': ['note']}
def resolveDecisions( self, spec: Union[int, exploration.base.DecisionSpecifier, str], zoneHint: Optional[str] = None, domainHint: Optional[str] = None) -> Set[int]:
764    def resolveDecisions(
765        self,
766        spec: base.AnyDecisionSpecifier,
767        zoneHint: Optional[base.Zone] = None,
768        domainHint: Optional[base.Domain] = None
769    ) -> Set[base.DecisionID]:
770        """
771        Works like `resolveDecision`, except that it returns a set of
772        decision IDs. Where `resolveDecision` would raise an
773        `AmbiguousDecisionSpecifierError`, it instead returns a set with
774        multiple IDs. Where `resolveDecision` would raise a
775        `MissingDecisionError`, it instead returns an empty set.
776
777        Examples:
778
779        >>> g = DecisionGraph()
780        >>> g.addDecision('A')
781        0
782        >>> g.addDecision('B')
783        1
784        >>> g.addDecision('C')
785        2
786        >>> g.addDecision('A')
787        3
788        >>> g.addDecision('B', 'menu')
789        4
790        >>> g.createZone('Z', 0)
791        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
792 annotations=[])
793        >>> g.createZone('Z2', 0)
794        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
795 annotations=[])
796        >>> g.createZone('Zup', 1)
797        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
798 annotations=[])
799        >>> g.addDecisionToZone(0, 'Z')
800        >>> g.addDecisionToZone(1, 'Z')
801        >>> g.addDecisionToZone(2, 'Z')
802        >>> g.addDecisionToZone(3, 'Z2')
803        >>> g.addZoneToZone('Z', 'Zup')
804        >>> g.addZoneToZone('Z2', 'Zup')
805        >>> g.resolveDecisions(1)
806        {1}
807        >>> g.resolveDecisions('A')
808        {0, 3}
809        >>> g.resolveDecisions('B')
810        {1, 4}
811        >>> g.resolveDecisions('C')
812        {2}
813        >>> g.resolveDecisions('A', 'Z')
814        {0}
815        >>> g.resolveDecisions('A', zoneHint='Z2')
816        {3}
817        >>> g.resolveDecisions('B', domainHint='main')
818        {1}
819        >>> g.resolveDecisions('B', None, 'menu')
820        {4}
821        >>> g.resolveDecisions('B', zoneHint='Z2')
822        set()
823        >>> g.resolveDecisions('A', domainHint='menu')
824        set()
825        >>> g.resolveDecisions('A', domainHint='madeup')
826        set()
827        >>> g.resolveDecisions('A', zoneHint='madeup')
828        set()
829        >>> g.resolveDecisions(17)
830        set()
831        """
832        # Parse it to either an ID or specifier if it's a string:
833        if isinstance(spec, str):
834            try:
835                spec = int(spec)
836            except ValueError:
837                pass
838
839        # If it's an ID, check for existence:
840        if isinstance(spec, base.DecisionID):
841            if spec in self:
842                return { spec }
843            else:
844                return set()
845        else:
846            if isinstance(spec, base.DecisionName):
847                spec = base.DecisionSpecifier(
848                    domain=None,
849                    zone=None,
850                    name=spec
851                )
852            elif not isinstance(spec, base.DecisionSpecifier):
853                raise TypeError(
854                    f"Specification is not provided as a"
855                    f" DecisionSpecifier or other valid type. (got type"
856                    f" {type(spec)})."
857                )
858
859            # Merge domain hints from spec/args
860            if (
861                spec.domain is not None
862            and domainHint is not None
863            and spec.domain != domainHint
864            ):
865                raise ValueError(
866                    f"Specifier {repr(spec)} includes domain hint"
867                    f" {repr(spec.domain)} which is incompatible with"
868                    f" explicit domain hint {repr(domainHint)}."
869                )
870            else:
871                domainHint = spec.domain or domainHint
872
873            # Merge zone hints from spec/args
874            if (
875                spec.zone is not None
876            and zoneHint is not None
877            and spec.zone != zoneHint
878            ):
879                raise ValueError(
880                    f"Specifier {repr(spec)} includes zone hint"
881                    f" {repr(spec.zone)} which is incompatible with"
882                    f" explicit zone hint {repr(zoneHint)}."
883                )
884            else:
885                zoneHint = spec.zone or zoneHint
886
887            if spec.name not in self.nameLookup:
888                return set()
889            else:
890                options = self.nameLookup[spec.name]
891                if len(options) == 0:
892                    return set()
893                return {
894                    opt
895                    for opt in options
896                    if (
897                        domainHint is None
898                     or self.domainFor(opt) == domainHint
899                    ) and (
900                        zoneHint is None
901                     or zoneHint in self.zoneAncestors(opt)
902                    )
903                }

Works like resolveDecision, except that it returns a set of decision IDs. Where resolveDecision would raise an AmbiguousDecisionSpecifierError, it instead returns a set with multiple IDs. Where resolveDecision would raise a MissingDecisionError, it instead returns an empty set.

Examples:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addDecision('A')
3
>>> g.addDecision('B', 'menu')
4
>>> g.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('Zup', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone(0, 'Z')
>>> g.addDecisionToZone(1, 'Z')
>>> g.addDecisionToZone(2, 'Z')
>>> g.addDecisionToZone(3, 'Z2')
>>> g.addZoneToZone('Z', 'Zup')
>>> g.addZoneToZone('Z2', 'Zup')
>>> g.resolveDecisions(1)
{1}
>>> g.resolveDecisions('A')
{0, 3}
>>> g.resolveDecisions('B')
{1, 4}
>>> g.resolveDecisions('C')
{2}
>>> g.resolveDecisions('A', 'Z')
{0}
>>> g.resolveDecisions('A', zoneHint='Z2')
{3}
>>> g.resolveDecisions('B', domainHint='main')
{1}
>>> g.resolveDecisions('B', None, 'menu')
{4}
>>> g.resolveDecisions('B', zoneHint='Z2')
set()
>>> g.resolveDecisions('A', domainHint='menu')
set()
>>> g.resolveDecisions('A', domainHint='madeup')
set()
>>> g.resolveDecisions('A', zoneHint='madeup')
set()
>>> g.resolveDecisions(17)
set()
def resolveDecision( self, spec: Union[int, exploration.base.DecisionSpecifier, str], zoneHint: Optional[str] = None, domainHint: Optional[str] = None) -> int:
 905    def resolveDecision(
 906        self,
 907        spec: base.AnyDecisionSpecifier,
 908        zoneHint: Optional[base.Zone] = None,
 909        domainHint: Optional[base.Domain] = None
 910    ) -> base.DecisionID:
 911        """
 912        Given a decision specifier returns the ID associated with that
 913        decision, or raises an `AmbiguousDecisionSpecifierError` or a
 914        `MissingDecisionError` if the specified decision is either
 915        missing or ambiguous. Cannot handle strings that contain domain
 916        and/or zone parts; use
 917        `parsing.ParseFormat.parseDecisionSpecifier` to turn such
 918        strings into `DecisionSpecifier`s if you need to first.
 919
 920        Examples:
 921
 922        >>> g = DecisionGraph()
 923        >>> g.addDecision('A')
 924        0
 925        >>> g.addDecision('B')
 926        1
 927        >>> g.addDecision('C')
 928        2
 929        >>> g.addDecision('A')
 930        3
 931        >>> g.addDecision('B', 'menu')
 932        4
 933        >>> g.createZone('Z', 0)
 934        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 935 annotations=[])
 936        >>> g.createZone('Z2', 0)
 937        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 938 annotations=[])
 939        >>> g.createZone('Zup', 1)
 940        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 941 annotations=[])
 942        >>> g.addDecisionToZone(0, 'Z')
 943        >>> g.addDecisionToZone(1, 'Z')
 944        >>> g.addDecisionToZone(2, 'Z')
 945        >>> g.addDecisionToZone(3, 'Z2')
 946        >>> g.addZoneToZone('Z', 'Zup')
 947        >>> g.addZoneToZone('Z2', 'Zup')
 948        >>> g.resolveDecision(1)
 949        1
 950        >>> g.resolveDecision('A')
 951        Traceback (most recent call last):
 952        ...
 953        exploration.core.AmbiguousDecisionSpecifierError...
 954        >>> g.resolveDecision('B')
 955        Traceback (most recent call last):
 956        ...
 957        exploration.core.AmbiguousDecisionSpecifierError...
 958        >>> g.resolveDecision('C')
 959        2
 960        >>> g.resolveDecision('A', 'Z')
 961        0
 962        >>> g.resolveDecision('A', zoneHint='Z2')
 963        3
 964        >>> g.resolveDecision('B', domainHint='main')
 965        1
 966        >>> g.resolveDecision('B', None, 'menu')
 967        4
 968        >>> g.resolveDecision('B', zoneHint='Z2')
 969        Traceback (most recent call last):
 970        ...
 971        exploration.core.MissingDecisionError...
 972        >>> g.resolveDecision('A', domainHint='menu')
 973        Traceback (most recent call last):
 974        ...
 975        exploration.core.MissingDecisionError...
 976        >>> g.resolveDecision('A', domainHint='madeup')
 977        Traceback (most recent call last):
 978        ...
 979        exploration.core.MissingDecisionError...
 980        >>> g.resolveDecision('A', zoneHint='madeup')
 981        Traceback (most recent call last):
 982        ...
 983        exploration.core.MissingDecisionError...
 984        """
 985        options = self.resolveDecisions(spec, zoneHint, domainHint)
 986        if len(options) == 0:  # zero options: decision doesn't exist
 987            if (
 988                (isinstance(spec, str) and spec.isdigit())
 989             or isinstance(spec, int)
 990            ):
 991                raise MissingDecisionError(
 992                    f"There is no decision with ID {int(spec)}."
 993                )
 994            elif isinstance(spec, str):
 995                if spec not in self.nameLookup:
 996                    raise MissingDecisionError(
 997                        f"There is no decision named {spec!r}."
 998                    )
 999                else:
1000                    filterDesc = ""
1001                    if domainHint is not None:
1002                        filterDesc += f" in domain {repr(domainHint)}"
1003                    if zoneHint is not None:
1004                        filterDesc += f" in zone {repr(zoneHint)}"
1005                    raise MissingDecisionError(
1006                        f"There is at least one decision named {spec!r},"
1007                        f" but there are none {filterDesc}."
1008                    )
1009            else:
1010                assert isinstance(spec, base.DecisionSpecifier)
1011                if spec.name not in self.nameLookup:
1012                    raise MissingDecisionError(
1013                        f"There is no decision named {spec.name!r}."
1014                    )
1015                else:
1016                    filterDesc = ""
1017                    domainHint = domainHint or spec.domain
1018                    zoneHint = zoneHint or spec.zone
1019                    if domainHint is not None:
1020                        filterDesc += f" in domain {repr(domainHint)}"
1021                    if zoneHint is not None:
1022                        filterDesc += f" in zone {repr(zoneHint)}"
1023                    raise MissingDecisionError(
1024                        f"There is at least one decision matching {spec!r},"
1025                        f" but there are none {filterDesc}."
1026                    )
1027        elif len(options) > 1:  # multiple options: specifier was ambiguous
1028            assert not isinstance(spec, int)  # couldn't be ambiguous
1029            if isinstance(spec, str):
1030                assert not spec.isdigit()  # couldn't be ambiguous
1031                raise AmbiguousDecisionSpecifierError(
1032                    f"There are {len(options)} decisions named"
1033                    f" {repr(spec)}."
1034                )
1035            else:
1036                assert isinstance(spec, base.DecisionSpecifier)
1037                filterDesc = ""
1038                domainHint = domainHint or spec.domain
1039                zoneHint = zoneHint or spec.zone
1040                if domainHint is not None:
1041                    filterDesc += f" in domain {repr(domainHint)}"
1042                if zoneHint is not None:
1043                    filterDesc += f" in zone {repr(zoneHint)}"
1044                raise AmbiguousDecisionSpecifierError(
1045                    f"There are {len(options)} decisions named"
1046                    f" {repr(spec.name)}{filterDesc}."
1047                )
1048        else:  # only 1 option: successfully resolved to unique decision
1049            return list(options)[0]

Given a decision specifier returns the ID associated with that decision, or raises an AmbiguousDecisionSpecifierError or a MissingDecisionError if the specified decision is either missing or ambiguous. Cannot handle strings that contain domain and/or zone parts; use parsing.ParseFormat.parseDecisionSpecifier to turn such strings into DecisionSpecifiers if you need to first.

Examples:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addDecision('A')
3
>>> g.addDecision('B', 'menu')
4
>>> g.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('Zup', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone(0, 'Z')
>>> g.addDecisionToZone(1, 'Z')
>>> g.addDecisionToZone(2, 'Z')
>>> g.addDecisionToZone(3, 'Z2')
>>> g.addZoneToZone('Z', 'Zup')
>>> g.addZoneToZone('Z2', 'Zup')
>>> g.resolveDecision(1)
1
>>> g.resolveDecision('A')
Traceback (most recent call last):
...
AmbiguousDecisionSpecifierError...
>>> g.resolveDecision('B')
Traceback (most recent call last):
...
AmbiguousDecisionSpecifierError...
>>> g.resolveDecision('C')
2
>>> g.resolveDecision('A', 'Z')
0
>>> g.resolveDecision('A', zoneHint='Z2')
3
>>> g.resolveDecision('B', domainHint='main')
1
>>> g.resolveDecision('B', None, 'menu')
4
>>> g.resolveDecision('B', zoneHint='Z2')
Traceback (most recent call last):
...
MissingDecisionError...
>>> g.resolveDecision('A', domainHint='menu')
Traceback (most recent call last):
...
MissingDecisionError...
>>> g.resolveDecision('A', domainHint='madeup')
Traceback (most recent call last):
...
MissingDecisionError...
>>> g.resolveDecision('A', zoneHint='madeup')
Traceback (most recent call last):
...
MissingDecisionError...
def getDecision( self, decision: Union[int, exploration.base.DecisionSpecifier, str], zoneHint: Optional[str] = None, domainHint: Optional[str] = None) -> Optional[int]:
1051    def getDecision(
1052        self,
1053        decision: base.AnyDecisionSpecifier,
1054        zoneHint: Optional[base.Zone] = None,
1055        domainHint: Optional[base.Domain] = None
1056    ) -> Optional[base.DecisionID]:
1057        """
1058        Works like `resolveDecision` but returns None instead of raising
1059        a `MissingDecisionError` if the specified decision isn't listed.
1060        May still raise an `AmbiguousDecisionSpecifierError`.
1061        """
1062        try:
1063            return self.resolveDecision(
1064                decision,
1065                zoneHint,
1066                domainHint
1067            )
1068        except MissingDecisionError:
1069            return None

Works like resolveDecision but returns None instead of raising a MissingDecisionError if the specified decision isn't listed. May still raise an AmbiguousDecisionSpecifierError.

def nameFor( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> str:
1071    def nameFor(
1072        self,
1073        decision: base.AnyDecisionSpecifier
1074    ) -> base.DecisionName:
1075        """
1076        Returns the name of the specified decision. Note that names are
1077        not necessarily unique.
1078
1079        Example:
1080
1081        >>> d = DecisionGraph()
1082        >>> d.addDecision('A')
1083        0
1084        >>> d.addDecision('B')
1085        1
1086        >>> d.addDecision('B')
1087        2
1088        >>> d.nameFor(0)
1089        'A'
1090        >>> d.nameFor(1)
1091        'B'
1092        >>> d.nameFor(2)
1093        'B'
1094        >>> d.nameFor(3)
1095        Traceback (most recent call last):
1096        ...
1097        exploration.core.MissingDecisionError...
1098        """
1099        dID = self.resolveDecision(decision)
1100        return self.nodes[dID]['name']

Returns the name of the specified decision. Note that names are not necessarily unique.

Example:

>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addDecision('B')
2
>>> d.nameFor(0)
'A'
>>> d.nameFor(1)
'B'
>>> d.nameFor(2)
'B'
>>> d.nameFor(3)
Traceback (most recent call last):
...
MissingDecisionError...
def shortIdentity( self, decision: Union[int, exploration.base.DecisionSpecifier, str, NoneType], includeZones: bool = True, alwaysDomain: Optional[bool] = None):
1102    def shortIdentity(
1103        self,
1104        decision: Optional[base.AnyDecisionSpecifier],
1105        includeZones: bool = True,
1106        alwaysDomain: Optional[bool] = None
1107    ):
1108        """
1109        Returns a string containing the name for the given decision,
1110        prefixed by its level-0 zone(s) and domain. If the value provided
1111        is `None`, it returns the string "(nowhere)". This is not
1112        necessarily unique.
1113
1114        If `includeZones` is true (the default) then zone information
1115        is included before the decision name.
1116
1117        If `alwaysDomain` is true or false, then the domain information
1118        will always (or never) be included. If it's `None` (the default)
1119        then domain info will only be included for decisions which are
1120        not in the default domain.
1121
1122        This string is NOT necessarily valid input to
1123        `parsing.ParseFormat.parseDecisionSpecifier` (see
1124        `journal.JournalObserver.identifyingString` for a function that
1125        can generate that).
1126        """
1127        if decision is None:
1128            return "(nowhere)"
1129        else:
1130            dID = self.resolveDecision(decision)
1131            thisDomain = self.domainFor(dID)
1132            dSpec = ''
1133            zSpec = ''
1134            if (
1135                alwaysDomain is True
1136             or (
1137                    alwaysDomain is None
1138                and thisDomain != base.DEFAULT_DOMAIN
1139                )
1140            ):
1141                dSpec = thisDomain + '//'  # TODO: Don't hardcode this?
1142            if includeZones:
1143                zones = [
1144                    z
1145                    for z in self.zoneParents(dID)
1146                    if self.zones[z].level == 0
1147                ]
1148                if len(zones) == 1:
1149                    zSpec = zones[0] + '::'  # TODO: Don't hardcode this?
1150                elif len(zones) > 1:
1151                    zSpec = '[' + ', '.join(sorted(zones)) + ']::'
1152                # else leave zSpec empty
1153
1154            return f"{dSpec}{zSpec}{self.nameFor(dID)}"

Returns a string containing the name for the given decision, prefixed by its level-0 zone(s) and domain. If the value provided is None, it returns the string "(nowhere)". This is not necessarily unique.

If includeZones is true (the default) then zone information is included before the decision name.

If alwaysDomain is true or false, then the domain information will always (or never) be included. If it's None (the default) then domain info will only be included for decisions which are not in the default domain.

This string is NOT necessarily valid input to parsing.ParseFormat.parseDecisionSpecifier (see journal.JournalObserver.identifyingString for a function that can generate that).

def identityOf( self, decision: Union[int, exploration.base.DecisionSpecifier, str, NoneType], includeZones: bool = True, alwaysDomain: Optional[bool] = None) -> str:
1156    def identityOf(
1157        self,
1158        decision: Optional[base.AnyDecisionSpecifier],
1159        includeZones: bool = True,
1160        alwaysDomain: Optional[bool] = None
1161    ) -> str:
1162        """
1163        Returns the given node's ID, plus its `shortIdentity` in
1164        parentheses. Arguments are passed through to `shortIdentity`.
1165        """
1166        if decision is None:
1167            return "(nowhere)"
1168        else:
1169            dID = self.resolveDecision(decision)
1170            short = self.shortIdentity(decision, includeZones, alwaysDomain)
1171            return f"{dID} ({short})"

Returns the given node's ID, plus its shortIdentity in parentheses. Arguments are passed through to shortIdentity.

def namesListing( self, decisions: Collection[int], includeZones: bool = True, indent: int = 2) -> str:
1173    def namesListing(
1174        self,
1175        decisions: Collection[base.DecisionID],
1176        includeZones: bool = True,
1177        indent: int = 2
1178    ) -> str:
1179        """
1180        Returns a multi-line string containing an indented listing of
1181        the provided decision IDs with their names in parentheses after
1182        each. Useful for debugging & error messages.
1183
1184        Includes level-0 zones where applicable, with a zone separator
1185        before the decision, unless `includeZones` is set to False. Where
1186        there are multiple level-0 zones, they're listed together in
1187        brackets.
1188
1189        Uses the string '(none)' when there are no decisions are in the
1190        list.
1191
1192        Set `indent` to something other than 2 to control how much
1193        indentation is added.
1194
1195        For example:
1196
1197        >>> g = DecisionGraph()
1198        >>> g.addDecision('A')
1199        0
1200        >>> g.addDecision('B')
1201        1
1202        >>> g.addDecision('C')
1203        2
1204        >>> g.namesListing(['A', 'C', 'B'])
1205        '  0 (A)\\n  2 (C)\\n  1 (B)\\n'
1206        >>> g.namesListing([])
1207        '  (none)\\n'
1208        >>> g.createZone('zone', 0)
1209        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1210 annotations=[])
1211        >>> g.createZone('zone2', 0)
1212        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1213 annotations=[])
1214        >>> g.createZone('zoneUp', 1)
1215        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
1216 annotations=[])
1217        >>> g.addDecisionToZone(0, 'zone')
1218        >>> g.addDecisionToZone(1, 'zone')
1219        >>> g.addDecisionToZone(1, 'zone2')
1220        >>> g.addDecisionToZone(2, 'zoneUp')  # won't be listed: it's level-1
1221        >>> g.namesListing(['A', 'C', 'B'])
1222        '  0 (zone::A)\\n  2 (C)\\n  1 ([zone, zone2]::B)\\n'
1223        """
1224        ind = ' ' * indent
1225        if len(decisions) == 0:
1226            return ind + '(none)\n'
1227        else:
1228            result = ''
1229            for dID in decisions:
1230                result += ind + self.identityOf(dID, includeZones) + '\n'
1231            return result

Returns a multi-line string containing an indented listing of the provided decision IDs with their names in parentheses after each. Useful for debugging & error messages.

Includes level-0 zones where applicable, with a zone separator before the decision, unless includeZones is set to False. Where there are multiple level-0 zones, they're listed together in brackets.

Uses the string '(none)' when there are no decisions are in the list.

Set indent to something other than 2 to control how much indentation is added.

For example:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.namesListing(['A', 'C', 'B'])
'  0 (A)\n  2 (C)\n  1 (B)\n'
>>> g.namesListing([])
'  (none)\n'
>>> g.createZone('zone', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zone2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zoneUp', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone(0, 'zone')
>>> g.addDecisionToZone(1, 'zone')
>>> g.addDecisionToZone(1, 'zone2')
>>> g.addDecisionToZone(2, 'zoneUp')  # won't be listed: it's level-1
>>> g.namesListing(['A', 'C', 'B'])
'  0 (zone::A)\n  2 (C)\n  1 ([zone, zone2]::B)\n'
def destinationsListing( self, destinations: Dict[str, int], includeZones: bool = True, indent: int = 2) -> str:
1233    def destinationsListing(
1234        self,
1235        destinations: Dict[base.Transition, base.DecisionID],
1236        includeZones: bool = True,
1237        indent: int = 2
1238    ) -> str:
1239        """
1240        Returns a multi-line string containing an indented listing of
1241        the provided transitions along with their destinations and the
1242        names of those destinations in parentheses. Useful for debugging
1243        & error messages. (Use e.g., `destinationsFrom` to get a
1244        transitions -> destinations dictionary in the required format.)
1245
1246        Uses the string '(no transitions)' when there are no transitions
1247        in the dictionary.
1248
1249        Set `indent` to something other than 2 to control how much
1250        indentation is added.
1251
1252        For example:
1253
1254        >>> g = DecisionGraph()
1255        >>> g.addDecision('A')
1256        0
1257        >>> g.addDecision('B')
1258        1
1259        >>> g.addDecision('C')
1260        2
1261        >>> g.addTransition('A', 'north', 'B', 'south')
1262        >>> g.addTransition('B', 'east', 'C', 'west')
1263        >>> g.addTransition('C', 'southwest', 'A', 'northeast')
1264        >>> g.destinationsListing(g.destinationsFrom('A'))
1265        '  north to 1 (B)\\n  northeast to 2 (C)\\n'
1266        >>> g.destinationsListing(g.destinationsFrom('B'))
1267        '  south to 0 (A)\\n  east to 2 (C)\\n'
1268        >>> g.destinationsListing({})
1269        '  (none)\\n'
1270        >>> g.createZone('zone', 0)
1271        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1272 annotations=[])
1273        >>> g.addDecisionToZone(0, 'zone')
1274        >>> g.destinationsListing(g.destinationsFrom('B'))
1275        '  south to 0 (zone::A)\\n  east to 2 (C)\\n'
1276        """
1277        ind = ' ' * indent
1278        if len(destinations) == 0:
1279            return ind + '(none)\n'
1280        else:
1281            result = ''
1282            for transition, dID in destinations.items():
1283                line = f"{transition} to {self.identityOf(dID, includeZones)}"
1284                result += ind + line + '\n'
1285            return result

Returns a multi-line string containing an indented listing of the provided transitions along with their destinations and the names of those destinations in parentheses. Useful for debugging & error messages. (Use e.g., destinationsFrom to get a transitions -> destinations dictionary in the required format.)

Uses the string '(no transitions)' when there are no transitions in the dictionary.

Set indent to something other than 2 to control how much indentation is added.

For example:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addTransition('A', 'north', 'B', 'south')
>>> g.addTransition('B', 'east', 'C', 'west')
>>> g.addTransition('C', 'southwest', 'A', 'northeast')
>>> g.destinationsListing(g.destinationsFrom('A'))
'  north to 1 (B)\n  northeast to 2 (C)\n'
>>> g.destinationsListing(g.destinationsFrom('B'))
'  south to 0 (A)\n  east to 2 (C)\n'
>>> g.destinationsListing({})
'  (none)\n'
>>> g.createZone('zone', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone(0, 'zone')
>>> g.destinationsListing(g.destinationsFrom('B'))
'  south to 0 (zone::A)\n  east to 2 (C)\n'
def domainFor( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> str:
1287    def domainFor(self, decision: base.AnyDecisionSpecifier) -> base.Domain:
1288        """
1289        Returns the domain that a decision belongs to.
1290        """
1291        dID = self.resolveDecision(decision)
1292        return self.nodes[dID]['domain']

Returns the domain that a decision belongs to.

def allDecisionsInDomain(self, domain: str) -> Set[int]:
1294    def allDecisionsInDomain(
1295        self,
1296        domain: base.Domain
1297    ) -> Set[base.DecisionID]:
1298        """
1299        Returns the set of all `DecisionID`s for decisions in the
1300        specified domain.
1301        """
1302        return set(dID for dID in self if self.nodes[dID]['domain'] == domain)

Returns the set of all DecisionIDs for decisions in the specified domain.

def destination( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str) -> int:
1304    def destination(
1305        self,
1306        decision: base.AnyDecisionSpecifier,
1307        transition: base.Transition
1308    ) -> base.DecisionID:
1309        """
1310        Overrides base `UniqueExitsGraph.destination` to raise
1311        `MissingDecisionError` or `MissingTransitionError` as
1312        appropriate, and to work with an `AnyDecisionSpecifier`.
1313        """
1314        dID = self.resolveDecision(decision)
1315        try:
1316            return super().destination(dID, transition)
1317        except KeyError:
1318            raise MissingTransitionError(
1319                f"Transition {transition!r} does not exist at decision"
1320                f" {self.identityOf(dID)}."
1321            )

Overrides base UniqueExitsGraph.destination to raise MissingDecisionError or MissingTransitionError as appropriate, and to work with an AnyDecisionSpecifier.

def getDestination( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, default: Any = None) -> Optional[int]:
1323    def getDestination(
1324        self,
1325        decision: base.AnyDecisionSpecifier,
1326        transition: base.Transition,
1327        default: Any = None
1328    ) -> Optional[base.DecisionID]:
1329        """
1330        Overrides base `UniqueExitsGraph.getDestination` with different
1331        argument names, since those matter for the edit DSL.
1332        """
1333        dID = self.resolveDecision(decision)
1334        return super().getDestination(dID, transition)

Overrides base UniqueExitsGraph.getDestination with different argument names, since those matter for the edit DSL.

def destinationsFrom( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> Dict[str, int]:
1336    def destinationsFrom(
1337        self,
1338        decision: base.AnyDecisionSpecifier
1339    ) -> Dict[base.Transition, base.DecisionID]:
1340        """
1341        Override that just changes the type of the exception from a
1342        `KeyError` to a `MissingDecisionError` when the source does not
1343        exist.
1344        """
1345        dID = self.resolveDecision(decision)
1346        return super().destinationsFrom(dID)

Override that just changes the type of the exception from a KeyError to a MissingDecisionError when the source does not exist.

def newTransitionNameFrom( self, decision: Union[int, exploration.base.DecisionSpecifier, str], baseName: str) -> str:
1348    def newTransitionNameFrom(
1349        self,
1350        decision: base.AnyDecisionSpecifier,
1351        baseName: base.Transition
1352    ) -> base.Transition:
1353        """
1354        Given a decision and a desired transition name, returns a
1355        transition name that doesn't match any existing transition from
1356        the specified destination. Returns the given name as-is if it
1357        doesn't collide with an existing transition name, otherwise
1358        appends a number to it, starting with 2. Note that a number will
1359        be appended even if the base name already has a number at the
1360        end, so for example if a decision already has 'up' and 'up2' as
1361        options, asking for a new transition based on 'up' will give
1362        'up3', but asking for a new transition based on 'up2' will give
1363        'up22'.
1364
1365        Some examples:
1366
1367        >>> g = DecisionGraph()
1368        >>> g.addDecision('A')
1369        0
1370        >>> g.newTransitionNameFrom('A', 'up')
1371        'up'
1372        >>> g.addDecision('B')
1373        1
1374        >>> g.addTransition('A', 'up', 'B')
1375        >>> g.newTransitionNameFrom('A', 'up')
1376        'up2'
1377        >>> g.addTransition('A', 'up2', 'B')
1378        >>> g.newTransitionNameFrom('A', 'up')
1379        'up3'
1380        >>> g.newTransitionNameFrom('A', 'up2')  # suffixes not parsed
1381        'up22'
1382        """
1383        already = self.destinationsFrom(decision)
1384        candidate = baseName
1385        i = 2
1386        while candidate in already:
1387            candidate = baseName + str(i)
1388            i += 1
1389        return candidate 

Given a decision and a desired transition name, returns a transition name that doesn't match any existing transition from the specified destination. Returns the given name as-is if it doesn't collide with an existing transition name, otherwise appends a number to it, starting with 2. Note that a number will be appended even if the base name already has a number at the end, so for example if a decision already has 'up' and 'up2' as options, asking for a new transition based on 'up' will give 'up3', but asking for a new transition based on 'up2' will give 'up22'.

Some examples:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.newTransitionNameFrom('A', 'up')
'up'
>>> g.addDecision('B')
1
>>> g.addTransition('A', 'up', 'B')
>>> g.newTransitionNameFrom('A', 'up')
'up2'
>>> g.addTransition('A', 'up2', 'B')
>>> g.newTransitionNameFrom('A', 'up')
'up3'
>>> g.newTransitionNameFrom('A', 'up2')  # suffixes not parsed
'up22'
def bothEnds( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str) -> Set[int]:
1391    def bothEnds(
1392        self,
1393        decision: base.AnyDecisionSpecifier,
1394        transition: base.Transition
1395    ) -> Set[base.DecisionID]:
1396        """
1397        Returns a set containing the `DecisionID`(s) for both the start
1398        and end of the specified transition. Raises a
1399        `MissingDecisionError` or `MissingTransitionError`if the
1400        specified decision and/or transition do not exist.
1401
1402        Note that for actions since the source and destination are the
1403        same, the set will have only one element.
1404        """
1405        dID = self.resolveDecision(decision)
1406        result = {dID}
1407        dest = self.destination(dID, transition)
1408        if dest is not None:
1409            result.add(dest)
1410        return result

Returns a set containing the DecisionID(s) for both the start and end of the specified transition. Raises a MissingDecisionError or MissingTransitionErrorif the specified decision and/or transition do not exist.

Note that for actions since the source and destination are the same, the set will have only one element.

def decisionActions( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> Set[str]:
1412    def decisionActions(
1413        self,
1414        decision: base.AnyDecisionSpecifier
1415    ) -> Set[base.Transition]:
1416        """
1417        Retrieves the set of self-edges at a decision. Editing the set
1418        will not affect the graph.
1419
1420        Example:
1421
1422        >>> g = DecisionGraph()
1423        >>> g.addDecision('A')
1424        0
1425        >>> g.addDecision('B')
1426        1
1427        >>> g.addDecision('C')
1428        2
1429        >>> g.addAction('A', 'action1')
1430        >>> g.addAction('A', 'action2')
1431        >>> g.addAction('B', 'action3')
1432        >>> sorted(g.decisionActions('A'))
1433        ['action1', 'action2']
1434        >>> g.decisionActions('B')
1435        {'action3'}
1436        >>> g.decisionActions('C')
1437        set()
1438        """
1439        result = set()
1440        dID = self.resolveDecision(decision)
1441        for transition, dest in self.destinationsFrom(dID).items():
1442            if dest == dID:
1443                result.add(transition)
1444        return result

Retrieves the set of self-edges at a decision. Editing the set will not affect the graph.

Example:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addAction('A', 'action1')
>>> g.addAction('A', 'action2')
>>> g.addAction('B', 'action3')
>>> sorted(g.decisionActions('A'))
['action1', 'action2']
>>> g.decisionActions('B')
{'action3'}
>>> g.decisionActions('C')
set()
def getTransitionProperties( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str) -> TransitionProperties:
1446    def getTransitionProperties(
1447        self,
1448        decision: base.AnyDecisionSpecifier,
1449        transition: base.Transition
1450    ) -> TransitionProperties:
1451        """
1452        Returns a dictionary containing transition properties for the
1453        specified transition from the specified decision. The properties
1454        included are:
1455
1456        - 'requirement': The requirement for the transition.
1457        - 'consequence': Any consequence of the transition.
1458        - 'tags': Any tags applied to the transition.
1459        - 'annotations': Any annotations on the transition.
1460
1461        The reciprocal of the transition is not included.
1462
1463        The result is a clone of the stored properties; edits to the
1464        dictionary will NOT modify the graph.
1465        """
1466        dID = self.resolveDecision(decision)
1467        dest = self.destination(dID, transition)
1468
1469        info: TransitionProperties = copy.deepcopy(
1470            self.edges[dID, dest, transition]  # type:ignore
1471        )
1472        return {
1473            'requirement': info.get('requirement', base.ReqNothing()),
1474            'consequence': info.get('consequence', []),
1475            'tags': info.get('tags', {}),
1476            'annotations': info.get('annotations', [])
1477        }

Returns a dictionary containing transition properties for the specified transition from the specified decision. The properties included are:

  • 'requirement': The requirement for the transition.
  • 'consequence': Any consequence of the transition.
  • 'tags': Any tags applied to the transition.
  • 'annotations': Any annotations on the transition.

The reciprocal of the transition is not included.

The result is a clone of the stored properties; edits to the dictionary will NOT modify the graph.

def setTransitionProperties( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, requirement: Optional[exploration.base.Requirement] = None, consequence: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None, tags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, annotations: Optional[List[str]] = None) -> None:
1479    def setTransitionProperties(
1480        self,
1481        decision: base.AnyDecisionSpecifier,
1482        transition: base.Transition,
1483        requirement: Optional[base.Requirement] = None,
1484        consequence: Optional[base.Consequence] = None,
1485        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
1486        annotations: Optional[List[base.Annotation]] = None
1487    ) -> None:
1488        """
1489        Sets one or more transition properties all at once. Can be used
1490        to set the requirement, consequence, tags, and/or annotations.
1491        Old values are overwritten, although if `None`s are provided (or
1492        arguments are omitted), corresponding properties are not
1493        updated.
1494
1495        To add tags or annotations to existing tags/annotations instead
1496        of replacing them, use `tagTransition` or `annotateTransition`
1497        instead.
1498        """
1499        dID = self.resolveDecision(decision)
1500        if requirement is not None:
1501            self.setTransitionRequirement(dID, transition, requirement)
1502        if consequence is not None:
1503            self.setConsequence(dID, transition, consequence)
1504        if tags is not None:
1505            dest = self.destination(dID, transition)
1506            # TODO: Submit pull request to update MultiDiGraph stubs in
1507            # types-networkx to include OutMultiEdgeView that accepts
1508            # from/to/key tuples as indices.
1509            info = cast(
1510                TransitionProperties,
1511                self.edges[dID, dest, transition]  # type:ignore
1512            )
1513            info['tags'] = tags
1514        if annotations is not None:
1515            dest = self.destination(dID, transition)
1516            info = cast(
1517                TransitionProperties,
1518                self.edges[dID, dest, transition]  # type:ignore
1519            )
1520            info['annotations'] = annotations

Sets one or more transition properties all at once. Can be used to set the requirement, consequence, tags, and/or annotations. Old values are overwritten, although if Nones are provided (or arguments are omitted), corresponding properties are not updated.

To add tags or annotations to existing tags/annotations instead of replacing them, use tagTransition or annotateTransition instead.

def getTransitionRequirement( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str) -> exploration.base.Requirement:
1522    def getTransitionRequirement(
1523        self,
1524        decision: base.AnyDecisionSpecifier,
1525        transition: base.Transition
1526    ) -> base.Requirement:
1527        """
1528        Returns the `Requirement` for accessing a specific transition at
1529        a specific decision. For transitions which don't have
1530        requirements, returns a `ReqNothing` instance.
1531        """
1532        dID = self.resolveDecision(decision)
1533        dest = self.destination(dID, transition)
1534
1535        info = cast(
1536            TransitionProperties,
1537            self.edges[dID, dest, transition]  # type:ignore
1538        )
1539
1540        return info.get('requirement', base.ReqNothing())

Returns the Requirement for accessing a specific transition at a specific decision. For transitions which don't have requirements, returns a ReqNothing instance.

def setTransitionRequirement( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, requirement: Optional[exploration.base.Requirement]) -> None:
1542    def setTransitionRequirement(
1543        self,
1544        decision: base.AnyDecisionSpecifier,
1545        transition: base.Transition,
1546        requirement: Optional[base.Requirement]
1547    ) -> None:
1548        """
1549        Sets the `Requirement` for accessing a specific transition at
1550        a specific decision. Raises a `KeyError` if the decision or
1551        transition does not exist.
1552
1553        Deletes the requirement if `None` is given as the requirement.
1554
1555        Use `parsing.ParseFormat.parseRequirement` first if you have a
1556        requirement in string format.
1557
1558        Does not raise an error if deletion is requested for a
1559        non-existent requirement, and silently overwrites any previous
1560        requirement.
1561        """
1562        dID = self.resolveDecision(decision)
1563
1564        dest = self.destination(dID, transition)
1565
1566        info = cast(
1567            TransitionProperties,
1568            self.edges[dID, dest, transition]  # type:ignore
1569        )
1570
1571        if requirement is None:
1572            try:
1573                del info['requirement']
1574            except KeyError:
1575                pass
1576        else:
1577            if not isinstance(requirement, base.Requirement):
1578                raise TypeError(
1579                    f"Invalid requirement type: {type(requirement)}"
1580                )
1581
1582            info['requirement'] = requirement

Sets the Requirement for accessing a specific transition at a specific decision. Raises a KeyError if the decision or transition does not exist.

Deletes the requirement if None is given as the requirement.

Use parsing.ParseFormat.parseRequirement first if you have a requirement in string format.

Does not raise an error if deletion is requested for a non-existent requirement, and silently overwrites any previous requirement.

def getConsequence( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str) -> List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]:
1584    def getConsequence(
1585        self,
1586        decision: base.AnyDecisionSpecifier,
1587        transition: base.Transition
1588    ) -> base.Consequence:
1589        """
1590        Retrieves the consequence of a transition.
1591
1592        A `KeyError` is raised if the specified decision/transition
1593        combination doesn't exist.
1594        """
1595        dID = self.resolveDecision(decision)
1596
1597        dest = self.destination(dID, transition)
1598
1599        info = cast(
1600            TransitionProperties,
1601            self.edges[dID, dest, transition]  # type:ignore
1602        )
1603
1604        return info.get('consequence', [])

Retrieves the consequence of a transition.

A KeyError is raised if the specified decision/transition combination doesn't exist.

def addConsequence( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, consequence: List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]) -> Tuple[int, int]:
1606    def addConsequence(
1607        self,
1608        decision: base.AnyDecisionSpecifier,
1609        transition: base.Transition,
1610        consequence: base.Consequence
1611    ) -> Tuple[int, int]:
1612        """
1613        Adds the given `Consequence` to the consequence list for the
1614        specified transition, extending that list at the end. Note that
1615        this does NOT make a copy of the consequence, so it should not
1616        be used to copy consequences from one transition to another
1617        without making a deep copy first.
1618
1619        A `MissingDecisionError` or a `MissingTransitionError` is raised
1620        if the specified decision/transition combination doesn't exist.
1621
1622        Returns a pair of integers indicating the minimum and maximum
1623        depth-first-traversal-indices of the added consequence part(s)
1624        (inclusive).
1625
1626        The outer consequence list itself (index 0) is not counted.
1627
1628        >>> d = DecisionGraph()
1629        >>> d.addDecision('A')
1630        0
1631        >>> d.addDecision('B')
1632        1
1633        >>> d.addTransition('A', 'fwd', 'B', 'rev')
1634        >>> d.addConsequence('A', 'fwd', [base.effect(gain='sword')])
1635        (1, 1)
1636        >>> d.addConsequence('B', 'rev', [base.effect(lose='sword')])
1637        (1, 1)
1638        >>> ef = d.getConsequence('A', 'fwd')
1639        >>> er = d.getConsequence('B', 'rev')
1640        >>> ef == [base.effect(gain='sword')]
1641        True
1642        >>> er == [base.effect(lose='sword')]
1643        True
1644        >>> d.addConsequence('A', 'fwd', [base.effect(deactivate=True)])
1645        (2, 2)
1646        >>> ef = d.getConsequence('A', 'fwd')
1647        >>> ef == [base.effect(gain='sword'), base.effect(deactivate=True)]
1648        True
1649        >>> d.addConsequence(
1650        ...     'A',
1651        ...     'fwd',  # adding to consequence with 3 parts already
1652        ...     [  # outer list not counted because it merges
1653        ...         base.challenge(  # 1 part
1654        ...             None,
1655        ...             0,
1656        ...             [base.effect(gain=('flowers', 3))],  # 2 parts
1657        ...             [base.effect(gain=('flowers', 1))]  # 2 parts
1658        ...         )
1659        ...     ]
1660        ... )  # note indices below are inclusive; indices are 3, 4, 5, 6, 7
1661        (3, 7)
1662        """
1663        dID = self.resolveDecision(decision)
1664
1665        dest = self.destination(dID, transition)
1666
1667        info = cast(
1668            TransitionProperties,
1669            self.edges[dID, dest, transition]  # type:ignore
1670        )
1671
1672        existing = info.setdefault('consequence', [])
1673        startIndex = base.countParts(existing)
1674        existing.extend(consequence)
1675        endIndex = base.countParts(existing) - 1
1676        return (startIndex, endIndex)

Adds the given Consequence to the consequence list for the specified transition, extending that list at the end. Note that this does NOT make a copy of the consequence, so it should not be used to copy consequences from one transition to another without making a deep copy first.

A MissingDecisionError or a MissingTransitionError is raised if the specified decision/transition combination doesn't exist.

Returns a pair of integers indicating the minimum and maximum depth-first-traversal-indices of the added consequence part(s) (inclusive).

The outer consequence list itself (index 0) is not counted.

>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addTransition('A', 'fwd', 'B', 'rev')
>>> d.addConsequence('A', 'fwd', [base.effect(gain='sword')])
(1, 1)
>>> d.addConsequence('B', 'rev', [base.effect(lose='sword')])
(1, 1)
>>> ef = d.getConsequence('A', 'fwd')
>>> er = d.getConsequence('B', 'rev')
>>> ef == [base.effect(gain='sword')]
True
>>> er == [base.effect(lose='sword')]
True
>>> d.addConsequence('A', 'fwd', [base.effect(deactivate=True)])
(2, 2)
>>> ef = d.getConsequence('A', 'fwd')
>>> ef == [base.effect(gain='sword'), base.effect(deactivate=True)]
True
>>> d.addConsequence(
...     'A',
...     'fwd',  # adding to consequence with 3 parts already
...     [  # outer list not counted because it merges
...         base.challenge(  # 1 part
...             None,
...             0,
...             [base.effect(gain=('flowers', 3))],  # 2 parts
...             [base.effect(gain=('flowers', 1))]  # 2 parts
...         )
...     ]
... )  # note indices below are inclusive; indices are 3, 4, 5, 6, 7
(3, 7)
def setConsequence( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, consequence: List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]) -> None:
1678    def setConsequence(
1679        self,
1680        decision: base.AnyDecisionSpecifier,
1681        transition: base.Transition,
1682        consequence: base.Consequence
1683    ) -> None:
1684        """
1685        Replaces the transition consequence for the given transition at
1686        the given decision. Any previous consequence is discarded. See
1687        `Consequence` for the structure of these. Note that this does
1688        NOT make a copy of the consequence, do that first to avoid
1689        effect-entanglement if you're copying a consequence.
1690
1691        A `MissingDecisionError` or a `MissingTransitionError` is raised
1692        if the specified decision/transition combination doesn't exist.
1693        """
1694        dID = self.resolveDecision(decision)
1695
1696        dest = self.destination(dID, transition)
1697
1698        info = cast(
1699            TransitionProperties,
1700            self.edges[dID, dest, transition]  # type:ignore
1701        )
1702
1703        info['consequence'] = consequence

Replaces the transition consequence for the given transition at the given decision. Any previous consequence is discarded. See Consequence for the structure of these. Note that this does NOT make a copy of the consequence, do that first to avoid effect-entanglement if you're copying a consequence.

A MissingDecisionError or a MissingTransitionError is raised if the specified decision/transition combination doesn't exist.

def addEquivalence( self, requirement: exploration.base.Requirement, capabilityOrMechanismState: Union[str, Tuple[int, str]]) -> None:
1705    def addEquivalence(
1706        self,
1707        requirement: base.Requirement,
1708        capabilityOrMechanismState: Union[
1709            base.Capability,
1710            Tuple[base.MechanismID, base.MechanismState]
1711        ]
1712    ) -> None:
1713        """
1714        Adds the given requirement as an equivalence for the given
1715        capability or the given mechanism state. Note that having a
1716        capability via an equivalence does not count as actually having
1717        that capability; it only counts for the purpose of satisfying
1718        `Requirement`s.
1719
1720        Note also that because a mechanism-based requirement looks up
1721        the specific mechanism locally based on a name, an equivalence
1722        defined in one location may affect mechanism requirements in
1723        other locations unless the mechanism name in the requirement is
1724        zone-qualified to be specific. But in such situations the base
1725        mechanism would have caused issues in any case.
1726        """
1727        self.equivalences.setdefault(
1728            capabilityOrMechanismState,
1729            set()
1730        ).add(requirement)

Adds the given requirement as an equivalence for the given capability or the given mechanism state. Note that having a capability via an equivalence does not count as actually having that capability; it only counts for the purpose of satisfying Requirements.

Note also that because a mechanism-based requirement looks up the specific mechanism locally based on a name, an equivalence defined in one location may affect mechanism requirements in other locations unless the mechanism name in the requirement is zone-qualified to be specific. But in such situations the base mechanism would have caused issues in any case.

def removeEquivalence( self, requirement: exploration.base.Requirement, capabilityOrMechanismState: Union[str, Tuple[int, str]]) -> None:
1732    def removeEquivalence(
1733        self,
1734        requirement: base.Requirement,
1735        capabilityOrMechanismState: Union[
1736            base.Capability,
1737            Tuple[base.MechanismID, base.MechanismState]
1738        ]
1739    ) -> None:
1740        """
1741        Removes an equivalence. Raises a `KeyError` if no such
1742        equivalence existed.
1743        """
1744        self.equivalences[capabilityOrMechanismState].remove(requirement)

Removes an equivalence. Raises a KeyError if no such equivalence existed.

def hasAnyEquivalents(self, capabilityOrMechanismState: Union[str, Tuple[int, str]]) -> bool:
1746    def hasAnyEquivalents(
1747        self,
1748        capabilityOrMechanismState: Union[
1749            base.Capability,
1750            Tuple[base.MechanismID, base.MechanismState]
1751        ]
1752    ) -> bool:
1753        """
1754        Returns `True` if the given capability or mechanism state has at
1755        least one equivalence.
1756        """
1757        return capabilityOrMechanismState in self.equivalences

Returns True if the given capability or mechanism state has at least one equivalence.

def allEquivalents( self, capabilityOrMechanismState: Union[str, Tuple[int, str]]) -> Set[exploration.base.Requirement]:
1759    def allEquivalents(
1760        self,
1761        capabilityOrMechanismState: Union[
1762            base.Capability,
1763            Tuple[base.MechanismID, base.MechanismState]
1764        ]
1765    ) -> Set[base.Requirement]:
1766        """
1767        Returns the set of equivalences for the given capability. This is
1768        a live set which may be modified (it's probably better to use
1769        `addEquivalence` and `removeEquivalence` instead...).
1770        """
1771        return self.equivalences.setdefault(
1772            capabilityOrMechanismState,
1773            set()
1774        )

Returns the set of equivalences for the given capability. This is a live set which may be modified (it's probably better to use addEquivalence and removeEquivalence instead...).

def reversionType(self, name: str, equivalentTo: Set[str]) -> None:
1776    def reversionType(self, name: str, equivalentTo: Set[str]) -> None:
1777        """
1778        Specifies a new reversion type, so that when used in a reversion
1779        aspects set with a colon before the name, all items in the
1780        `equivalentTo` value will be added to that set. These may
1781        include other custom reversion type names (with the colon) but
1782        take care not to create an equivalence loop which would result
1783        in a crash.
1784
1785        If you re-use the same name, it will override the old equivalence
1786        for that name.
1787        """
1788        self.reversionTypes[name] = equivalentTo

Specifies a new reversion type, so that when used in a reversion aspects set with a colon before the name, all items in the equivalentTo value will be added to that set. These may include other custom reversion type names (with the colon) but take care not to create an equivalence loop which would result in a crash.

If you re-use the same name, it will override the old equivalence for that name.

def addAction( self, decision: Union[int, exploration.base.DecisionSpecifier, str], action: str, requires: Optional[exploration.base.Requirement] = None, consequence: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None, tags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, annotations: Optional[List[str]] = None) -> None:
1790    def addAction(
1791        self,
1792        decision: base.AnyDecisionSpecifier,
1793        action: base.Transition,
1794        requires: Optional[base.Requirement] = None,
1795        consequence: Optional[base.Consequence] = None,
1796        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
1797        annotations: Optional[List[base.Annotation]] = None,
1798    ) -> None:
1799        """
1800        Adds the given action as a possibility at the given decision. An
1801        action is just a self-edge, which can have requirements like any
1802        edge, and which can have consequences like any edge.
1803        The optional arguments are given to `setTransitionRequirement`
1804        and `setConsequence`; see those functions for descriptions
1805        of what they mean.
1806
1807        Raises a `KeyError` if a transition with the given name already
1808        exists at the given decision.
1809        """
1810        if tags is None:
1811            tags = {}
1812        if annotations is None:
1813            annotations = []
1814
1815        dID = self.resolveDecision(decision)
1816
1817        self.add_edge(
1818            dID,
1819            dID,
1820            key=action,
1821            tags=tags,
1822            annotations=annotations
1823        )
1824        self.setTransitionRequirement(dID, action, requires)
1825        if consequence is not None:
1826            self.setConsequence(dID, action, consequence)

Adds the given action as a possibility at the given decision. An action is just a self-edge, which can have requirements like any edge, and which can have consequences like any edge. The optional arguments are given to setTransitionRequirement and setConsequence; see those functions for descriptions of what they mean.

Raises a KeyError if a transition with the given name already exists at the given decision.

def tagDecision( self, decision: Union[int, exploration.base.DecisionSpecifier, str], tagOrTags: Union[str, Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]], tagValue: Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]], type[exploration.base.NoTagValue]] = <class 'exploration.base.NoTagValue'>) -> None:
1828    def tagDecision(
1829        self,
1830        decision: base.AnyDecisionSpecifier,
1831        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
1832        tagValue: Union[
1833            base.TagValue,
1834            type[base.NoTagValue]
1835        ] = base.NoTagValue
1836    ) -> None:
1837        """
1838        Adds a tag (or many tags from a dictionary of tags) to a
1839        decision, using `1` as the value if no value is provided. It's
1840        a `ValueError` to provide a value when a dictionary of tags is
1841        provided to set multiple tags at once.
1842
1843        Note that certain tags have special meanings:
1844
1845        - 'unconfirmed' is used for decisions that represent unconfirmed
1846            parts of the graph (this is separate from the 'unknown'
1847            and/or 'hypothesized' exploration statuses, which are only
1848            tracked in a `DiscreteExploration`, not in a `DecisionGraph`).
1849            Various methods require this tag and many also add or remove
1850            it.
1851        """
1852        if isinstance(tagOrTags, base.Tag):
1853            if tagValue is base.NoTagValue:
1854                tagValue = 1
1855
1856            # Not sure why this cast is necessary given the `if` above...
1857            tagValue = cast(base.TagValue, tagValue)
1858
1859            tagOrTags = {tagOrTags: tagValue}
1860
1861        elif tagValue is not base.NoTagValue:
1862            raise ValueError(
1863                "Provided a dictionary to update multiple tags, but"
1864                " also a tag value."
1865            )
1866
1867        dID = self.resolveDecision(decision)
1868
1869        tagsAlready = self.nodes[dID].setdefault('tags', {})
1870        tagsAlready.update(tagOrTags)

Adds a tag (or many tags from a dictionary of tags) to a decision, using 1 as the value if no value is provided. It's a ValueError to provide a value when a dictionary of tags is provided to set multiple tags at once.

Note that certain tags have special meanings:

  • 'unconfirmed' is used for decisions that represent unconfirmed parts of the graph (this is separate from the 'unknown' and/or 'hypothesized' exploration statuses, which are only tracked in a DiscreteExploration, not in a DecisionGraph). Various methods require this tag and many also add or remove it.
def untagDecision( self, decision: Union[int, exploration.base.DecisionSpecifier, str], tag: str) -> Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]], type[exploration.base.NoTagValue]]:
1872    def untagDecision(
1873        self,
1874        decision: base.AnyDecisionSpecifier,
1875        tag: base.Tag
1876    ) -> Union[base.TagValue, type[base.NoTagValue]]:
1877        """
1878        Removes a tag from a decision. Returns the tag's old value if
1879        the tag was present and got removed, or `NoTagValue` if the tag
1880        wasn't present.
1881        """
1882        dID = self.resolveDecision(decision)
1883
1884        target = self.nodes[dID]['tags']
1885        try:
1886            return target.pop(tag)
1887        except KeyError:
1888            return base.NoTagValue

Removes a tag from a decision. Returns the tag's old value if the tag was present and got removed, or NoTagValue if the tag wasn't present.

def decisionTags( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]:
1890    def decisionTags(
1891        self,
1892        decision: base.AnyDecisionSpecifier
1893    ) -> Dict[base.Tag, base.TagValue]:
1894        """
1895        Returns the dictionary of tags for a decision. Edits to the
1896        returned value will be applied to the graph.
1897        """
1898        dID = self.resolveDecision(decision)
1899
1900        return self.nodes[dID]['tags']

Returns the dictionary of tags for a decision. Edits to the returned value will be applied to the graph.

def annotateDecision( self, decision: Union[int, exploration.base.DecisionSpecifier, str], annotationOrAnnotations: Union[str, Sequence[str]]) -> None:
1902    def annotateDecision(
1903        self,
1904        decision: base.AnyDecisionSpecifier,
1905        annotationOrAnnotations: Union[
1906            base.Annotation,
1907            Sequence[base.Annotation]
1908        ]
1909    ) -> None:
1910        """
1911        Adds an annotation to a decision's annotations list.
1912        """
1913        dID = self.resolveDecision(decision)
1914
1915        if isinstance(annotationOrAnnotations, base.Annotation):
1916            annotationOrAnnotations = [annotationOrAnnotations]
1917        self.nodes[dID]['annotations'].extend(annotationOrAnnotations)

Adds an annotation to a decision's annotations list.

def decisionAnnotations( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> List[str]:
1919    def decisionAnnotations(
1920        self,
1921        decision: base.AnyDecisionSpecifier
1922    ) -> List[base.Annotation]:
1923        """
1924        Returns the list of annotations for the specified decision.
1925        Modifying the list affects the graph.
1926        """
1927        dID = self.resolveDecision(decision)
1928
1929        return self.nodes[dID]['annotations']

Returns the list of annotations for the specified decision. Modifying the list affects the graph.

def tagTransition( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, tagOrTags: Union[str, Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]], tagValue: Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]], type[exploration.base.NoTagValue]] = <class 'exploration.base.NoTagValue'>) -> None:
1931    def tagTransition(
1932        self,
1933        decision: base.AnyDecisionSpecifier,
1934        transition: base.Transition,
1935        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
1936        tagValue: Union[
1937            base.TagValue,
1938            type[base.NoTagValue]
1939        ] = base.NoTagValue
1940    ) -> None:
1941        """
1942        Adds a tag (or each tag from a dictionary) to a transition
1943        coming out of a specific decision. `1` will be used as the
1944        default value if a single tag is supplied; supplying a tag value
1945        when providing a dictionary of multiple tags to update is a
1946        `ValueError`.
1947
1948        Note that certain transition tags have special meanings:
1949        - 'trigger' causes any actions (but not normal transitions) that
1950            it applies to to be automatically triggered when
1951            `advanceSituation` is called and the decision they're
1952            attached to is active in the new situation (as long as the
1953            action's requirements are met). This happens once per
1954            situation; use 'wait' steps to re-apply triggers.
1955        """
1956        dID = self.resolveDecision(decision)
1957
1958        dest = self.destination(dID, transition)
1959        if isinstance(tagOrTags, base.Tag):
1960            if tagValue is base.NoTagValue:
1961                tagValue = 1
1962
1963            # Not sure why this is necessary given the `if` above...
1964            tagValue = cast(base.TagValue, tagValue)
1965
1966            tagOrTags = {tagOrTags: tagValue}
1967        elif tagValue is not base.NoTagValue:
1968            raise ValueError(
1969                "Provided a dictionary to update multiple tags, but"
1970                " also a tag value."
1971            )
1972
1973        info = cast(
1974            TransitionProperties,
1975            self.edges[dID, dest, transition]  # type:ignore
1976        )
1977
1978        info.setdefault('tags', {}).update(tagOrTags)

Adds a tag (or each tag from a dictionary) to a transition coming out of a specific decision. 1 will be used as the default value if a single tag is supplied; supplying a tag value when providing a dictionary of multiple tags to update is a ValueError.

Note that certain transition tags have special meanings:

  • 'trigger' causes any actions (but not normal transitions) that it applies to to be automatically triggered when advanceSituation is called and the decision they're attached to is active in the new situation (as long as the action's requirements are met). This happens once per situation; use 'wait' steps to re-apply triggers.
def untagTransition( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, tagOrTags: Union[str, Set[str]]) -> None:
1980    def untagTransition(
1981        self,
1982        decision: base.AnyDecisionSpecifier,
1983        transition: base.Transition,
1984        tagOrTags: Union[base.Tag, Set[base.Tag]]
1985    ) -> None:
1986        """
1987        Removes a tag (or each tag in a set) from a transition coming out
1988        of a specific decision. Raises a `KeyError` if (one of) the
1989        specified tag(s) is not currently applied to the specified
1990        transition.
1991        """
1992        dID = self.resolveDecision(decision)
1993
1994        dest = self.destination(dID, transition)
1995        if isinstance(tagOrTags, base.Tag):
1996            tagOrTags = {tagOrTags}
1997
1998        info = cast(
1999            TransitionProperties,
2000            self.edges[dID, dest, transition]  # type:ignore
2001        )
2002        tagsAlready = info.setdefault('tags', {})
2003
2004        for tag in tagOrTags:
2005            tagsAlready.pop(tag)

Removes a tag (or each tag in a set) from a transition coming out of a specific decision. Raises a KeyError if (one of) the specified tag(s) is not currently applied to the specified transition.

def transitionTags( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str) -> Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]:
2007    def transitionTags(
2008        self,
2009        decision: base.AnyDecisionSpecifier,
2010        transition: base.Transition
2011    ) -> Dict[base.Tag, base.TagValue]:
2012        """
2013        Returns the dictionary of tags for a transition. Edits to the
2014        returned dictionary will be applied to the graph.
2015        """
2016        dID = self.resolveDecision(decision)
2017
2018        dest = self.destination(dID, transition)
2019        info = cast(
2020            TransitionProperties,
2021            self.edges[dID, dest, transition]  # type:ignore
2022        )
2023        return info.setdefault('tags', {})

Returns the dictionary of tags for a transition. Edits to the returned dictionary will be applied to the graph.

def annotateTransition( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, annotations: Union[str, Sequence[str]]) -> None:
2025    def annotateTransition(
2026        self,
2027        decision: base.AnyDecisionSpecifier,
2028        transition: base.Transition,
2029        annotations: Union[base.Annotation, Sequence[base.Annotation]]
2030    ) -> None:
2031        """
2032        Adds an annotation (or a sequence of annotations) to a
2033        transition's annotations list.
2034        """
2035        dID = self.resolveDecision(decision)
2036
2037        dest = self.destination(dID, transition)
2038        if isinstance(annotations, base.Annotation):
2039            annotations = [annotations]
2040        info = cast(
2041            TransitionProperties,
2042            self.edges[dID, dest, transition]  # type:ignore
2043        )
2044        info['annotations'].extend(annotations)

Adds an annotation (or a sequence of annotations) to a transition's annotations list.

def transitionAnnotations( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str) -> List[str]:
2046    def transitionAnnotations(
2047        self,
2048        decision: base.AnyDecisionSpecifier,
2049        transition: base.Transition
2050    ) -> List[base.Annotation]:
2051        """
2052        Returns the annotation list for a specific transition at a
2053        specific decision. Editing the list affects the graph.
2054        """
2055        dID = self.resolveDecision(decision)
2056
2057        dest = self.destination(dID, transition)
2058        info = cast(
2059            TransitionProperties,
2060            self.edges[dID, dest, transition]  # type:ignore
2061        )
2062        return info['annotations']

Returns the annotation list for a specific transition at a specific decision. Editing the list affects the graph.

def annotateZone(self, zone: str, annotations: Union[str, Sequence[str]]) -> None:
2064    def annotateZone(
2065        self,
2066        zone: base.Zone,
2067        annotations: Union[base.Annotation, Sequence[base.Annotation]]
2068    ) -> None:
2069        """
2070        Adds an annotation (or many annotations from a sequence) to a
2071        zone.
2072
2073        Raises a `MissingZoneError` if the specified zone does not exist.
2074        """
2075        if zone not in self.zones:
2076            raise MissingZoneError(
2077                f"Can't add annotation(s) to zone {zone!r} because that"
2078                f" zone doesn't exist yet."
2079            )
2080
2081        if isinstance(annotations, base.Annotation):
2082            annotations = [ annotations ]
2083
2084        self.zones[zone].annotations.extend(annotations)

Adds an annotation (or many annotations from a sequence) to a zone.

Raises a MissingZoneError if the specified zone does not exist.

def zoneAnnotations(self, zone: str) -> List[str]:
2086    def zoneAnnotations(self, zone: base.Zone) -> List[base.Annotation]:
2087        """
2088        Returns the list of annotations for the specified zone (empty if
2089        none have been added yet).
2090        """
2091        return self.zones[zone].annotations

Returns the list of annotations for the specified zone (empty if none have been added yet).

def tagZone( self, zone: str, tagOrTags: Union[str, Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]], tagValue: Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]], type[exploration.base.NoTagValue]] = <class 'exploration.base.NoTagValue'>) -> None:
2093    def tagZone(
2094        self,
2095        zone: base.Zone,
2096        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
2097        tagValue: Union[
2098            base.TagValue,
2099            type[base.NoTagValue]
2100        ] = base.NoTagValue
2101    ) -> None:
2102        """
2103        Adds a tag (or many tags from a dictionary of tags) to a
2104        zone, using `1` as the value if no value is provided. It's
2105        a `ValueError` to provide a value when a dictionary of tags is
2106        provided to set multiple tags at once.
2107
2108        Raises a `MissingZoneError` if the specified zone does not exist.
2109        """
2110        if zone not in self.zones:
2111            raise MissingZoneError(
2112                f"Can't add tag(s) to zone {zone!r} because that zone"
2113                f" doesn't exist yet."
2114            )
2115
2116        if isinstance(tagOrTags, base.Tag):
2117            if tagValue is base.NoTagValue:
2118                tagValue = 1
2119
2120            # Not sure why this cast is necessary given the `if` above...
2121            tagValue = cast(base.TagValue, tagValue)
2122
2123            tagOrTags = {tagOrTags: tagValue}
2124
2125        elif tagValue is not base.NoTagValue:
2126            raise ValueError(
2127                "Provided a dictionary to update multiple tags, but"
2128                " also a tag value."
2129            )
2130
2131        tagsAlready = self.zones[zone].tags
2132        tagsAlready.update(tagOrTags)

Adds a tag (or many tags from a dictionary of tags) to a zone, using 1 as the value if no value is provided. It's a ValueError to provide a value when a dictionary of tags is provided to set multiple tags at once.

Raises a MissingZoneError if the specified zone does not exist.

def untagZone( self, zone: str, tag: str) -> Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]], type[exploration.base.NoTagValue]]:
2134    def untagZone(
2135        self,
2136        zone: base.Zone,
2137        tag: base.Tag
2138    ) -> Union[base.TagValue, type[base.NoTagValue]]:
2139        """
2140        Removes a tag from a zone. Returns the tag's old value if the
2141        tag was present and got removed, or `NoTagValue` if the tag
2142        wasn't present.
2143
2144        Raises a `MissingZoneError` if the specified zone does not exist.
2145        """
2146        if zone not in self.zones:
2147            raise MissingZoneError(
2148                f"Can't remove tag {tag!r} from zone {zone!r} because"
2149                f" that zone doesn't exist yet."
2150            )
2151        target = self.zones[zone].tags
2152        try:
2153            return target.pop(tag)
2154        except KeyError:
2155            return base.NoTagValue

Removes a tag from a zone. Returns the tag's old value if the tag was present and got removed, or NoTagValue if the tag wasn't present.

Raises a MissingZoneError if the specified zone does not exist.

def zoneTags( self, zone: str) -> Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]:
2157    def zoneTags(
2158        self,
2159        zone: base.Zone
2160    ) -> Dict[base.Tag, base.TagValue]:
2161        """
2162        Returns the dictionary of tags for a zone. Edits to the returned
2163        value will be applied to the graph. Returns an empty tags
2164        dictionary if called on a zone that didn't have any tags
2165        previously, but raises a `MissingZoneError` if attempting to get
2166        tags for a zone which does not exist.
2167
2168        For example:
2169
2170        >>> g = DecisionGraph()
2171        >>> g.addDecision('A')
2172        0
2173        >>> g.addDecision('B')
2174        1
2175        >>> g.createZone('Zone')
2176        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2177 annotations=[])
2178        >>> g.tagZone('Zone', 'color', 'blue')
2179        >>> g.tagZone(
2180        ...     'Zone',
2181        ...     {'shape': 'square', 'color': 'red', 'sound': 'loud'}
2182        ... )
2183        >>> g.untagZone('Zone', 'sound')
2184        'loud'
2185        >>> g.zoneTags('Zone')
2186        {'color': 'red', 'shape': 'square'}
2187        """
2188        if zone in self.zones:
2189            return self.zones[zone].tags
2190        else:
2191            raise MissingZoneError(
2192                f"Tags for zone {zone!r} don't exist because that"
2193                f" zone has not been created yet."
2194            )

Returns the dictionary of tags for a zone. Edits to the returned value will be applied to the graph. Returns an empty tags dictionary if called on a zone that didn't have any tags previously, but raises a MissingZoneError if attempting to get tags for a zone which does not exist.

For example:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.createZone('Zone')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.tagZone('Zone', 'color', 'blue')
>>> g.tagZone(
...     'Zone',
...     {'shape': 'square', 'color': 'red', 'sound': 'loud'}
... )
>>> g.untagZone('Zone', 'sound')
'loud'
>>> g.zoneTags('Zone')
{'color': 'red', 'shape': 'square'}
def createZone(self, zone: str, level: int = 0) -> exploration.base.ZoneInfo:
2196    def createZone(self, zone: base.Zone, level: int = 0) -> base.ZoneInfo:
2197        """
2198        Creates an empty zone with the given name at the given level
2199        (default 0). Raises a `ZoneCollisionError` if that zone name is
2200        already in use (at any level), including if it's in use by a
2201        decision.
2202
2203        Raises an `InvalidLevelError` if the level value is less than 0.
2204
2205        Returns the `ZoneInfo` for the new blank zone.
2206
2207        For example:
2208
2209        >>> d = DecisionGraph()
2210        >>> d.createZone('Z', 0)
2211        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2212 annotations=[])
2213        >>> d.getZoneInfo('Z')
2214        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2215 annotations=[])
2216        >>> d.createZone('Z2', 0)
2217        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2218 annotations=[])
2219        >>> d.createZone('Z3', -1)  # level -1 is not valid (must be >= 0)
2220        Traceback (most recent call last):
2221        ...
2222        exploration.core.InvalidLevelError...
2223        >>> d.createZone('Z2')  # Name Z2 is already in use
2224        Traceback (most recent call last):
2225        ...
2226        exploration.core.ZoneCollisionError...
2227        """
2228        if level < 0:
2229            raise InvalidLevelError(
2230                "Cannot create a zone with a negative level."
2231            )
2232        if zone in self.zones:
2233            raise ZoneCollisionError(f"Zone {zone!r} already exists.")
2234        if zone in self:
2235            raise ZoneCollisionError(
2236                f"A decision named {zone!r} already exists, so a zone"
2237                f" with that name cannot be created."
2238            )
2239        info: base.ZoneInfo = base.ZoneInfo(
2240            level=level,
2241            parents=set(),
2242            contents=set(),
2243            tags={},
2244            annotations=[]
2245        )
2246        self.zones[zone] = info
2247        return info

Creates an empty zone with the given name at the given level (default 0). Raises a ZoneCollisionError if that zone name is already in use (at any level), including if it's in use by a decision.

Raises an InvalidLevelError if the level value is less than 0.

Returns the ZoneInfo for the new blank zone.

For example:

>>> d = DecisionGraph()
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('Z3', -1)  # level -1 is not valid (must be >= 0)
Traceback (most recent call last):
...
InvalidLevelError...
>>> d.createZone('Z2')  # Name Z2 is already in use
Traceback (most recent call last):
...
ZoneCollisionError...
def getZoneInfo(self, zone: str) -> Optional[exploration.base.ZoneInfo]:
2249    def getZoneInfo(self, zone: base.Zone) -> Optional[base.ZoneInfo]:
2250        """
2251        Returns the `ZoneInfo` (level, parents, and contents) for the
2252        specified zone, or `None` if that zone does not exist.
2253
2254        For example:
2255
2256        >>> d = DecisionGraph()
2257        >>> d.createZone('Z', 0)
2258        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2259 annotations=[])
2260        >>> d.getZoneInfo('Z')
2261        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2262 annotations=[])
2263        >>> d.createZone('Z2', 0)
2264        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2265 annotations=[])
2266        >>> d.getZoneInfo('Z2')
2267        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2268 annotations=[])
2269        """
2270        return self.zones.get(zone)

Returns the ZoneInfo (level, parents, and contents) for the specified zone, or None if that zone does not exist.

For example:

>>> d = DecisionGraph()
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('Z2')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
def deleteZone(self, zone: str) -> exploration.base.ZoneInfo:
2272    def deleteZone(self, zone: base.Zone) -> base.ZoneInfo:
2273        """
2274        Deletes the specified zone, returning a `ZoneInfo` object with
2275        the information on the level, parents, and contents of that zone.
2276
2277        Raises a `MissingZoneError` if the zone in question does not
2278        exist.
2279
2280        The zone will be removed as a child/parent of any zones that used
2281        to contain it or be contained in it.
2282
2283        For example:
2284
2285        >>> d = DecisionGraph()
2286        >>> d.createZone('Z', 0)
2287        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2288 annotations=[])
2289        >>> d.getZoneInfo('Z')
2290        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2291 annotations=[])
2292        >>> d.deleteZone('Z')
2293        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2294 annotations=[])
2295        >>> d.getZoneInfo('Z') is None  # no info any more
2296        True
2297        >>> d.deleteZone('Z')  # can't re-delete
2298        Traceback (most recent call last):
2299        ...
2300        exploration.core.MissingZoneError...
2301        """
2302        info = self.getZoneInfo(zone)
2303        if info is None:
2304            raise MissingZoneError(
2305                f"Cannot delete zone {zone!r}: it does not exist."
2306            )
2307        for sub in info.contents:
2308            if 'zones' in self.nodes[sub]:
2309                try:
2310                    self.nodes[sub]['zones'].remove(zone)
2311                except KeyError:
2312                    pass
2313        del self.zones[zone]
2314        # Clean up child/contents info in ALL other zones
2315        for otherZoneInfo in self.zones.values():
2316            if zone in otherZoneInfo.parents:
2317                otherZoneInfo.parents.remove(zone)
2318            if zone in otherZoneInfo.contents:
2319                otherZoneInfo.contents.remove(zone)
2320        return info

Deletes the specified zone, returning a ZoneInfo object with the information on the level, parents, and contents of that zone.

Raises a MissingZoneError if the zone in question does not exist.

The zone will be removed as a child/parent of any zones that used to contain it or be contained in it.

For example:

>>> d = DecisionGraph()
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.deleteZone('Z')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('Z') is None  # no info any more
True
>>> d.deleteZone('Z')  # can't re-delete
Traceback (most recent call last):
...
MissingZoneError...
def addDecisionToZone( self, decision: Union[int, exploration.base.DecisionSpecifier, str], zone: str) -> None:
2322    def addDecisionToZone(
2323        self,
2324        decision: base.AnyDecisionSpecifier,
2325        zone: base.Zone
2326    ) -> None:
2327        """
2328        Adds a decision directly to a zone. Should normally only be used
2329        with level-0 zones. Raises a `MissingZoneError` if the specified
2330        zone did not already exist.
2331
2332        For example:
2333
2334        >>> d = DecisionGraph()
2335        >>> d.addDecision('A')
2336        0
2337        >>> d.addDecision('B')
2338        1
2339        >>> d.addDecision('C')
2340        2
2341        >>> d.createZone('Z', 0)
2342        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2343 annotations=[])
2344        >>> d.addDecisionToZone('A', 'Z')
2345        >>> d.getZoneInfo('Z')
2346        ZoneInfo(level=0, parents=set(), contents={0}, tags={},\
2347 annotations=[])
2348        >>> d.addDecisionToZone('B', 'Z')
2349        >>> d.getZoneInfo('Z')
2350        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2351 annotations=[])
2352        """
2353        dID = self.resolveDecision(decision)
2354
2355        if zone not in self.zones:
2356            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2357
2358        self.zones[zone].contents.add(dID)
2359        self.nodes[dID].setdefault('zones', set()).add(zone)

Adds a decision directly to a zone. Should normally only be used with level-0 zones. Raises a MissingZoneError if the specified zone did not already exist.

For example:

>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addDecision('C')
2
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('A', 'Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents={0}, tags={}, annotations=[])
>>> d.addDecisionToZone('B', 'Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={}, annotations=[])
def removeDecisionFromZone( self, decision: Union[int, exploration.base.DecisionSpecifier, str], zone: str, thorough: bool = False) -> bool:
2361    def removeDecisionFromZone(
2362        self,
2363        decision: base.AnyDecisionSpecifier,
2364        zone: base.Zone,
2365        thorough: bool = False
2366    ) -> bool:
2367        """
2368        Removes a decision from a zone if it had been in it, returning
2369        True if that decision had been in that zone, and False if it was
2370        not in that zone, including if that zone didn't exist.
2371
2372        Note that this only removes a decision from direct zone
2373        membership. If the decision is a member of one or more zones
2374        which are (directly or indirectly) sub-zones of the target zone,
2375        the decision will remain in those zones, and will still be
2376        indirectly part of the target zone afterwards. You can set
2377        `thorough` to True to also remove the decision from any immediate
2378        parents which are descendants of the specified zone, thereby
2379        ensuring that it isn't afterwards even indirectly included in
2380        that zone, even though this may affect membership in multiple
2381        zones at different levels.
2382
2383        When 'thorough' is used the result is True even if the decision
2384        had been an indirect member of the target zone; without it,
2385        False is returned for indirect members.
2386
2387        Examples:
2388
2389        >>> g = DecisionGraph()
2390        >>> g.addDecision('A')
2391        0
2392        >>> g.addDecision('B')
2393        1
2394        >>> g.createZone('level0', 0)
2395        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2396 annotations=[])
2397        >>> g.createZone('level1', 1)
2398        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2399 annotations=[])
2400        >>> g.createZone('level2', 2)
2401        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2402 annotations=[])
2403        >>> g.createZone('level3', 3)
2404        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
2405 annotations=[])
2406        >>> g.addDecisionToZone('A', 'level0')
2407        >>> g.addDecisionToZone('B', 'level0')
2408        >>> g.addZoneToZone('level0', 'level1')
2409        >>> g.addZoneToZone('level1', 'level2')
2410        >>> g.addZoneToZone('level2', 'level3')
2411        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
2412        >>> g.removeDecisionFromZone('A', 'level1')
2413        False
2414        >>> g.zoneParents(0)
2415        {'level0'}
2416        >>> g.removeDecisionFromZone('A', 'level0')
2417        True
2418        >>> g.zoneParents(0)
2419        set()
2420        >>> g.removeDecisionFromZone('A', 'level0')
2421        False
2422        >>> g.removeDecisionFromZone('B', 'level0')
2423        True
2424        >>> g.zoneParents(1)
2425        {'level2'}
2426        >>> g.removeDecisionFromZone('B', 'level0')
2427        False
2428        >>> g.removeDecisionFromZone('B', 'level2')
2429        True
2430        >>> g.zoneParents(1)
2431        set()
2432
2433        Example of 'thorough' argument:
2434
2435        >>> g = DecisionGraph()
2436        >>> g.addDecision('A')
2437        0
2438        >>> g.addDecision('B')
2439        1
2440        >>> g.addDecision('C')
2441        2
2442        >>> g.createZone('level0', 0)
2443        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2444 annotations=[])
2445        >>> g.createZone('level1', 1)
2446        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2447 annotations=[])
2448        >>> g.addDecisionToZone('A', 'level0')
2449        >>> g.addDecisionToZone('B', 'level0')
2450        >>> g.addDecisionToZone('C', 'level0')
2451        >>> g.addDecisionToZone('B', 'level1')  # also direct
2452        >>> g.addDecisionToZone('C', 'level1')  # also direct
2453        >>> g.addZoneToZone('level0', 'level1')
2454        >>> g.removeDecisionFromZone('A', 'level1')  # indirect member
2455        False
2456        >>> g.allDecisionsInZone('level1')  # A is still in there indirectly
2457        {0, 1, 2}
2458        >>> g.removeDecisionFromZone('A', 'level1', True)
2459        True
2460        >>> g.allDecisionsInZone('level1')  # A is now gone
2461        {1, 2}
2462        >>> g.zoneParents(0)  # removed from 'level0'
2463        set()
2464        >>> g.removeDecisionFromZone('B', 'level1')  # not thorough
2465        True
2466        >>> g.allDecisionsInZone('level1')  # B still there indirectly
2467        {1, 2}
2468        >>> g.removeDecisionFromZone('B', 'level1', True)  # thorough
2469        True
2470        >>> g.allDecisionsInZone('level1')  # now gone
2471        {2}
2472        >>> g.removeDecisionFromZone('C', 'level1', True)  # 1st time
2473        True
2474        >>> g.allDecisionsInZone('level1')  # now gone
2475        set()
2476        """
2477        dID = self.resolveDecision(decision)
2478
2479        if zone not in self.zones:
2480            return False
2481
2482        if thorough:
2483            parents = self.nodes[dID]['zones']  # editable reference
2484            discard = set()
2485            for parentZone in parents:
2486                if parentZone == zone:
2487                    info = self.zones[parentZone]
2488                    info.contents.remove(dID)
2489                    discard.add(zone)
2490                elif zone in self.zoneAncestors(parentZone):
2491                    info = self.zones[parentZone]
2492                    info.contents.remove(dID)
2493                    discard.add(parentZone)
2494            if discard:
2495                for indirectZone in discard:
2496                    parents.remove(indirectZone)
2497                return True
2498            else:
2499                return False
2500        else:
2501            info = self.zones[zone]
2502            if dID not in info.contents:
2503                return False
2504            else:
2505                info.contents.remove(dID)
2506                try:
2507                    self.nodes[dID]['zones'].remove(zone)
2508                except KeyError:
2509                    pass
2510                return True

Removes a decision from a zone if it had been in it, returning True if that decision had been in that zone, and False if it was not in that zone, including if that zone didn't exist.

Note that this only removes a decision from direct zone membership. If the decision is a member of one or more zones which are (directly or indirectly) sub-zones of the target zone, the decision will remain in those zones, and will still be indirectly part of the target zone afterwards. You can set thorough to True to also remove the decision from any immediate parents which are descendants of the specified zone, thereby ensuring that it isn't afterwards even indirectly included in that zone, even though this may affect membership in multiple zones at different levels.

When 'thorough' is used the result is True even if the decision had been an indirect member of the target zone; without it, False is returned for indirect members.

Examples:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.createZone('level0', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level1', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level2', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level3', 3)
ZoneInfo(level=3, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('A', 'level0')
>>> g.addDecisionToZone('B', 'level0')
>>> g.addZoneToZone('level0', 'level1')
>>> g.addZoneToZone('level1', 'level2')
>>> g.addZoneToZone('level2', 'level3')
>>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
>>> g.removeDecisionFromZone('A', 'level1')
False
>>> g.zoneParents(0)
{'level0'}
>>> g.removeDecisionFromZone('A', 'level0')
True
>>> g.zoneParents(0)
set()
>>> g.removeDecisionFromZone('A', 'level0')
False
>>> g.removeDecisionFromZone('B', 'level0')
True
>>> g.zoneParents(1)
{'level2'}
>>> g.removeDecisionFromZone('B', 'level0')
False
>>> g.removeDecisionFromZone('B', 'level2')
True
>>> g.zoneParents(1)
set()

Example of 'thorough' argument:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.createZone('level0', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level1', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('A', 'level0')
>>> g.addDecisionToZone('B', 'level0')
>>> g.addDecisionToZone('C', 'level0')
>>> g.addDecisionToZone('B', 'level1')  # also direct
>>> g.addDecisionToZone('C', 'level1')  # also direct
>>> g.addZoneToZone('level0', 'level1')
>>> g.removeDecisionFromZone('A', 'level1')  # indirect member
False
>>> g.allDecisionsInZone('level1')  # A is still in there indirectly
{0, 1, 2}
>>> g.removeDecisionFromZone('A', 'level1', True)
True
>>> g.allDecisionsInZone('level1')  # A is now gone
{1, 2}
>>> g.zoneParents(0)  # removed from 'level0'
set()
>>> g.removeDecisionFromZone('B', 'level1')  # not thorough
True
>>> g.allDecisionsInZone('level1')  # B still there indirectly
{1, 2}
>>> g.removeDecisionFromZone('B', 'level1', True)  # thorough
True
>>> g.allDecisionsInZone('level1')  # now gone
{2}
>>> g.removeDecisionFromZone('C', 'level1', True)  # 1st time
True
>>> g.allDecisionsInZone('level1')  # now gone
set()
def addZoneToZone(self, addIt: str, addTo: str) -> None:
2512    def addZoneToZone(
2513        self,
2514        addIt: base.Zone,
2515        addTo: base.Zone
2516    ) -> None:
2517        """
2518        Adds a zone to another zone. The `addIt` one must be at a
2519        strictly lower level than the `addTo` zone, or an
2520        `InvalidLevelError` will be raised.
2521
2522        If the zone to be added didn't already exist, it is created at
2523        one level below the target zone. Similarly, if the zone being
2524        added to didn't already exist, it is created at one level above
2525        the target zone. If neither existed, a `MissingZoneError` will
2526        be raised.
2527
2528        For example:
2529
2530        >>> d = DecisionGraph()
2531        >>> d.addDecision('A')
2532        0
2533        >>> d.addDecision('B')
2534        1
2535        >>> d.addDecision('C')
2536        2
2537        >>> d.createZone('Z', 0)
2538        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2539 annotations=[])
2540        >>> d.addDecisionToZone('A', 'Z')
2541        >>> d.addDecisionToZone('B', 'Z')
2542        >>> d.getZoneInfo('Z')
2543        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2544 annotations=[])
2545        >>> d.createZone('Z2', 0)
2546        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2547 annotations=[])
2548        >>> d.addDecisionToZone('B', 'Z2')
2549        >>> d.addDecisionToZone('C', 'Z2')
2550        >>> d.getZoneInfo('Z2')
2551        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
2552 annotations=[])
2553        >>> d.createZone('l1Z', 1)
2554        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2555 annotations=[])
2556        >>> d.createZone('l2Z', 2)
2557        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2558 annotations=[])
2559        >>> d.addZoneToZone('Z', 'l1Z')
2560        >>> d.getZoneInfo('Z')
2561        ZoneInfo(level=0, parents={'l1Z'}, contents={0, 1}, tags={},\
2562 annotations=[])
2563        >>> d.getZoneInfo('l1Z')
2564        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
2565 annotations=[])
2566        >>> d.addZoneToZone('l1Z', 'l2Z')
2567        >>> d.getZoneInfo('l1Z')
2568        ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\
2569 annotations=[])
2570        >>> d.getZoneInfo('l2Z')
2571        ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\
2572 annotations=[])
2573        >>> d.addZoneToZone('Z2', 'l2Z')
2574        >>> d.getZoneInfo('Z2')
2575        ZoneInfo(level=0, parents={'l2Z'}, contents={1, 2}, tags={},\
2576 annotations=[])
2577        >>> l2i = d.getZoneInfo('l2Z')
2578        >>> l2i.level
2579        2
2580        >>> l2i.parents
2581        set()
2582        >>> sorted(l2i.contents)
2583        ['Z2', 'l1Z']
2584        >>> d.addZoneToZone('NZ', 'NZ2')
2585        Traceback (most recent call last):
2586        ...
2587        exploration.core.MissingZoneError...
2588        >>> d.addZoneToZone('Z', 'l1Z2')
2589        >>> zi = d.getZoneInfo('Z')
2590        >>> zi.level
2591        0
2592        >>> sorted(zi.parents)
2593        ['l1Z', 'l1Z2']
2594        >>> sorted(zi.contents)
2595        [0, 1]
2596        >>> d.getZoneInfo('l1Z2')
2597        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
2598 annotations=[])
2599        >>> d.addZoneToZone('NZ', 'l1Z')
2600        >>> d.getZoneInfo('NZ')
2601        ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\
2602 annotations=[])
2603        >>> zi = d.getZoneInfo('l1Z')
2604        >>> zi.level
2605        1
2606        >>> zi.parents
2607        {'l2Z'}
2608        >>> sorted(zi.contents)
2609        ['NZ', 'Z']
2610        """
2611        # Create one or the other (but not both) if they're missing
2612        addInfo = self.getZoneInfo(addIt)
2613        toInfo = self.getZoneInfo(addTo)
2614        if addInfo is None and toInfo is None:
2615            raise MissingZoneError(
2616                f"Cannot add zone {addIt!r} to zone {addTo!r}: neither"
2617                f" exists already."
2618            )
2619
2620        # Create missing addIt
2621        elif addInfo is None:
2622            toInfo = cast(base.ZoneInfo, toInfo)
2623            newLevel = toInfo.level - 1
2624            if newLevel < 0:
2625                raise InvalidLevelError(
2626                    f"Zone {addTo!r} is at level {toInfo.level} and so"
2627                    f" a new zone cannot be added underneath it."
2628                )
2629            addInfo = self.createZone(addIt, newLevel)
2630
2631        # Create missing addTo
2632        elif toInfo is None:
2633            addInfo = cast(base.ZoneInfo, addInfo)
2634            newLevel = addInfo.level + 1
2635            if newLevel < 0:
2636                raise InvalidLevelError(
2637                    f"Zone {addIt!r} is at level {addInfo.level} (!!!)"
2638                    f" and so a new zone cannot be added above it."
2639                )
2640            toInfo = self.createZone(addTo, newLevel)
2641
2642        # Now both addInfo and toInfo are defined
2643        if addInfo.level >= toInfo.level:
2644            raise InvalidLevelError(
2645                f"Cannot add zone {addIt!r} at level {addInfo.level}"
2646                f" to zone {addTo!r} at level {toInfo.level}: zones can"
2647                f" only contain zones of lower levels."
2648            )
2649
2650        # Now both addInfo and toInfo are defined
2651        toInfo.contents.add(addIt)
2652        addInfo.parents.add(addTo)

Adds a zone to another zone. The addIt one must be at a strictly lower level than the addTo zone, or an InvalidLevelError will be raised.

If the zone to be added didn't already exist, it is created at one level below the target zone. Similarly, if the zone being added to didn't already exist, it is created at one level above the target zone. If neither existed, a MissingZoneError will be raised.

For example:

>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addDecision('C')
2
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('A', 'Z')
>>> d.addDecisionToZone('B', 'Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={}, annotations=[])
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('B', 'Z2')
>>> d.addDecisionToZone('C', 'Z2')
>>> d.getZoneInfo('Z2')
ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={}, annotations=[])
>>> d.createZone('l1Z', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('l2Z', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addZoneToZone('Z', 'l1Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents={'l1Z'}, contents={0, 1}, tags={}, annotations=[])
>>> d.getZoneInfo('l1Z')
ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={}, annotations=[])
>>> d.addZoneToZone('l1Z', 'l2Z')
>>> d.getZoneInfo('l1Z')
ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={}, annotations=[])
>>> d.getZoneInfo('l2Z')
ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={}, annotations=[])
>>> d.addZoneToZone('Z2', 'l2Z')
>>> d.getZoneInfo('Z2')
ZoneInfo(level=0, parents={'l2Z'}, contents={1, 2}, tags={}, annotations=[])
>>> l2i = d.getZoneInfo('l2Z')
>>> l2i.level
2
>>> l2i.parents
set()
>>> sorted(l2i.contents)
['Z2', 'l1Z']
>>> d.addZoneToZone('NZ', 'NZ2')
Traceback (most recent call last):
...
MissingZoneError...
>>> d.addZoneToZone('Z', 'l1Z2')
>>> zi = d.getZoneInfo('Z')
>>> zi.level
0
>>> sorted(zi.parents)
['l1Z', 'l1Z2']
>>> sorted(zi.contents)
[0, 1]
>>> d.getZoneInfo('l1Z2')
ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={}, annotations=[])
>>> d.addZoneToZone('NZ', 'l1Z')
>>> d.getZoneInfo('NZ')
ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={}, annotations=[])
>>> zi = d.getZoneInfo('l1Z')
>>> zi.level
1
>>> zi.parents
{'l2Z'}
>>> sorted(zi.contents)
['NZ', 'Z']
def removeZoneFromZone(self, removeIt: str, removeFrom: str) -> bool:
2654    def removeZoneFromZone(
2655        self,
2656        removeIt: base.Zone,
2657        removeFrom: base.Zone
2658    ) -> bool:
2659        """
2660        Removes a zone from a zone if it had been in it, returning True
2661        if that zone had been in that zone, and False if it was not in
2662        that zone, including if either zone did not exist.
2663
2664        For example:
2665
2666        >>> d = DecisionGraph()
2667        >>> d.createZone('Z', 0)
2668        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2669 annotations=[])
2670        >>> d.createZone('Z2', 0)
2671        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2672 annotations=[])
2673        >>> d.createZone('l1Z', 1)
2674        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2675 annotations=[])
2676        >>> d.createZone('l2Z', 2)
2677        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2678 annotations=[])
2679        >>> d.addZoneToZone('Z', 'l1Z')
2680        >>> d.addZoneToZone('l1Z', 'l2Z')
2681        >>> d.getZoneInfo('Z')
2682        ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\
2683 annotations=[])
2684        >>> d.getZoneInfo('l1Z')
2685        ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\
2686 annotations=[])
2687        >>> d.getZoneInfo('l2Z')
2688        ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\
2689 annotations=[])
2690        >>> d.removeZoneFromZone('l1Z', 'l2Z')
2691        True
2692        >>> d.getZoneInfo('l1Z')
2693        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
2694 annotations=[])
2695        >>> d.getZoneInfo('l2Z')
2696        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2697 annotations=[])
2698        >>> d.removeZoneFromZone('Z', 'l1Z')
2699        True
2700        >>> d.getZoneInfo('Z')
2701        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2702 annotations=[])
2703        >>> d.getZoneInfo('l1Z')
2704        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2705 annotations=[])
2706        >>> d.removeZoneFromZone('Z', 'l1Z')
2707        False
2708        >>> d.removeZoneFromZone('Z', 'madeup')
2709        False
2710        >>> d.removeZoneFromZone('nope', 'madeup')
2711        False
2712        >>> d.removeZoneFromZone('nope', 'l1Z')
2713        False
2714        """
2715        remInfo = self.getZoneInfo(removeIt)
2716        fromInfo = self.getZoneInfo(removeFrom)
2717
2718        if remInfo is None or fromInfo is None:
2719            return False
2720
2721        if removeIt not in fromInfo.contents:
2722            return False
2723
2724        remInfo.parents.remove(removeFrom)
2725        fromInfo.contents.remove(removeIt)
2726        return True

Removes a zone from a zone if it had been in it, returning True if that zone had been in that zone, and False if it was not in that zone, including if either zone did not exist.

For example:

>>> d = DecisionGraph()
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('l1Z', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('l2Z', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addZoneToZone('Z', 'l1Z')
>>> d.addZoneToZone('l1Z', 'l2Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('l1Z')
ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={}, annotations=[])
>>> d.getZoneInfo('l2Z')
ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={}, annotations=[])
>>> d.removeZoneFromZone('l1Z', 'l2Z')
True
>>> d.getZoneInfo('l1Z')
ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={}, annotations=[])
>>> d.getZoneInfo('l2Z')
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.removeZoneFromZone('Z', 'l1Z')
True
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('l1Z')
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.removeZoneFromZone('Z', 'l1Z')
False
>>> d.removeZoneFromZone('Z', 'madeup')
False
>>> d.removeZoneFromZone('nope', 'madeup')
False
>>> d.removeZoneFromZone('nope', 'l1Z')
False
def decisionsInZone(self, zone: str) -> Set[int]:
2728    def decisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]:
2729        """
2730        Returns a set of all decisions included directly in the given
2731        zone, not counting decisions included via intermediate
2732        sub-zones (see `allDecisionsInZone` to include those).
2733
2734        Raises a `MissingZoneError` if the specified zone does not
2735        exist.
2736
2737        The returned set is a copy, not a live editable set.
2738
2739        For example:
2740
2741        >>> d = DecisionGraph()
2742        >>> d.addDecision('A')
2743        0
2744        >>> d.addDecision('B')
2745        1
2746        >>> d.addDecision('C')
2747        2
2748        >>> d.createZone('Z', 0)
2749        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2750 annotations=[])
2751        >>> d.addDecisionToZone('A', 'Z')
2752        >>> d.addDecisionToZone('B', 'Z')
2753        >>> d.getZoneInfo('Z')
2754        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2755 annotations=[])
2756        >>> d.decisionsInZone('Z')
2757        {0, 1}
2758        >>> d.createZone('Z2', 0)
2759        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2760 annotations=[])
2761        >>> d.addDecisionToZone('B', 'Z2')
2762        >>> d.addDecisionToZone('C', 'Z2')
2763        >>> d.getZoneInfo('Z2')
2764        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
2765 annotations=[])
2766        >>> d.decisionsInZone('Z')
2767        {0, 1}
2768        >>> d.decisionsInZone('Z2')
2769        {1, 2}
2770        >>> d.createZone('l1Z', 1)
2771        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2772 annotations=[])
2773        >>> d.addZoneToZone('Z', 'l1Z')
2774        >>> d.decisionsInZone('Z')
2775        {0, 1}
2776        >>> d.decisionsInZone('l1Z')
2777        set()
2778        >>> d.decisionsInZone('madeup')
2779        Traceback (most recent call last):
2780        ...
2781        exploration.core.MissingZoneError...
2782        >>> zDec = d.decisionsInZone('Z')
2783        >>> zDec.add(2)  # won't affect the zone
2784        >>> zDec
2785        {0, 1, 2}
2786        >>> d.decisionsInZone('Z')
2787        {0, 1}
2788        """
2789        info = self.getZoneInfo(zone)
2790        if info is None:
2791            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2792
2793        # Everything that's not a zone must be a decision
2794        return {
2795            item
2796            for item in info.contents
2797            if isinstance(item, base.DecisionID)
2798        }

Returns a set of all decisions included directly in the given zone, not counting decisions included via intermediate sub-zones (see allDecisionsInZone to include those).

Raises a MissingZoneError if the specified zone does not exist.

The returned set is a copy, not a live editable set.

For example:

>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addDecision('C')
2
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('A', 'Z')
>>> d.addDecisionToZone('B', 'Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={}, annotations=[])
>>> d.decisionsInZone('Z')
{0, 1}
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('B', 'Z2')
>>> d.addDecisionToZone('C', 'Z2')
>>> d.getZoneInfo('Z2')
ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={}, annotations=[])
>>> d.decisionsInZone('Z')
{0, 1}
>>> d.decisionsInZone('Z2')
{1, 2}
>>> d.createZone('l1Z', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addZoneToZone('Z', 'l1Z')
>>> d.decisionsInZone('Z')
{0, 1}
>>> d.decisionsInZone('l1Z')
set()
>>> d.decisionsInZone('madeup')
Traceback (most recent call last):
...
MissingZoneError...
>>> zDec = d.decisionsInZone('Z')
>>> zDec.add(2)  # won't affect the zone
>>> zDec
{0, 1, 2}
>>> d.decisionsInZone('Z')
{0, 1}
def subZones(self, zone: str) -> Set[str]:
2800    def subZones(self, zone: base.Zone) -> Set[base.Zone]:
2801        """
2802        Returns the set of all immediate sub-zones of the given zone.
2803        Will be an empty set if there are no sub-zones; raises a
2804        `MissingZoneError` if the specified zone does not exit.
2805
2806        The returned set is a copy, not a live editable set.
2807
2808        For example:
2809
2810        >>> d = DecisionGraph()
2811        >>> d.createZone('Z', 0)
2812        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2813 annotations=[])
2814        >>> d.subZones('Z')
2815        set()
2816        >>> d.createZone('l1Z', 1)
2817        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2818 annotations=[])
2819        >>> d.addZoneToZone('Z', 'l1Z')
2820        >>> d.subZones('Z')
2821        set()
2822        >>> d.subZones('l1Z')
2823        {'Z'}
2824        >>> s = d.subZones('l1Z')
2825        >>> s.add('Q')  # doesn't affect the zone
2826        >>> sorted(s)
2827        ['Q', 'Z']
2828        >>> d.subZones('l1Z')
2829        {'Z'}
2830        >>> d.subZones('madeup')
2831        Traceback (most recent call last):
2832        ...
2833        exploration.core.MissingZoneError...
2834        """
2835        info = self.getZoneInfo(zone)
2836        if info is None:
2837            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2838
2839        # Sub-zones will appear in self.zones
2840        return {
2841            item
2842            for item in info.contents
2843            if isinstance(item, base.Zone)
2844        }

Returns the set of all immediate sub-zones of the given zone. Will be an empty set if there are no sub-zones; raises a MissingZoneError if the specified zone does not exit.

The returned set is a copy, not a live editable set.

For example:

>>> d = DecisionGraph()
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.subZones('Z')
set()
>>> d.createZone('l1Z', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addZoneToZone('Z', 'l1Z')
>>> d.subZones('Z')
set()
>>> d.subZones('l1Z')
{'Z'}
>>> s = d.subZones('l1Z')
>>> s.add('Q')  # doesn't affect the zone
>>> sorted(s)
['Q', 'Z']
>>> d.subZones('l1Z')
{'Z'}
>>> d.subZones('madeup')
Traceback (most recent call last):
...
MissingZoneError...
def allDecisionsInZone(self, zone: str) -> Set[int]:
2846    def allDecisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]:
2847        """
2848        Returns a set containing all decisions in the given zone,
2849        including those included via sub-zones.
2850
2851        Raises a `MissingZoneError` if the specified zone does not
2852        exist.`
2853
2854        For example:
2855
2856        >>> d = DecisionGraph()
2857        >>> d.addDecision('A')
2858        0
2859        >>> d.addDecision('B')
2860        1
2861        >>> d.addDecision('C')
2862        2
2863        >>> d.createZone('Z', 0)
2864        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2865 annotations=[])
2866        >>> d.addDecisionToZone('A', 'Z')
2867        >>> d.addDecisionToZone('B', 'Z')
2868        >>> d.getZoneInfo('Z')
2869        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2870 annotations=[])
2871        >>> d.decisionsInZone('Z')
2872        {0, 1}
2873        >>> d.allDecisionsInZone('Z')
2874        {0, 1}
2875        >>> d.createZone('Z2', 0)
2876        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2877 annotations=[])
2878        >>> d.addDecisionToZone('B', 'Z2')
2879        >>> d.addDecisionToZone('C', 'Z2')
2880        >>> d.getZoneInfo('Z2')
2881        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
2882 annotations=[])
2883        >>> d.decisionsInZone('Z')
2884        {0, 1}
2885        >>> d.decisionsInZone('Z2')
2886        {1, 2}
2887        >>> d.allDecisionsInZone('Z2')
2888        {1, 2}
2889        >>> d.createZone('l1Z', 1)
2890        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2891 annotations=[])
2892        >>> d.createZone('l2Z', 2)
2893        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2894 annotations=[])
2895        >>> d.addZoneToZone('Z', 'l1Z')
2896        >>> d.addZoneToZone('l1Z', 'l2Z')
2897        >>> d.addZoneToZone('Z2', 'l2Z')
2898        >>> d.decisionsInZone('Z')
2899        {0, 1}
2900        >>> d.decisionsInZone('Z2')
2901        {1, 2}
2902        >>> d.decisionsInZone('l1Z')
2903        set()
2904        >>> d.allDecisionsInZone('l1Z')
2905        {0, 1}
2906        >>> d.allDecisionsInZone('l2Z')
2907        {0, 1, 2}
2908        """
2909        result: Set[base.DecisionID] = set()
2910        info = self.getZoneInfo(zone)
2911        if info is None:
2912            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2913
2914        for item in info.contents:
2915            if isinstance(item, base.Zone):
2916                # This can't be an error because of the condition above
2917                result |= self.allDecisionsInZone(item)
2918            else:  # it's a decision
2919                result.add(item)
2920
2921        return result

Returns a set containing all decisions in the given zone, including those included via sub-zones.

Raises a MissingZoneError if the specified zone does not exist.`

For example:

>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addDecision('C')
2
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('A', 'Z')
>>> d.addDecisionToZone('B', 'Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={}, annotations=[])
>>> d.decisionsInZone('Z')
{0, 1}
>>> d.allDecisionsInZone('Z')
{0, 1}
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('B', 'Z2')
>>> d.addDecisionToZone('C', 'Z2')
>>> d.getZoneInfo('Z2')
ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={}, annotations=[])
>>> d.decisionsInZone('Z')
{0, 1}
>>> d.decisionsInZone('Z2')
{1, 2}
>>> d.allDecisionsInZone('Z2')
{1, 2}
>>> d.createZone('l1Z', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('l2Z', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addZoneToZone('Z', 'l1Z')
>>> d.addZoneToZone('l1Z', 'l2Z')
>>> d.addZoneToZone('Z2', 'l2Z')
>>> d.decisionsInZone('Z')
{0, 1}
>>> d.decisionsInZone('Z2')
{1, 2}
>>> d.decisionsInZone('l1Z')
set()
>>> d.allDecisionsInZone('l1Z')
{0, 1}
>>> d.allDecisionsInZone('l2Z')
{0, 1, 2}
def zoneHierarchyLevel(self, zone: str) -> int:
2923    def zoneHierarchyLevel(self, zone: base.Zone) -> int:
2924        """
2925        Returns the hierarchy level of the given zone, as stored in its
2926        zone info.
2927
2928        By convention, level-0 zones contain decisions directly, and
2929        higher-level zones contain zones of lower levels. This
2930        convention is not enforced, and there could be exceptions to it.
2931
2932        Raises a `MissingZoneError` if the specified zone does not
2933        exist.
2934
2935        For example:
2936
2937        >>> d = DecisionGraph()
2938        >>> d.createZone('Z', 0)
2939        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2940 annotations=[])
2941        >>> d.createZone('l1Z', 1)
2942        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2943 annotations=[])
2944        >>> d.createZone('l5Z', 5)
2945        ZoneInfo(level=5, parents=set(), contents=set(), tags={},\
2946 annotations=[])
2947        >>> d.zoneHierarchyLevel('Z')
2948        0
2949        >>> d.zoneHierarchyLevel('l1Z')
2950        1
2951        >>> d.zoneHierarchyLevel('l5Z')
2952        5
2953        >>> d.zoneHierarchyLevel('madeup')
2954        Traceback (most recent call last):
2955        ...
2956        exploration.core.MissingZoneError...
2957        """
2958        info = self.getZoneInfo(zone)
2959        if info is None:
2960            raise MissingZoneError(f"Zone {zone!r} dose not exist.")
2961
2962        return info.level

Returns the hierarchy level of the given zone, as stored in its zone info.

By convention, level-0 zones contain decisions directly, and higher-level zones contain zones of lower levels. This convention is not enforced, and there could be exceptions to it.

Raises a MissingZoneError if the specified zone does not exist.

For example:

>>> d = DecisionGraph()
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('l1Z', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('l5Z', 5)
ZoneInfo(level=5, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.zoneHierarchyLevel('Z')
0
>>> d.zoneHierarchyLevel('l1Z')
1
>>> d.zoneHierarchyLevel('l5Z')
5
>>> d.zoneHierarchyLevel('madeup')
Traceback (most recent call last):
...
MissingZoneError...
def zoneParents(self, zoneOrDecision: Union[str, int]) -> Set[str]:
2964    def zoneParents(
2965        self,
2966        zoneOrDecision: Union[base.Zone, base.DecisionID]
2967    ) -> Set[base.Zone]:
2968        """
2969        Returns the set of all zones which directly contain the target
2970        zone or decision.
2971
2972        Raises a `MissingDecisionError` if the target is neither a valid
2973        zone nor a valid decision.
2974
2975        Returns a copy, not a live editable set.
2976
2977        Example:
2978
2979        >>> g = DecisionGraph()
2980        >>> g.addDecision('A')
2981        0
2982        >>> g.addDecision('B')
2983        1
2984        >>> g.createZone('level0', 0)
2985        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2986 annotations=[])
2987        >>> g.createZone('level1', 1)
2988        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2989 annotations=[])
2990        >>> g.createZone('level2', 2)
2991        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2992 annotations=[])
2993        >>> g.createZone('level3', 3)
2994        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
2995 annotations=[])
2996        >>> g.addDecisionToZone('A', 'level0')
2997        >>> g.addDecisionToZone('B', 'level0')
2998        >>> g.addZoneToZone('level0', 'level1')
2999        >>> g.addZoneToZone('level1', 'level2')
3000        >>> g.addZoneToZone('level2', 'level3')
3001        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
3002        >>> sorted(g.zoneParents(0))
3003        ['level0']
3004        >>> sorted(g.zoneParents(1))
3005        ['level0', 'level2']
3006        """
3007        if zoneOrDecision in self.zones:
3008            zoneOrDecision = cast(base.Zone, zoneOrDecision)
3009            info = cast(base.ZoneInfo, self.getZoneInfo(zoneOrDecision))
3010            return copy.copy(info.parents)
3011        elif zoneOrDecision in self:
3012            return self.nodes[zoneOrDecision].get('zones', set())
3013        else:
3014            raise MissingDecisionError(
3015                f"Name {zoneOrDecision!r} is neither a valid zone nor a"
3016                f" valid decision."
3017            )

Returns the set of all zones which directly contain the target zone or decision.

Raises a MissingDecisionError if the target is neither a valid zone nor a valid decision.

Returns a copy, not a live editable set.

Example:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.createZone('level0', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level1', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level2', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level3', 3)
ZoneInfo(level=3, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('A', 'level0')
>>> g.addDecisionToZone('B', 'level0')
>>> g.addZoneToZone('level0', 'level1')
>>> g.addZoneToZone('level1', 'level2')
>>> g.addZoneToZone('level2', 'level3')
>>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
>>> sorted(g.zoneParents(0))
['level0']
>>> sorted(g.zoneParents(1))
['level0', 'level2']
def zoneAncestors( self, zoneOrDecision: Union[str, int], exclude: Set[str] = set(), atLevel: Optional[int] = None) -> Set[str]:
3019    def zoneAncestors(
3020        self,
3021        zoneOrDecision: Union[base.Zone, base.DecisionID],
3022        exclude: Set[base.Zone] = set(),
3023        atLevel: Optional[int] = None
3024    ) -> Set[base.Zone]:
3025        """
3026        Returns the set of zones which contain the target zone or
3027        decision, either directly or indirectly. The target is not
3028        included in the set.
3029
3030        Any ones listed in the `exclude` set are also excluded, as are
3031        any of their ancestors which are not also ancestors of the
3032        target zone via another path of inclusion.
3033
3034        If `atLevel` is not `None`, then only zones at that hierarchy
3035        level will be included.
3036
3037        Raises a `MissingDecisionError` if the target is nether a valid
3038        zone nor a valid decision.
3039
3040        Example:
3041
3042        >>> g = DecisionGraph()
3043        >>> g.addDecision('A')
3044        0
3045        >>> g.addDecision('B')
3046        1
3047        >>> g.createZone('level0', 0)
3048        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
3049 annotations=[])
3050        >>> g.createZone('level1', 1)
3051        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3052 annotations=[])
3053        >>> g.createZone('level2', 2)
3054        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
3055 annotations=[])
3056        >>> g.createZone('level3', 3)
3057        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
3058 annotations=[])
3059        >>> g.addDecisionToZone('A', 'level0')
3060        >>> g.addDecisionToZone('B', 'level0')
3061        >>> g.addZoneToZone('level0', 'level1')
3062        >>> g.addZoneToZone('level1', 'level2')
3063        >>> g.addZoneToZone('level2', 'level3')
3064        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
3065        >>> sorted(g.zoneAncestors(0))
3066        ['level0', 'level1', 'level2', 'level3']
3067        >>> sorted(g.zoneAncestors(1))
3068        ['level0', 'level1', 'level2', 'level3']
3069        >>> sorted(g.zoneParents(0))
3070        ['level0']
3071        >>> sorted(g.zoneParents(1))
3072        ['level0', 'level2']
3073        >>> sorted(g.zoneAncestors(0, atLevel=2))
3074        ['level2']
3075        >>> sorted(g.zoneAncestors(0, exclude={'level2'}))
3076        ['level0', 'level1']
3077        """
3078        # Copy is important here!
3079        result = set(self.zoneParents(zoneOrDecision))
3080        result -= exclude
3081        for parent in copy.copy(result):
3082            # Recursively dig up ancestors, but exclude
3083            # results-so-far to avoid re-enumerating when there are
3084            # multiple braided inclusion paths.
3085            result |= self.zoneAncestors(parent, result | exclude, atLevel)
3086
3087        if atLevel is not None:
3088            return {
3089                z for z in result if self.zoneHierarchyLevel(z) == atLevel
3090            }
3091        else:
3092            return result

Returns the set of zones which contain the target zone or decision, either directly or indirectly. The target is not included in the set.

Any ones listed in the exclude set are also excluded, as are any of their ancestors which are not also ancestors of the target zone via another path of inclusion.

If atLevel is not None, then only zones at that hierarchy level will be included.

Raises a MissingDecisionError if the target is nether a valid zone nor a valid decision.

Example:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.createZone('level0', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level1', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level2', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level3', 3)
ZoneInfo(level=3, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('A', 'level0')
>>> g.addDecisionToZone('B', 'level0')
>>> g.addZoneToZone('level0', 'level1')
>>> g.addZoneToZone('level1', 'level2')
>>> g.addZoneToZone('level2', 'level3')
>>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
>>> sorted(g.zoneAncestors(0))
['level0', 'level1', 'level2', 'level3']
>>> sorted(g.zoneAncestors(1))
['level0', 'level1', 'level2', 'level3']
>>> sorted(g.zoneParents(0))
['level0']
>>> sorted(g.zoneParents(1))
['level0', 'level2']
>>> sorted(g.zoneAncestors(0, atLevel=2))
['level2']
>>> sorted(g.zoneAncestors(0, exclude={'level2'}))
['level0', 'level1']
def region(self, decision: int, useLevel: int = 1) -> Optional[str]:
3094    def region(
3095        self,
3096        decision: base.DecisionID,
3097        useLevel: int=1
3098    ) -> Optional[base.Zone]:
3099        """
3100        Returns the 'region' that this decision belongs to. 'Regions'
3101        are level-1 zones, but when a decision is in multiple level-1
3102        zones, its region counts as the smallest of those zones in terms
3103        of total decisions contained, breaking ties by the one with the
3104        alphabetically earlier name.
3105
3106        Always returns a single zone name string, unless the target
3107        decision is not in any level-1 zones, in which case it returns
3108        `None`.
3109
3110        If `useLevel` is specified, then zones of the specified level
3111        will be used instead of level-1 zones.
3112
3113        Example:
3114
3115        >>> g = DecisionGraph()
3116        >>> g.addDecision('A')
3117        0
3118        >>> g.addDecision('B')
3119        1
3120        >>> g.addDecision('C')
3121        2
3122        >>> g.addDecision('D')
3123        3
3124        >>> g.createZone('zoneX', 0)
3125        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
3126 annotations=[])
3127        >>> g.createZone('regionA', 1)
3128        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3129 annotations=[])
3130        >>> g.createZone('zoneY', 0)
3131        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
3132 annotations=[])
3133        >>> g.createZone('regionB', 1)
3134        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3135 annotations=[])
3136        >>> g.createZone('regionC', 1)
3137        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3138 annotations=[])
3139        >>> g.createZone('quadrant', 2)
3140        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
3141 annotations=[])
3142        >>> g.addDecisionToZone('A', 'zoneX')
3143        >>> g.addDecisionToZone('B', 'zoneY')
3144        >>> # C is not in any level-1 zones
3145        >>> g.addDecisionToZone('D', 'zoneX')
3146        >>> g.addDecisionToZone('D', 'zoneY')  # D is in both
3147        >>> g.addZoneToZone('zoneX', 'regionA')
3148        >>> g.addZoneToZone('zoneY', 'regionB')
3149        >>> g.addZoneToZone('zoneX', 'regionC')  # includes both
3150        >>> g.addZoneToZone('zoneY', 'regionC')
3151        >>> g.addZoneToZone('regionA', 'quadrant')
3152        >>> g.addZoneToZone('regionB', 'quadrant')
3153        >>> g.addDecisionToZone('C', 'regionC')  # Direct in level-2
3154        >>> sorted(g.allDecisionsInZone('zoneX'))
3155        [0, 3]
3156        >>> sorted(g.allDecisionsInZone('zoneY'))
3157        [1, 3]
3158        >>> sorted(g.allDecisionsInZone('regionA'))
3159        [0, 3]
3160        >>> sorted(g.allDecisionsInZone('regionB'))
3161        [1, 3]
3162        >>> sorted(g.allDecisionsInZone('regionC'))
3163        [0, 1, 2, 3]
3164        >>> sorted(g.allDecisionsInZone('quadrant'))
3165        [0, 1, 3]
3166        >>> g.region(0)  # for A; region A is smaller than region C
3167        'regionA'
3168        >>> g.region(1)  # for B; region B is also smaller than C
3169        'regionB'
3170        >>> g.region(2)  # for C
3171        'regionC'
3172        >>> g.region(3)  # for D; tie broken alphabetically
3173        'regionA'
3174        >>> g.region(0, useLevel=0)  # for A at level 0
3175        'zoneX'
3176        >>> g.region(1, useLevel=0)  # for B at level 0
3177        'zoneY'
3178        >>> g.region(2, useLevel=0) is None  # for C at level 0 (none)
3179        True
3180        >>> g.region(3, useLevel=0)  # for D at level 0; tie
3181        'zoneX'
3182        >>> g.region(0, useLevel=2) # for A at level 2
3183        'quadrant'
3184        >>> g.region(1, useLevel=2) # for B at level 2
3185        'quadrant'
3186        >>> g.region(2, useLevel=2) is None # for C at level 2 (none)
3187        True
3188        >>> g.region(3, useLevel=2)  # for D at level 2
3189        'quadrant'
3190        """
3191        relevant = self.zoneAncestors(decision, atLevel=useLevel)
3192        if len(relevant) == 0:
3193            return None
3194        elif len(relevant) == 1:
3195            for zone in relevant:
3196                return zone
3197            return None  # not really necessary but keeps mypy happy
3198        else:
3199            # more than one zone ancestor at the relevant hierarchy
3200            # level: need to measure their sizes
3201            minSize = None
3202            candidates = []
3203            for zone in relevant:
3204                size = len(self.allDecisionsInZone(zone))
3205                if minSize is None or size < minSize:
3206                    candidates = [zone]
3207                    minSize = size
3208                elif size == minSize:
3209                    candidates.append(zone)
3210            return min(candidates)

Returns the 'region' that this decision belongs to. 'Regions' are level-1 zones, but when a decision is in multiple level-1 zones, its region counts as the smallest of those zones in terms of total decisions contained, breaking ties by the one with the alphabetically earlier name.

Always returns a single zone name string, unless the target decision is not in any level-1 zones, in which case it returns None.

If useLevel is specified, then zones of the specified level will be used instead of level-1 zones.

Example:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addDecision('D')
3
>>> g.createZone('zoneX', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('regionA', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zoneY', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('regionB', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('regionC', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('quadrant', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('A', 'zoneX')
>>> g.addDecisionToZone('B', 'zoneY')
>>> # C is not in any level-1 zones
>>> g.addDecisionToZone('D', 'zoneX')
>>> g.addDecisionToZone('D', 'zoneY')  # D is in both
>>> g.addZoneToZone('zoneX', 'regionA')
>>> g.addZoneToZone('zoneY', 'regionB')
>>> g.addZoneToZone('zoneX', 'regionC')  # includes both
>>> g.addZoneToZone('zoneY', 'regionC')
>>> g.addZoneToZone('regionA', 'quadrant')
>>> g.addZoneToZone('regionB', 'quadrant')
>>> g.addDecisionToZone('C', 'regionC')  # Direct in level-2
>>> sorted(g.allDecisionsInZone('zoneX'))
[0, 3]
>>> sorted(g.allDecisionsInZone('zoneY'))
[1, 3]
>>> sorted(g.allDecisionsInZone('regionA'))
[0, 3]
>>> sorted(g.allDecisionsInZone('regionB'))
[1, 3]
>>> sorted(g.allDecisionsInZone('regionC'))
[0, 1, 2, 3]
>>> sorted(g.allDecisionsInZone('quadrant'))
[0, 1, 3]
>>> g.region(0)  # for A; region A is smaller than region C
'regionA'
>>> g.region(1)  # for B; region B is also smaller than C
'regionB'
>>> g.region(2)  # for C
'regionC'
>>> g.region(3)  # for D; tie broken alphabetically
'regionA'
>>> g.region(0, useLevel=0)  # for A at level 0
'zoneX'
>>> g.region(1, useLevel=0)  # for B at level 0
'zoneY'
>>> g.region(2, useLevel=0) is None  # for C at level 0 (none)
True
>>> g.region(3, useLevel=0)  # for D at level 0; tie
'zoneX'
>>> g.region(0, useLevel=2) # for A at level 2
'quadrant'
>>> g.region(1, useLevel=2) # for B at level 2
'quadrant'
>>> g.region(2, useLevel=2) is None # for C at level 2 (none)
True
>>> g.region(3, useLevel=2)  # for D at level 2
'quadrant'
def zoneEdges( self, zone: str) -> Optional[Tuple[Set[Tuple[int, str]], Set[Tuple[int, str]]]]:
3212    def zoneEdges(self, zone: base.Zone) -> Optional[
3213        Tuple[
3214            Set[Tuple[base.DecisionID, base.Transition]],
3215            Set[Tuple[base.DecisionID, base.Transition]]
3216        ]
3217    ]:
3218        """
3219        Given a zone to look at, finds all of the transitions which go
3220        out of and into that zone, ignoring internal transitions between
3221        decisions in the zone. This includes all decisions in sub-zones.
3222        The return value is a pair of sets for outgoing and then
3223        incoming transitions, where each transition is specified as a
3224        (sourceID, transitionName) pair.
3225
3226        Returns `None` if the target zone isn't yet fully defined.
3227
3228        Note that this takes time proportional to *all* edges plus *all*
3229        nodes in the graph no matter how large or small the zone in
3230        question is.
3231
3232        >>> g = DecisionGraph()
3233        >>> g.addDecision('A')
3234        0
3235        >>> g.addDecision('B')
3236        1
3237        >>> g.addDecision('C')
3238        2
3239        >>> g.addDecision('D')
3240        3
3241        >>> g.addTransition('A', 'up', 'B', 'down')
3242        >>> g.addTransition('B', 'right', 'C', 'left')
3243        >>> g.addTransition('C', 'down', 'D', 'up')
3244        >>> g.addTransition('D', 'left', 'A', 'right')
3245        >>> g.addTransition('A', 'tunnel', 'C', 'tunnel')
3246        >>> g.createZone('Z', 0)
3247        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
3248 annotations=[])
3249        >>> g.createZone('ZZ', 1)
3250        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3251 annotations=[])
3252        >>> g.addZoneToZone('Z', 'ZZ')
3253        >>> g.addDecisionToZone('A', 'Z')
3254        >>> g.addDecisionToZone('B', 'Z')
3255        >>> g.addDecisionToZone('D', 'ZZ')
3256        >>> outgoing, incoming = g.zoneEdges('Z')  # TODO: Sort for testing
3257        >>> sorted(outgoing)
3258        [(0, 'right'), (0, 'tunnel'), (1, 'right')]
3259        >>> sorted(incoming)
3260        [(2, 'left'), (2, 'tunnel'), (3, 'left')]
3261        >>> outgoing, incoming = g.zoneEdges('ZZ')
3262        >>> sorted(outgoing)
3263        [(0, 'tunnel'), (1, 'right'), (3, 'up')]
3264        >>> sorted(incoming)
3265        [(2, 'down'), (2, 'left'), (2, 'tunnel')]
3266        >>> g.zoneEdges('madeup') is None
3267        True
3268        """
3269        # Find the interior nodes
3270        try:
3271            interior = self.allDecisionsInZone(zone)
3272        except MissingZoneError:
3273            return None
3274
3275        # Set up our result
3276        results: Tuple[
3277            Set[Tuple[base.DecisionID, base.Transition]],
3278            Set[Tuple[base.DecisionID, base.Transition]]
3279        ] = (set(), set())
3280
3281        # Because finding incoming edges requires searching the entire
3282        # graph anyways, it's more efficient to just consider each edge
3283        # once.
3284        for fromDecision in self:
3285            fromThere = self[fromDecision]
3286            for toDecision in fromThere:
3287                for transition in fromThere[toDecision]:
3288                    sourceIn = fromDecision in interior
3289                    destIn = toDecision in interior
3290                    if sourceIn and not destIn:
3291                        results[0].add((fromDecision, transition))
3292                    elif destIn and not sourceIn:
3293                        results[1].add((fromDecision, transition))
3294
3295        return results

Given a zone to look at, finds all of the transitions which go out of and into that zone, ignoring internal transitions between decisions in the zone. This includes all decisions in sub-zones. The return value is a pair of sets for outgoing and then incoming transitions, where each transition is specified as a (sourceID, transitionName) pair.

Returns None if the target zone isn't yet fully defined.

Note that this takes time proportional to all edges plus all nodes in the graph no matter how large or small the zone in question is.

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addDecision('D')
3
>>> g.addTransition('A', 'up', 'B', 'down')
>>> g.addTransition('B', 'right', 'C', 'left')
>>> g.addTransition('C', 'down', 'D', 'up')
>>> g.addTransition('D', 'left', 'A', 'right')
>>> g.addTransition('A', 'tunnel', 'C', 'tunnel')
>>> g.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('ZZ', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addZoneToZone('Z', 'ZZ')
>>> g.addDecisionToZone('A', 'Z')
>>> g.addDecisionToZone('B', 'Z')
>>> g.addDecisionToZone('D', 'ZZ')
>>> outgoing, incoming = g.zoneEdges('Z')  # TODO: Sort for testing
>>> sorted(outgoing)
[(0, 'right'), (0, 'tunnel'), (1, 'right')]
>>> sorted(incoming)
[(2, 'left'), (2, 'tunnel'), (3, 'left')]
>>> outgoing, incoming = g.zoneEdges('ZZ')
>>> sorted(outgoing)
[(0, 'tunnel'), (1, 'right'), (3, 'up')]
>>> sorted(incoming)
[(2, 'down'), (2, 'left'), (2, 'tunnel')]
>>> g.zoneEdges('madeup') is None
True
def replaceZonesInHierarchy( self, target: Union[int, exploration.base.DecisionSpecifier, str], zone: str, level: int) -> None:
3297    def replaceZonesInHierarchy(
3298        self,
3299        target: base.AnyDecisionSpecifier,
3300        zone: base.Zone,
3301        level: int
3302    ) -> None:
3303        """
3304        This method replaces one or more zones which contain the
3305        specified `target` decision with a specific zone, at a specific
3306        level in the zone hierarchy (see `zoneHierarchyLevel`). If the
3307        named zone doesn't yet exist, it will be created.
3308
3309        To do this, it looks at all zones which contain the target
3310        decision directly or indirectly (see `zoneAncestors`) and which
3311        are at the specified level.
3312
3313        - Any direct children of those zones which are ancestors of the
3314            target decision are removed from those zones and placed into
3315            the new zone instead, regardless of their levels. Indirect
3316            children are not affected (except perhaps indirectly via
3317            their parents' ancestors changing).
3318        - The new zone is placed into every direct parent of those
3319            zones, regardless of their levels (those parents are by
3320            definition all ancestors of the target decision).
3321        - If there were no zones at the target level, every zone at the
3322            next level down which is an ancestor of the target decision
3323            (or just that decision if the level is 0) is placed into the
3324            new zone as a direct child (and is removed from any previous
3325            parents it had). In this case, the new zone will also be
3326            added as a sub-zone to every ancestor of the target decision
3327            at the level above the specified level, if there are any.
3328            * In this case, if there are no zones at the level below the
3329                specified level, the highest level of zones smaller than
3330                that is treated as the level below, down to targeting
3331                the decision itself.
3332            * Similarly, if there are no zones at the level above the
3333                specified level but there are zones at a higher level,
3334                the new zone will be added to each of the zones in the
3335                lowest level above the target level that has zones in it.
3336
3337        A `MissingDecisionError` will be raised if the specified
3338        decision is not valid, or if the decision is left as default but
3339        there is no current decision in the exploration.
3340
3341        An `InvalidLevelError` will be raised if the level is less than
3342        zero.
3343
3344        Example:
3345
3346        >>> g = DecisionGraph()
3347        >>> g.addDecision('decision')
3348        0
3349        >>> g.addDecision('alternate')
3350        1
3351        >>> g.createZone('zone0', 0)
3352        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
3353 annotations=[])
3354        >>> g.createZone('zone1', 1)
3355        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3356 annotations=[])
3357        >>> g.createZone('zone2.1', 2)
3358        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
3359 annotations=[])
3360        >>> g.createZone('zone2.2', 2)
3361        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
3362 annotations=[])
3363        >>> g.createZone('zone3', 3)
3364        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
3365 annotations=[])
3366        >>> g.addDecisionToZone('decision', 'zone0')
3367        >>> g.addDecisionToZone('alternate', 'zone0')
3368        >>> g.addZoneToZone('zone0', 'zone1')
3369        >>> g.addZoneToZone('zone1', 'zone2.1')
3370        >>> g.addZoneToZone('zone1', 'zone2.2')
3371        >>> g.addZoneToZone('zone2.1', 'zone3')
3372        >>> g.addZoneToZone('zone2.2', 'zone3')
3373        >>> g.zoneHierarchyLevel('zone0')
3374        0
3375        >>> g.zoneHierarchyLevel('zone1')
3376        1
3377        >>> g.zoneHierarchyLevel('zone2.1')
3378        2
3379        >>> g.zoneHierarchyLevel('zone2.2')
3380        2
3381        >>> g.zoneHierarchyLevel('zone3')
3382        3
3383        >>> sorted(g.decisionsInZone('zone0'))
3384        [0, 1]
3385        >>> sorted(g.zoneAncestors('zone0'))
3386        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
3387        >>> g.subZones('zone1')
3388        {'zone0'}
3389        >>> g.zoneParents('zone0')
3390        {'zone1'}
3391        >>> g.replaceZonesInHierarchy('decision', 'new0', 0)
3392        >>> g.zoneParents('zone0')
3393        {'zone1'}
3394        >>> g.zoneParents('new0')
3395        {'zone1'}
3396        >>> sorted(g.zoneAncestors('zone0'))
3397        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
3398        >>> sorted(g.zoneAncestors('new0'))
3399        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
3400        >>> g.decisionsInZone('zone0')
3401        {1}
3402        >>> g.decisionsInZone('new0')
3403        {0}
3404        >>> sorted(g.subZones('zone1'))
3405        ['new0', 'zone0']
3406        >>> g.zoneParents('new0')
3407        {'zone1'}
3408        >>> g.replaceZonesInHierarchy('decision', 'new1', 1)
3409        >>> sorted(g.zoneAncestors(0))
3410        ['new0', 'new1', 'zone2.1', 'zone2.2', 'zone3']
3411        >>> g.subZones('zone1')
3412        {'zone0'}
3413        >>> g.subZones('new1')
3414        {'new0'}
3415        >>> g.zoneParents('new0')
3416        {'new1'}
3417        >>> sorted(g.zoneParents('zone1'))
3418        ['zone2.1', 'zone2.2']
3419        >>> sorted(g.zoneParents('new1'))
3420        ['zone2.1', 'zone2.2']
3421        >>> g.zoneParents('zone2.1')
3422        {'zone3'}
3423        >>> g.zoneParents('zone2.2')
3424        {'zone3'}
3425        >>> sorted(g.subZones('zone2.1'))
3426        ['new1', 'zone1']
3427        >>> sorted(g.subZones('zone2.2'))
3428        ['new1', 'zone1']
3429        >>> sorted(g.allDecisionsInZone('zone2.1'))
3430        [0, 1]
3431        >>> sorted(g.allDecisionsInZone('zone2.2'))
3432        [0, 1]
3433        >>> g.replaceZonesInHierarchy('decision', 'new2', 2)
3434        >>> g.zoneParents('zone2.1')
3435        {'zone3'}
3436        >>> g.zoneParents('zone2.2')
3437        {'zone3'}
3438        >>> g.subZones('zone2.1')
3439        {'zone1'}
3440        >>> g.subZones('zone2.2')
3441        {'zone1'}
3442        >>> g.subZones('new2')
3443        {'new1'}
3444        >>> g.zoneParents('new2')
3445        {'zone3'}
3446        >>> g.allDecisionsInZone('zone2.1')
3447        {1}
3448        >>> g.allDecisionsInZone('zone2.2')
3449        {1}
3450        >>> g.allDecisionsInZone('new2')
3451        {0}
3452        >>> sorted(g.subZones('zone3'))
3453        ['new2', 'zone2.1', 'zone2.2']
3454        >>> g.zoneParents('zone3')
3455        set()
3456        >>> sorted(g.allDecisionsInZone('zone3'))
3457        [0, 1]
3458        >>> g.replaceZonesInHierarchy('decision', 'new3', 3)
3459        >>> sorted(g.subZones('zone3'))
3460        ['zone2.1', 'zone2.2']
3461        >>> g.subZones('new3')
3462        {'new2'}
3463        >>> g.zoneParents('zone3')
3464        set()
3465        >>> g.zoneParents('new3')
3466        set()
3467        >>> g.allDecisionsInZone('zone3')
3468        {1}
3469        >>> g.allDecisionsInZone('new3')
3470        {0}
3471        >>> g.replaceZonesInHierarchy('decision', 'new4', 5)
3472        >>> g.subZones('new4')
3473        {'new3'}
3474        >>> g.zoneHierarchyLevel('new4')
3475        5
3476
3477        Another example of level collapse when trying to replace a zone
3478        at a level above :
3479
3480        >>> g = DecisionGraph()
3481        >>> g.addDecision('A')
3482        0
3483        >>> g.addDecision('B')
3484        1
3485        >>> g.createZone('level0', 0)
3486        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
3487 annotations=[])
3488        >>> g.createZone('level1', 1)
3489        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3490 annotations=[])
3491        >>> g.createZone('level2', 2)
3492        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
3493 annotations=[])
3494        >>> g.createZone('level3', 3)
3495        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
3496 annotations=[])
3497        >>> g.addDecisionToZone('B', 'level0')
3498        >>> g.addZoneToZone('level0', 'level1')
3499        >>> g.addZoneToZone('level1', 'level2')
3500        >>> g.addZoneToZone('level2', 'level3')
3501        >>> g.addDecisionToZone('A', 'level3') # missing some zone levels
3502        >>> g.zoneHierarchyLevel('level3')
3503        3
3504        >>> g.replaceZonesInHierarchy('A', 'newFirst', 1)
3505        >>> g.zoneHierarchyLevel('newFirst')
3506        1
3507        >>> g.decisionsInZone('newFirst')
3508        {0}
3509        >>> g.decisionsInZone('level3')
3510        set()
3511        >>> sorted(g.allDecisionsInZone('level3'))
3512        [0, 1]
3513        >>> g.subZones('newFirst')
3514        set()
3515        >>> sorted(g.subZones('level3'))
3516        ['level2', 'newFirst']
3517        >>> g.zoneParents('newFirst')
3518        {'level3'}
3519        >>> g.replaceZonesInHierarchy('A', 'newSecond', 2)
3520        >>> g.zoneHierarchyLevel('newSecond')
3521        2
3522        >>> g.decisionsInZone('newSecond')
3523        set()
3524        >>> g.allDecisionsInZone('newSecond')
3525        {0}
3526        >>> g.subZones('newSecond')
3527        {'newFirst'}
3528        >>> g.zoneParents('newSecond')
3529        {'level3'}
3530        >>> g.zoneParents('newFirst')
3531        {'newSecond'}
3532        >>> sorted(g.subZones('level3'))
3533        ['level2', 'newSecond']
3534        """
3535        tID = self.resolveDecision(target)
3536
3537        if level < 0:
3538            raise InvalidLevelError(
3539                f"Target level must be positive (got {level})."
3540            )
3541
3542        info = self.getZoneInfo(zone)
3543        if info is None:
3544            info = self.createZone(zone, level)
3545        elif level != info.level:
3546            raise InvalidLevelError(
3547                f"Target level ({level}) does not match the level of"
3548                f" the target zone ({zone!r} at level {info.level})."
3549            )
3550
3551        # Collect both parents & ancestors
3552        parents = self.zoneParents(tID)
3553        ancestors = set(self.zoneAncestors(tID))
3554
3555        # Map from levels to sets of zones from the ancestors pool
3556        levelMap: Dict[int, Set[base.Zone]] = {}
3557        highest = -1
3558        for ancestor in ancestors:
3559            ancestorLevel = self.zoneHierarchyLevel(ancestor)
3560            levelMap.setdefault(ancestorLevel, set()).add(ancestor)
3561            if ancestorLevel > highest:
3562                highest = ancestorLevel
3563
3564        # Figure out if we have target zones to replace or not
3565        reparentDecision = False
3566        if level in levelMap:
3567            # If there are zones at the target level,
3568            targetZones = levelMap[level]
3569
3570            above = set()
3571            below = set()
3572
3573            for replaced in targetZones:
3574                above |= self.zoneParents(replaced)
3575                below |= self.subZones(replaced)
3576                if replaced in parents:
3577                    reparentDecision = True
3578
3579            # Only ancestors should be reparented
3580            below &= ancestors
3581
3582        else:
3583            # Find levels w/ zones in them above + below
3584            levelBelow = level - 1
3585            levelAbove = level + 1
3586            below = levelMap.get(levelBelow, set())
3587            above = levelMap.get(levelAbove, set())
3588
3589            while len(below) == 0 and levelBelow > 0:
3590                levelBelow -= 1
3591                below = levelMap.get(levelBelow, set())
3592
3593            if len(below) == 0:
3594                reparentDecision = True
3595
3596            while len(above) == 0 and levelAbove < highest:
3597                levelAbove += 1
3598                above = levelMap.get(levelAbove, set())
3599
3600        # Handle re-parenting zones below
3601        for under in below:
3602            for parent in self.zoneParents(under):
3603                if parent in ancestors:
3604                    self.removeZoneFromZone(under, parent)
3605            self.addZoneToZone(under, zone)
3606
3607        # Add this zone to each parent
3608        for parent in above:
3609            self.addZoneToZone(zone, parent)
3610
3611        # Re-parent the decision itself if necessary
3612        if reparentDecision:
3613            # (using set() here to avoid size-change-during-iteration)
3614            for parent in set(parents):
3615                self.removeDecisionFromZone(tID, parent)
3616            self.addDecisionToZone(tID, zone)

This method replaces one or more zones which contain the specified target decision with a specific zone, at a specific level in the zone hierarchy (see zoneHierarchyLevel). If the named zone doesn't yet exist, it will be created.

To do this, it looks at all zones which contain the target decision directly or indirectly (see zoneAncestors) and which are at the specified level.

  • Any direct children of those zones which are ancestors of the target decision are removed from those zones and placed into the new zone instead, regardless of their levels. Indirect children are not affected (except perhaps indirectly via their parents' ancestors changing).
  • The new zone is placed into every direct parent of those zones, regardless of their levels (those parents are by definition all ancestors of the target decision).
  • If there were no zones at the target level, every zone at the next level down which is an ancestor of the target decision (or just that decision if the level is 0) is placed into the new zone as a direct child (and is removed from any previous parents it had). In this case, the new zone will also be added as a sub-zone to every ancestor of the target decision at the level above the specified level, if there are any.
    • In this case, if there are no zones at the level below the specified level, the highest level of zones smaller than that is treated as the level below, down to targeting the decision itself.
    • Similarly, if there are no zones at the level above the specified level but there are zones at a higher level, the new zone will be added to each of the zones in the lowest level above the target level that has zones in it.

A MissingDecisionError will be raised if the specified decision is not valid, or if the decision is left as default but there is no current decision in the exploration.

An InvalidLevelError will be raised if the level is less than zero.

Example:

>>> g = DecisionGraph()
>>> g.addDecision('decision')
0
>>> g.addDecision('alternate')
1
>>> g.createZone('zone0', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zone1', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zone2.1', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zone2.2', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zone3', 3)
ZoneInfo(level=3, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('decision', 'zone0')
>>> g.addDecisionToZone('alternate', 'zone0')
>>> g.addZoneToZone('zone0', 'zone1')
>>> g.addZoneToZone('zone1', 'zone2.1')
>>> g.addZoneToZone('zone1', 'zone2.2')
>>> g.addZoneToZone('zone2.1', 'zone3')
>>> g.addZoneToZone('zone2.2', 'zone3')
>>> g.zoneHierarchyLevel('zone0')
0
>>> g.zoneHierarchyLevel('zone1')
1
>>> g.zoneHierarchyLevel('zone2.1')
2
>>> g.zoneHierarchyLevel('zone2.2')
2
>>> g.zoneHierarchyLevel('zone3')
3
>>> sorted(g.decisionsInZone('zone0'))
[0, 1]
>>> sorted(g.zoneAncestors('zone0'))
['zone1', 'zone2.1', 'zone2.2', 'zone3']
>>> g.subZones('zone1')
{'zone0'}
>>> g.zoneParents('zone0')
{'zone1'}
>>> g.replaceZonesInHierarchy('decision', 'new0', 0)
>>> g.zoneParents('zone0')
{'zone1'}
>>> g.zoneParents('new0')
{'zone1'}
>>> sorted(g.zoneAncestors('zone0'))
['zone1', 'zone2.1', 'zone2.2', 'zone3']
>>> sorted(g.zoneAncestors('new0'))
['zone1', 'zone2.1', 'zone2.2', 'zone3']
>>> g.decisionsInZone('zone0')
{1}
>>> g.decisionsInZone('new0')
{0}
>>> sorted(g.subZones('zone1'))
['new0', 'zone0']
>>> g.zoneParents('new0')
{'zone1'}
>>> g.replaceZonesInHierarchy('decision', 'new1', 1)
>>> sorted(g.zoneAncestors(0))
['new0', 'new1', 'zone2.1', 'zone2.2', 'zone3']
>>> g.subZones('zone1')
{'zone0'}
>>> g.subZones('new1')
{'new0'}
>>> g.zoneParents('new0')
{'new1'}
>>> sorted(g.zoneParents('zone1'))
['zone2.1', 'zone2.2']
>>> sorted(g.zoneParents('new1'))
['zone2.1', 'zone2.2']
>>> g.zoneParents('zone2.1')
{'zone3'}
>>> g.zoneParents('zone2.2')
{'zone3'}
>>> sorted(g.subZones('zone2.1'))
['new1', 'zone1']
>>> sorted(g.subZones('zone2.2'))
['new1', 'zone1']
>>> sorted(g.allDecisionsInZone('zone2.1'))
[0, 1]
>>> sorted(g.allDecisionsInZone('zone2.2'))
[0, 1]
>>> g.replaceZonesInHierarchy('decision', 'new2', 2)
>>> g.zoneParents('zone2.1')
{'zone3'}
>>> g.zoneParents('zone2.2')
{'zone3'}
>>> g.subZones('zone2.1')
{'zone1'}
>>> g.subZones('zone2.2')
{'zone1'}
>>> g.subZones('new2')
{'new1'}
>>> g.zoneParents('new2')
{'zone3'}
>>> g.allDecisionsInZone('zone2.1')
{1}
>>> g.allDecisionsInZone('zone2.2')
{1}
>>> g.allDecisionsInZone('new2')
{0}
>>> sorted(g.subZones('zone3'))
['new2', 'zone2.1', 'zone2.2']
>>> g.zoneParents('zone3')
set()
>>> sorted(g.allDecisionsInZone('zone3'))
[0, 1]
>>> g.replaceZonesInHierarchy('decision', 'new3', 3)
>>> sorted(g.subZones('zone3'))
['zone2.1', 'zone2.2']
>>> g.subZones('new3')
{'new2'}
>>> g.zoneParents('zone3')
set()
>>> g.zoneParents('new3')
set()
>>> g.allDecisionsInZone('zone3')
{1}
>>> g.allDecisionsInZone('new3')
{0}
>>> g.replaceZonesInHierarchy('decision', 'new4', 5)
>>> g.subZones('new4')
{'new3'}
>>> g.zoneHierarchyLevel('new4')
5

Another example of level collapse when trying to replace a zone at a level above :

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.createZone('level0', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level1', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level2', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level3', 3)
ZoneInfo(level=3, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('B', 'level0')
>>> g.addZoneToZone('level0', 'level1')
>>> g.addZoneToZone('level1', 'level2')
>>> g.addZoneToZone('level2', 'level3')
>>> g.addDecisionToZone('A', 'level3') # missing some zone levels
>>> g.zoneHierarchyLevel('level3')
3
>>> g.replaceZonesInHierarchy('A', 'newFirst', 1)
>>> g.zoneHierarchyLevel('newFirst')
1
>>> g.decisionsInZone('newFirst')
{0}
>>> g.decisionsInZone('level3')
set()
>>> sorted(g.allDecisionsInZone('level3'))
[0, 1]
>>> g.subZones('newFirst')
set()
>>> sorted(g.subZones('level3'))
['level2', 'newFirst']
>>> g.zoneParents('newFirst')
{'level3'}
>>> g.replaceZonesInHierarchy('A', 'newSecond', 2)
>>> g.zoneHierarchyLevel('newSecond')
2
>>> g.decisionsInZone('newSecond')
set()
>>> g.allDecisionsInZone('newSecond')
{0}
>>> g.subZones('newSecond')
{'newFirst'}
>>> g.zoneParents('newSecond')
{'level3'}
>>> g.zoneParents('newFirst')
{'newSecond'}
>>> sorted(g.subZones('level3'))
['level2', 'newSecond']
def getReciprocal( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str) -> Optional[str]:
3618    def getReciprocal(
3619        self,
3620        decision: base.AnyDecisionSpecifier,
3621        transition: base.Transition
3622    ) -> Optional[base.Transition]:
3623        """
3624        Returns the reciprocal edge for the specified transition from the
3625        specified decision (see `setReciprocal`). Returns
3626        `None` if no reciprocal has been established for that
3627        transition, or if that decision or transition does not exist.
3628        """
3629        dID = self.resolveDecision(decision)
3630
3631        dest = self.getDestination(dID, transition)
3632        if dest is not None:
3633            info = cast(
3634                TransitionProperties,
3635                self.edges[dID, dest, transition]  # type:ignore
3636            )
3637            recip = info.get("reciprocal")
3638            if recip is not None and not isinstance(recip, base.Transition):
3639                raise ValueError(f"Invalid reciprocal value: {repr(recip)}")
3640            return recip
3641        else:
3642            return None

Returns the reciprocal edge for the specified transition from the specified decision (see setReciprocal). Returns None if no reciprocal has been established for that transition, or if that decision or transition does not exist.

def setReciprocal( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, reciprocal: Optional[str], setBoth: bool = True, cleanup: bool = True) -> None:
3644    def setReciprocal(
3645        self,
3646        decision: base.AnyDecisionSpecifier,
3647        transition: base.Transition,
3648        reciprocal: Optional[base.Transition],
3649        setBoth: bool = True,
3650        cleanup: bool = True
3651    ) -> None:
3652        """
3653        Sets the 'reciprocal' transition for a particular transition from
3654        a particular decision, and removes the reciprocal property from
3655        any old reciprocal transition.
3656
3657        Raises a `MissingDecisionError` or a `MissingTransitionError` if
3658        the specified decision or transition does not exist.
3659
3660        Raises an `InvalidDestinationError` if the reciprocal transition
3661        does not exist, or if it does exist but does not lead back to
3662        the decision the transition came from.
3663
3664        If `setBoth` is True (the default) then the transition which is
3665        being identified as a reciprocal will also have its reciprocal
3666        property set, pointing back to the primary transition being
3667        modified, and any old reciprocal of that transition will have its
3668        reciprocal set to None. If you want to create a situation with
3669        non-exclusive reciprocals, use `setBoth=False`.
3670
3671        If `cleanup` is True (the default) then abandoned reciprocal
3672        transitions (for both edges if `setBoth` was true) have their
3673        reciprocal properties removed. Set `cleanup` to false if you want
3674        to retain them, although this will result in non-exclusive
3675        reciprocal relationships.
3676
3677        If the `reciprocal` value is None, this deletes the reciprocal
3678        value entirely, and if `setBoth` is true, it does this for the
3679        previous reciprocal edge as well. No error is raised in this case
3680        when there was not already a reciprocal to delete.
3681
3682        Note that one should remove a reciprocal relationship before
3683        redirecting either edge of the pair in a way that gives it a new
3684        reciprocal, since otherwise, a later attempt to remove the
3685        reciprocal with `setBoth` set to True (the default) will end up
3686        deleting the reciprocal information from the other edge that was
3687        already modified. There is no way to reliably detect and avoid
3688        this, because two different decisions could (and often do in
3689        practice) have transitions with identical names, meaning that the
3690        reciprocal value will still be the same, but it will indicate a
3691        different edge in virtue of the destination of the edge changing.
3692
3693        ## Example
3694
3695        >>> g = DecisionGraph()
3696        >>> g.addDecision('G')
3697        0
3698        >>> g.addDecision('H')
3699        1
3700        >>> g.addDecision('I')
3701        2
3702        >>> g.addTransition('G', 'up', 'H', 'down')
3703        >>> g.addTransition('G', 'next', 'H', 'prev')
3704        >>> g.addTransition('H', 'next', 'I', 'prev')
3705        >>> g.addTransition('H', 'return', 'G')
3706        >>> g.setReciprocal('G', 'up', 'next') # Error w/ destinations
3707        Traceback (most recent call last):
3708        ...
3709        exploration.core.InvalidDestinationError...
3710        >>> g.setReciprocal('G', 'up', 'none') # Doesn't exist
3711        Traceback (most recent call last):
3712        ...
3713        exploration.core.MissingTransitionError...
3714        >>> g.getReciprocal('G', 'up')
3715        'down'
3716        >>> g.getReciprocal('H', 'down')
3717        'up'
3718        >>> g.getReciprocal('H', 'return') is None
3719        True
3720        >>> g.setReciprocal('G', 'up', 'return')
3721        >>> g.getReciprocal('G', 'up')
3722        'return'
3723        >>> g.getReciprocal('H', 'down') is None
3724        True
3725        >>> g.getReciprocal('H', 'return')
3726        'up'
3727        >>> g.setReciprocal('H', 'return', None) # remove the reciprocal
3728        >>> g.getReciprocal('G', 'up') is None
3729        True
3730        >>> g.getReciprocal('H', 'down') is None
3731        True
3732        >>> g.getReciprocal('H', 'return') is None
3733        True
3734        >>> g.setReciprocal('G', 'up', 'down', setBoth=False) # one-way
3735        >>> g.getReciprocal('G', 'up')
3736        'down'
3737        >>> g.getReciprocal('H', 'down') is None
3738        True
3739        >>> g.getReciprocal('H', 'return') is None
3740        True
3741        >>> g.setReciprocal('H', 'return', 'up', setBoth=False) # non-sym
3742        >>> g.getReciprocal('G', 'up')
3743        'down'
3744        >>> g.getReciprocal('H', 'down') is None
3745        True
3746        >>> g.getReciprocal('H', 'return')
3747        'up'
3748        >>> g.setReciprocal('H', 'down', 'up') # setBoth not needed
3749        >>> g.getReciprocal('G', 'up')
3750        'down'
3751        >>> g.getReciprocal('H', 'down')
3752        'up'
3753        >>> g.getReciprocal('H', 'return') # unchanged
3754        'up'
3755        >>> g.setReciprocal('G', 'up', 'return', cleanup=False) # no cleanup
3756        >>> g.getReciprocal('G', 'up')
3757        'return'
3758        >>> g.getReciprocal('H', 'down')
3759        'up'
3760        >>> g.getReciprocal('H', 'return') # unchanged
3761        'up'
3762        >>> # Cleanup only applies to reciprocal if setBoth is true
3763        >>> g.setReciprocal('H', 'down', 'up', setBoth=False)
3764        >>> g.getReciprocal('G', 'up')
3765        'return'
3766        >>> g.getReciprocal('H', 'down')
3767        'up'
3768        >>> g.getReciprocal('H', 'return') # not cleaned up w/out setBoth
3769        'up'
3770        >>> g.setReciprocal('H', 'down', 'up') # with cleanup and setBoth
3771        >>> g.getReciprocal('G', 'up')
3772        'down'
3773        >>> g.getReciprocal('H', 'down')
3774        'up'
3775        >>> g.getReciprocal('H', 'return') is None # cleaned up
3776        True
3777        """
3778        dID = self.resolveDecision(decision)
3779
3780        dest = self.destination(dID, transition) # possible KeyError
3781        if reciprocal is None:
3782            rDest = None
3783        else:
3784            rDest = self.getDestination(dest, reciprocal)
3785
3786        # Set or delete reciprocal property
3787        if reciprocal is None:
3788            # Delete the property
3789            info = self.edges[dID, dest, transition]  # type:ignore
3790
3791            old = info.pop('reciprocal')
3792            if setBoth:
3793                rDest = self.getDestination(dest, old)
3794                if rDest != dID:
3795                    raise RuntimeError(
3796                        f"Invalid reciprocal {old!r} for transition"
3797                        f" {transition!r} from {self.identityOf(dID)}:"
3798                        f" destination is {rDest}."
3799                    )
3800                rInfo = self.edges[dest, dID, old]  # type:ignore
3801                if 'reciprocal' in rInfo:
3802                    del rInfo['reciprocal']
3803        else:
3804            # Set the property, checking for errors first
3805            if rDest is None:
3806                raise MissingTransitionError(
3807                    f"Reciprocal transition {reciprocal!r} for"
3808                    f" transition {transition!r} from decision"
3809                    f" {self.identityOf(dID)} does not exist at"
3810                    f" decision {self.identityOf(dest)}"
3811                )
3812
3813            if rDest != dID:
3814                raise InvalidDestinationError(
3815                    f"Reciprocal transition {reciprocal!r} from"
3816                    f" decision {self.identityOf(dest)} does not lead"
3817                    f" back to decision {self.identityOf(dID)}."
3818                )
3819
3820            eProps = self.edges[dID, dest, transition]  # type:ignore [index]
3821            abandoned = eProps.get('reciprocal')
3822            eProps['reciprocal'] = reciprocal
3823            if cleanup and abandoned not in (None, reciprocal):
3824                aProps = self.edges[dest, dID, abandoned]  # type:ignore
3825                if 'reciprocal' in aProps:
3826                    del aProps['reciprocal']
3827
3828            if setBoth:
3829                rProps = self.edges[dest, dID, reciprocal]  # type:ignore
3830                revAbandoned = rProps.get('reciprocal')
3831                rProps['reciprocal'] = transition
3832                # Sever old reciprocal relationship
3833                if cleanup and revAbandoned not in (None, transition):
3834                    raProps = self.edges[
3835                        dID,  # type:ignore
3836                        dest,
3837                        revAbandoned
3838                    ]
3839                    del raProps['reciprocal']

Sets the 'reciprocal' transition for a particular transition from a particular decision, and removes the reciprocal property from any old reciprocal transition.

Raises a MissingDecisionError or a MissingTransitionError if the specified decision or transition does not exist.

Raises an InvalidDestinationError if the reciprocal transition does not exist, or if it does exist but does not lead back to the decision the transition came from.

If setBoth is True (the default) then the transition which is being identified as a reciprocal will also have its reciprocal property set, pointing back to the primary transition being modified, and any old reciprocal of that transition will have its reciprocal set to None. If you want to create a situation with non-exclusive reciprocals, use setBoth=False.

If cleanup is True (the default) then abandoned reciprocal transitions (for both edges if setBoth was true) have their reciprocal properties removed. Set cleanup to false if you want to retain them, although this will result in non-exclusive reciprocal relationships.

If the reciprocal value is None, this deletes the reciprocal value entirely, and if setBoth is true, it does this for the previous reciprocal edge as well. No error is raised in this case when there was not already a reciprocal to delete.

Note that one should remove a reciprocal relationship before redirecting either edge of the pair in a way that gives it a new reciprocal, since otherwise, a later attempt to remove the reciprocal with setBoth set to True (the default) will end up deleting the reciprocal information from the other edge that was already modified. There is no way to reliably detect and avoid this, because two different decisions could (and often do in practice) have transitions with identical names, meaning that the reciprocal value will still be the same, but it will indicate a different edge in virtue of the destination of the edge changing.

Example

>>> g = DecisionGraph()
>>> g.addDecision('G')
0
>>> g.addDecision('H')
1
>>> g.addDecision('I')
2
>>> g.addTransition('G', 'up', 'H', 'down')
>>> g.addTransition('G', 'next', 'H', 'prev')
>>> g.addTransition('H', 'next', 'I', 'prev')
>>> g.addTransition('H', 'return', 'G')
>>> g.setReciprocal('G', 'up', 'next') # Error w/ destinations
Traceback (most recent call last):
...
InvalidDestinationError...
>>> g.setReciprocal('G', 'up', 'none') # Doesn't exist
Traceback (most recent call last):
...
MissingTransitionError...
>>> g.getReciprocal('G', 'up')
'down'
>>> g.getReciprocal('H', 'down')
'up'
>>> g.getReciprocal('H', 'return') is None
True
>>> g.setReciprocal('G', 'up', 'return')
>>> g.getReciprocal('G', 'up')
'return'
>>> g.getReciprocal('H', 'down') is None
True
>>> g.getReciprocal('H', 'return')
'up'
>>> g.setReciprocal('H', 'return', None) # remove the reciprocal
>>> g.getReciprocal('G', 'up') is None
True
>>> g.getReciprocal('H', 'down') is None
True
>>> g.getReciprocal('H', 'return') is None
True
>>> g.setReciprocal('G', 'up', 'down', setBoth=False) # one-way
>>> g.getReciprocal('G', 'up')
'down'
>>> g.getReciprocal('H', 'down') is None
True
>>> g.getReciprocal('H', 'return') is None
True
>>> g.setReciprocal('H', 'return', 'up', setBoth=False) # non-sym
>>> g.getReciprocal('G', 'up')
'down'
>>> g.getReciprocal('H', 'down') is None
True
>>> g.getReciprocal('H', 'return')
'up'
>>> g.setReciprocal('H', 'down', 'up') # setBoth not needed
>>> g.getReciprocal('G', 'up')
'down'
>>> g.getReciprocal('H', 'down')
'up'
>>> g.getReciprocal('H', 'return') # unchanged
'up'
>>> g.setReciprocal('G', 'up', 'return', cleanup=False) # no cleanup
>>> g.getReciprocal('G', 'up')
'return'
>>> g.getReciprocal('H', 'down')
'up'
>>> g.getReciprocal('H', 'return') # unchanged
'up'
>>> # Cleanup only applies to reciprocal if setBoth is true
>>> g.setReciprocal('H', 'down', 'up', setBoth=False)
>>> g.getReciprocal('G', 'up')
'return'
>>> g.getReciprocal('H', 'down')
'up'
>>> g.getReciprocal('H', 'return') # not cleaned up w/out setBoth
'up'
>>> g.setReciprocal('H', 'down', 'up') # with cleanup and setBoth
>>> g.getReciprocal('G', 'up')
'down'
>>> g.getReciprocal('H', 'down')
'up'
>>> g.getReciprocal('H', 'return') is None # cleaned up
True
def getReciprocalPair( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str) -> Optional[Tuple[int, str]]:
3841    def getReciprocalPair(
3842        self,
3843        decision: base.AnyDecisionSpecifier,
3844        transition: base.Transition
3845    ) -> Optional[Tuple[base.DecisionID, base.Transition]]:
3846        """
3847        Returns a tuple containing both the destination decision ID and
3848        the transition at that decision which is the reciprocal of the
3849        specified destination & transition. Returns `None` if no
3850        reciprocal has been established for that transition, or if that
3851        decision or transition does not exist.
3852
3853        >>> g = DecisionGraph()
3854        >>> g.addDecision('A')
3855        0
3856        >>> g.addDecision('B')
3857        1
3858        >>> g.addDecision('C')
3859        2
3860        >>> g.addTransition('A', 'up', 'B', 'down')
3861        >>> g.addTransition('B', 'right', 'C', 'left')
3862        >>> g.addTransition('A', 'oneway', 'C')
3863        >>> g.getReciprocalPair('A', 'up')
3864        (1, 'down')
3865        >>> g.getReciprocalPair('B', 'down')
3866        (0, 'up')
3867        >>> g.getReciprocalPair('B', 'right')
3868        (2, 'left')
3869        >>> g.getReciprocalPair('C', 'left')
3870        (1, 'right')
3871        >>> g.getReciprocalPair('C', 'up') is None
3872        True
3873        >>> g.getReciprocalPair('Q', 'up') is None
3874        True
3875        >>> g.getReciprocalPair('A', 'tunnel') is None
3876        True
3877        """
3878        try:
3879            dID = self.resolveDecision(decision)
3880        except MissingDecisionError:
3881            return None
3882
3883        reciprocal = self.getReciprocal(dID, transition)
3884        if reciprocal is None:
3885            return None
3886        else:
3887            destination = self.getDestination(dID, transition)
3888            if destination is None:
3889                return None
3890            else:
3891                return (destination, reciprocal)

Returns a tuple containing both the destination decision ID and the transition at that decision which is the reciprocal of the specified destination & transition. Returns None if no reciprocal has been established for that transition, or if that decision or transition does not exist.

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addTransition('A', 'up', 'B', 'down')
>>> g.addTransition('B', 'right', 'C', 'left')
>>> g.addTransition('A', 'oneway', 'C')
>>> g.getReciprocalPair('A', 'up')
(1, 'down')
>>> g.getReciprocalPair('B', 'down')
(0, 'up')
>>> g.getReciprocalPair('B', 'right')
(2, 'left')
>>> g.getReciprocalPair('C', 'left')
(1, 'right')
>>> g.getReciprocalPair('C', 'up') is None
True
>>> g.getReciprocalPair('Q', 'up') is None
True
>>> g.getReciprocalPair('A', 'tunnel') is None
True
def addDecision( self, name: str, domain: Optional[str] = None, tags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, annotations: Optional[List[str]] = None) -> int:
3893    def addDecision(
3894        self,
3895        name: base.DecisionName,
3896        domain: Optional[base.Domain] = None,
3897        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
3898        annotations: Optional[List[base.Annotation]] = None
3899    ) -> base.DecisionID:
3900        """
3901        Adds a decision to the graph, without any transitions yet. Each
3902        decision will be assigned an ID so name collisions are allowed,
3903        but it's usually best to keep names unique at least within each
3904        zone. If no domain is provided, the `DEFAULT_DOMAIN` will be
3905        used for the decision's domain. A dictionary of tags and/or a
3906        list of annotations (strings in both cases) may be provided.
3907
3908        Returns the newly-assigned `DecisionID` for the decision it
3909        created.
3910
3911        Emits a `DecisionCollisionWarning` if a decision with the
3912        provided name already exists and the `WARN_OF_NAME_COLLISIONS`
3913        global variable is set to `True`.
3914        """
3915        # Defaults
3916        if domain is None:
3917            domain = base.DEFAULT_DOMAIN
3918        if tags is None:
3919            tags = {}
3920        if annotations is None:
3921            annotations = []
3922
3923        # Error checking
3924        if name in self.nameLookup and WARN_OF_NAME_COLLISIONS:
3925            warnings.warn(
3926                (
3927                    f"Adding decision {name!r}: Another decision with"
3928                    f" that name already exists."
3929                ),
3930                DecisionCollisionWarning
3931            )
3932
3933        dID = self._assignID()
3934
3935        # Add the decision
3936        self.add_node(
3937            dID,
3938            name=name,
3939            domain=domain,
3940            tags=tags,
3941            annotations=annotations
3942        )
3943        #TODO: Elide tags/annotations if they're empty?
3944
3945        # Track it in our `nameLookup` dictionary
3946        self.nameLookup.setdefault(name, []).append(dID)
3947
3948        return dID

Adds a decision to the graph, without any transitions yet. Each decision will be assigned an ID so name collisions are allowed, but it's usually best to keep names unique at least within each zone. If no domain is provided, the DEFAULT_DOMAIN will be used for the decision's domain. A dictionary of tags and/or a list of annotations (strings in both cases) may be provided.

Returns the newly-assigned DecisionID for the decision it created.

Emits a DecisionCollisionWarning if a decision with the provided name already exists and the WARN_OF_NAME_COLLISIONS global variable is set to True.

def addIdentifiedDecision( self, dID: int, name: str, domain: Optional[str] = None, tags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, annotations: Optional[List[str]] = None) -> None:
3950    def addIdentifiedDecision(
3951        self,
3952        dID: base.DecisionID,
3953        name: base.DecisionName,
3954        domain: Optional[base.Domain] = None,
3955        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
3956        annotations: Optional[List[base.Annotation]] = None
3957    ) -> None:
3958        """
3959        Adds a new decision to the graph using a specific decision ID,
3960        rather than automatically assigning a new decision ID like
3961        `addDecision` does. Otherwise works like `addDecision`.
3962
3963        Raises a `DecisionCollisionError` if the specified decision ID
3964        is already in use.
3965        """
3966        # Defaults
3967        if domain is None:
3968            domain = base.DEFAULT_DOMAIN
3969        if tags is None:
3970            tags = {}
3971        if annotations is None:
3972            annotations = []
3973
3974        # Error checking
3975        if dID in self.nodes:
3976            raise DecisionCollisionError(
3977                f"Cannot add a node with id {dID} and name {name!r}:"
3978                f" that ID is already used by node {self.identityOf(dID)}"
3979            )
3980
3981        if name in self.nameLookup and WARN_OF_NAME_COLLISIONS:
3982            warnings.warn(
3983                (
3984                    f"Adding decision {name!r}: Another decision with"
3985                    f" that name already exists."
3986                ),
3987                DecisionCollisionWarning
3988            )
3989
3990        # Add the decision
3991        self.add_node(
3992            dID,
3993            name=name,
3994            domain=domain,
3995            tags=tags,
3996            annotations=annotations
3997        )
3998        #TODO: Elide tags/annotations if they're empty?
3999
4000        # Track it in our `nameLookup` dictionary
4001        self.nameLookup.setdefault(name, []).append(dID)

Adds a new decision to the graph using a specific decision ID, rather than automatically assigning a new decision ID like addDecision does. Otherwise works like addDecision.

Raises a DecisionCollisionError if the specified decision ID is already in use.

def addTransition( self, fromDecision: Union[int, exploration.base.DecisionSpecifier, str], name: str, toDecision: Union[int, exploration.base.DecisionSpecifier, str], reciprocal: Optional[str] = None, tags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, annotations: Optional[List[str]] = None, revTags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, revAnnotations: Optional[List[str]] = None, requires: Optional[exploration.base.Requirement] = None, consequence: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None, revRequires: Optional[exploration.base.Requirement] = None, revConsequece: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None) -> None:
4003    def addTransition(
4004        self,
4005        fromDecision: base.AnyDecisionSpecifier,
4006        name: base.Transition,
4007        toDecision: base.AnyDecisionSpecifier,
4008        reciprocal: Optional[base.Transition] = None,
4009        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
4010        annotations: Optional[List[base.Annotation]] = None,
4011        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
4012        revAnnotations: Optional[List[base.Annotation]] = None,
4013        requires: Optional[base.Requirement] = None,
4014        consequence: Optional[base.Consequence] = None,
4015        revRequires: Optional[base.Requirement] = None,
4016        revConsequece: Optional[base.Consequence] = None
4017    ) -> None:
4018        """
4019        Adds a transition connecting two decisions. A specifier for each
4020        decision is required, as is a name for the transition. If a
4021        `reciprocal` is provided, a reciprocal edge will be added in the
4022        opposite direction using that name; by default only the specified
4023        edge is added. A `TransitionCollisionError` will be raised if the
4024        `reciprocal` matches the name of an existing edge at the
4025        destination decision.
4026
4027        Both decisions must already exist, or a `MissingDecisionError`
4028        will be raised.
4029
4030        A dictionary of tags and/or a list of annotations may be
4031        provided. Tags and/or annotations for the reverse edge may also
4032        be specified if one is being added.
4033
4034        The `requires`, `consequence`, `revRequires`, and `revConsequece`
4035        arguments specify requirements and/or consequences of the new
4036        outgoing and reciprocal edges.
4037
4038        An example:
4039
4040        >>> g = DecisionGraph()
4041        >>> g.addDecision('A')
4042        0
4043        >>> g.addDecision('B')
4044        1
4045        >>> g.addDecision('C')
4046        2
4047        >>> g.addTransition('A', 'up', 'B', 'down')
4048        >>> g.destinationsFrom('A')
4049        {'up': 1}
4050        >>> g.destinationsFrom('B')
4051        {'down': 0}
4052        >>> g.addTransition('A', 'right', 'C', 'left')
4053        >>> g.destinationsFrom('A')
4054        {'up': 1, 'right': 2}
4055        >>> g.destinationsFrom('C')
4056        {'left': 0}
4057        """
4058        # Defaults
4059        if tags is None:
4060            tags = {}
4061        if annotations is None:
4062            annotations = []
4063        if revTags is None:
4064            revTags = {}
4065        if revAnnotations is None:
4066            revAnnotations = []
4067
4068        # Error checking
4069        fromID = self.resolveDecision(fromDecision)
4070        toID = self.resolveDecision(toDecision)
4071
4072        # Note: have to check this first so we don't add the forward edge
4073        # and then error out after a side effect!
4074        if (
4075            reciprocal is not None
4076        and self.getDestination(toDecision, reciprocal) is not None
4077        ):
4078            raise TransitionCollisionError(
4079                f"Cannot add a transition from"
4080                f" {self.identityOf(fromDecision)} to"
4081                f" {self.identityOf(toDecision)} with reciprocal edge"
4082                f" {reciprocal!r}: {reciprocal!r} is already used as an"
4083                f" edge name at {self.identityOf(toDecision)}."
4084            )
4085
4086        # Add the edge
4087        self.add_edge(
4088            fromID,
4089            toID,
4090            key=name,
4091            tags=tags,
4092            annotations=annotations
4093        )
4094        self.setTransitionRequirement(fromID, name, requires)
4095        if consequence is not None:
4096            self.setConsequence(fromID, name, consequence)
4097        if reciprocal is not None:
4098            # Add the reciprocal edge
4099            self.add_edge(
4100                toID,
4101                fromID,
4102                key=reciprocal,
4103                tags=revTags,
4104                annotations=revAnnotations
4105            )
4106            self.setReciprocal(fromID, name, reciprocal)
4107            self.setTransitionRequirement(
4108                toID,
4109                reciprocal,
4110                revRequires
4111            )
4112            if revConsequece is not None:
4113                self.setConsequence(toID, reciprocal, revConsequece)

Adds a transition connecting two decisions. A specifier for each decision is required, as is a name for the transition. If a reciprocal is provided, a reciprocal edge will be added in the opposite direction using that name; by default only the specified edge is added. A TransitionCollisionError will be raised if the reciprocal matches the name of an existing edge at the destination decision.

Both decisions must already exist, or a MissingDecisionError will be raised.

A dictionary of tags and/or a list of annotations may be provided. Tags and/or annotations for the reverse edge may also be specified if one is being added.

The requires, consequence, revRequires, and revConsequece arguments specify requirements and/or consequences of the new outgoing and reciprocal edges.

An example:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addTransition('A', 'up', 'B', 'down')
>>> g.destinationsFrom('A')
{'up': 1}
>>> g.destinationsFrom('B')
{'down': 0}
>>> g.addTransition('A', 'right', 'C', 'left')
>>> g.destinationsFrom('A')
{'up': 1, 'right': 2}
>>> g.destinationsFrom('C')
{'left': 0}
def removeTransition( self, fromDecision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, removeReciprocal=False) -> Union[TransitionProperties, Tuple[TransitionProperties, TransitionProperties]]:
4115    def removeTransition(
4116        self,
4117        fromDecision: base.AnyDecisionSpecifier,
4118        transition: base.Transition,
4119        removeReciprocal=False
4120    ) -> Union[
4121        TransitionProperties,
4122        Tuple[TransitionProperties, TransitionProperties]
4123    ]:
4124        """
4125        Removes a transition. If `removeReciprocal` is true (False is the
4126        default) any reciprocal transition will also be removed (but no
4127        error will occur if there wasn't a reciprocal).
4128
4129        For each removed transition, *every* transition that targeted
4130        that transition as its reciprocal will have its reciprocal set to
4131        `None`, to avoid leaving any invalid reciprocal values.
4132
4133        Raises a `KeyError` if either the target decision or the target
4134        transition does not exist.
4135
4136        Returns a transition properties dictionary with the properties
4137        of the removed transition, or if `removeReciprocal` is true,
4138        returns a pair of such dictionaries for the target transition
4139        and its reciprocal.
4140
4141        ## Example
4142
4143        >>> g = DecisionGraph()
4144        >>> g.addDecision('A')
4145        0
4146        >>> g.addDecision('B')
4147        1
4148        >>> g.addTransition('A', 'up', 'B', 'down', tags={'wide'})
4149        >>> g.addTransition('A', 'in', 'B', 'out') # we won't touch this
4150        >>> g.addTransition('A', 'next', 'B')
4151        >>> g.setReciprocal('A', 'next', 'down', setBoth=False)
4152        >>> p = g.removeTransition('A', 'up')
4153        >>> p['tags']
4154        {'wide'}
4155        >>> g.destinationsFrom('A')
4156        {'in': 1, 'next': 1}
4157        >>> g.destinationsFrom('B')
4158        {'down': 0, 'out': 0}
4159        >>> g.getReciprocal('B', 'down') is None
4160        True
4161        >>> g.getReciprocal('A', 'next') # Asymmetrical left over
4162        'down'
4163        >>> g.getReciprocal('A', 'in') # not affected
4164        'out'
4165        >>> g.getReciprocal('B', 'out') # not affected
4166        'in'
4167        >>> # Now with removeReciprocal set to True
4168        >>> g.addTransition('A', 'up', 'B') # add this back in
4169        >>> g.setReciprocal('A', 'up', 'down') # sets both
4170        >>> p = g.removeTransition('A', 'up', removeReciprocal=True)
4171        >>> g.destinationsFrom('A')
4172        {'in': 1, 'next': 1}
4173        >>> g.destinationsFrom('B')
4174        {'out': 0}
4175        >>> g.getReciprocal('A', 'next') is None
4176        True
4177        >>> g.getReciprocal('A', 'in') # not affected
4178        'out'
4179        >>> g.getReciprocal('B', 'out') # not affected
4180        'in'
4181        >>> g.removeTransition('A', 'none')
4182        Traceback (most recent call last):
4183        ...
4184        exploration.core.MissingTransitionError...
4185        >>> g.removeTransition('Z', 'nope')
4186        Traceback (most recent call last):
4187        ...
4188        exploration.core.MissingDecisionError...
4189        """
4190        # Resolve target ID
4191        fromID = self.resolveDecision(fromDecision)
4192
4193        # raises if either is missing:
4194        destination = self.destination(fromID, transition)
4195        reciprocal = self.getReciprocal(fromID, transition)
4196
4197        # Get dictionaries of parallel & antiparallel edges to be
4198        # checked for invalid reciprocals after removing edges
4199        # Note: these will update live as we remove edges
4200        allAntiparallel = self[destination][fromID]
4201        allParallel = self[fromID][destination]
4202
4203        # Remove the target edge
4204        fProps = self.getTransitionProperties(fromID, transition)
4205        self.remove_edge(fromID, destination, transition)
4206
4207        # Clean up any dangling reciprocal values
4208        for tProps in allAntiparallel.values():
4209            if tProps.get('reciprocal') == transition:
4210                del tProps['reciprocal']
4211
4212        # Remove the reciprocal if requested
4213        if removeReciprocal and reciprocal is not None:
4214            rProps = self.getTransitionProperties(destination, reciprocal)
4215            self.remove_edge(destination, fromID, reciprocal)
4216
4217            # Clean up any dangling reciprocal values
4218            for tProps in allParallel.values():
4219                if tProps.get('reciprocal') == reciprocal:
4220                    del tProps['reciprocal']
4221
4222            return (fProps, rProps)
4223        else:
4224            return fProps

Removes a transition. If removeReciprocal is true (False is the default) any reciprocal transition will also be removed (but no error will occur if there wasn't a reciprocal).

For each removed transition, every transition that targeted that transition as its reciprocal will have its reciprocal set to None, to avoid leaving any invalid reciprocal values.

Raises a KeyError if either the target decision or the target transition does not exist.

Returns a transition properties dictionary with the properties of the removed transition, or if removeReciprocal is true, returns a pair of such dictionaries for the target transition and its reciprocal.

Example

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addTransition('A', 'up', 'B', 'down', tags={'wide'})
>>> g.addTransition('A', 'in', 'B', 'out') # we won't touch this
>>> g.addTransition('A', 'next', 'B')
>>> g.setReciprocal('A', 'next', 'down', setBoth=False)
>>> p = g.removeTransition('A', 'up')
>>> p['tags']
{'wide'}
>>> g.destinationsFrom('A')
{'in': 1, 'next': 1}
>>> g.destinationsFrom('B')
{'down': 0, 'out': 0}
>>> g.getReciprocal('B', 'down') is None
True
>>> g.getReciprocal('A', 'next') # Asymmetrical left over
'down'
>>> g.getReciprocal('A', 'in') # not affected
'out'
>>> g.getReciprocal('B', 'out') # not affected
'in'
>>> # Now with removeReciprocal set to True
>>> g.addTransition('A', 'up', 'B') # add this back in
>>> g.setReciprocal('A', 'up', 'down') # sets both
>>> p = g.removeTransition('A', 'up', removeReciprocal=True)
>>> g.destinationsFrom('A')
{'in': 1, 'next': 1}
>>> g.destinationsFrom('B')
{'out': 0}
>>> g.getReciprocal('A', 'next') is None
True
>>> g.getReciprocal('A', 'in') # not affected
'out'
>>> g.getReciprocal('B', 'out') # not affected
'in'
>>> g.removeTransition('A', 'none')
Traceback (most recent call last):
...
MissingTransitionError...
>>> g.removeTransition('Z', 'nope')
Traceback (most recent call last):
...
MissingDecisionError...
def addMechanism( self, name: str, where: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None) -> int:
4226    def addMechanism(
4227        self,
4228        name: base.MechanismName,
4229        where: Optional[base.AnyDecisionSpecifier] = None
4230    ) -> base.MechanismID:
4231        """
4232        Creates a new mechanism with the given name at the specified
4233        decision, returning its assigned ID. If `where` is `None`, it
4234        creates a global mechanism. Raises a `MechanismCollisionError`
4235        if a mechanism with the same name already exists at a specified
4236        decision (or already exists as a global mechanism).
4237
4238        Note that if the decision is deleted, the mechanism will be as
4239        well.
4240
4241        Since `MechanismState`s are not tracked by `DecisionGraph`s but
4242        instead are part of a `State`, the mechanism won't be in any
4243        particular state, which means it will be treated as being in the
4244        `base.DEFAULT_MECHANISM_STATE`.
4245        """
4246        if where is None:
4247            mechs = self.globalMechanisms
4248            dID = None
4249        else:
4250            dID = self.resolveDecision(where)
4251            mechs = self.nodes[dID].setdefault('mechanisms', {})
4252
4253        if name in mechs:
4254            if dID is None:
4255                raise MechanismCollisionError(
4256                    f"A global mechanism named {name!r} already exists."
4257                )
4258            else:
4259                raise MechanismCollisionError(
4260                    f"A mechanism named {name!r} already exists at"
4261                    f" decision {self.identityOf(dID)}."
4262                )
4263
4264        mID = self._assignMechanismID()
4265        mechs[name] = mID
4266        self.mechanisms[mID] = (dID, name)
4267        return mID

Creates a new mechanism with the given name at the specified decision, returning its assigned ID. If where is None, it creates a global mechanism. Raises a MechanismCollisionError if a mechanism with the same name already exists at a specified decision (or already exists as a global mechanism).

Note that if the decision is deleted, the mechanism will be as well.

Since MechanismStates are not tracked by DecisionGraphs but instead are part of a State, the mechanism won't be in any particular state, which means it will be treated as being in the base.DEFAULT_MECHANISM_STATE.

def mechanismsAt( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> Dict[str, int]:
4269    def mechanismsAt(
4270        self,
4271        decision: base.AnyDecisionSpecifier
4272    ) -> Dict[base.MechanismName, base.MechanismID]:
4273        """
4274        Returns a dictionary mapping mechanism names to their IDs for
4275        all mechanisms at the specified decision.
4276        """
4277        dID = self.resolveDecision(decision)
4278
4279        return self.nodes[dID]['mechanisms']

Returns a dictionary mapping mechanism names to their IDs for all mechanisms at the specified decision.

def mechanismDetails(self, mID: int) -> Optional[Tuple[Optional[int], str]]:
4281    def mechanismDetails(
4282        self,
4283        mID: base.MechanismID
4284    ) -> Optional[Tuple[Optional[base.DecisionID], base.MechanismName]]:
4285        """
4286        Returns a tuple containing the decision ID and mechanism name
4287        for the specified mechanism. Returns `None` if there is no
4288        mechanism with that ID. For global mechanisms, `None` is used in
4289        place of a decision ID.
4290        """
4291        return self.mechanisms.get(mID)

Returns a tuple containing the decision ID and mechanism name for the specified mechanism. Returns None if there is no mechanism with that ID. For global mechanisms, None is used in place of a decision ID.

def deleteMechanism(self, mID: int) -> None:
4293    def deleteMechanism(self, mID: base.MechanismID) -> None:
4294        """
4295        Deletes the specified mechanism.
4296        """
4297        name, dID = self.mechanisms.pop(mID)
4298
4299        del self.nodes[dID]['mechanisms'][name]

Deletes the specified mechanism.

def localLookup( self, startFrom: Union[int, exploration.base.DecisionSpecifier, str, Collection[Union[int, exploration.base.DecisionSpecifier, str]]], findAmong: Callable[[DecisionGraph, Union[Set[int], str]], Optional[~LookupResult]], fallbackLayerName: Optional[str] = 'fallback', fallbackToAllDecisions: bool = True) -> Optional[~LookupResult]:
4301    def localLookup(
4302        self,
4303        startFrom: Union[
4304            base.AnyDecisionSpecifier,
4305            Collection[base.AnyDecisionSpecifier]
4306        ],
4307        findAmong: Callable[
4308            ['DecisionGraph', Union[Set[base.DecisionID], str]],
4309            Optional[LookupResult]
4310        ],
4311        fallbackLayerName: Optional[str] = "fallback",
4312        fallbackToAllDecisions: bool = True
4313    ) -> Optional[LookupResult]:
4314        """
4315        Looks up some kind of result in the graph by starting from a
4316        base set of decisions and widening the search iteratively based
4317        on zones. This first searches for result(s) in the set of
4318        decisions given, then in the set of all decisions which are in
4319        level-0 zones containing those decisions, then in level-1 zones,
4320        etc. When it runs out of relevant zones, it will check all
4321        decisions which are in any domain that a decision from the
4322        initial search set is in, and then if `fallbackLayerName` is a
4323        string, it will provide that string instead of a set of decision
4324        IDs to the `findAmong` function as the next layer to search.
4325        After the `fallbackLayerName` is used, if
4326        `fallbackToAllDecisions` is `True` (the default) a final search
4327        will be run on all decisions in the graph. The provided
4328        `findAmong` function is called on each successive decision ID
4329        set, until it generates a non-`None` result. We stop and return
4330        that non-`None` result as soon as one is generated. But if none
4331        of the decision sets consulted generate non-`None` results, then
4332        the entire result will be `None`.
4333        """
4334        # Normalize starting decisions to a set
4335        if isinstance(startFrom, (int, str, base.DecisionSpecifier)):
4336            startFrom = set([startFrom])
4337
4338        # Resolve decision IDs; convert to set
4339        searchArea: Union[Set[base.DecisionID], str] = set(
4340            self.resolveDecision(spec) for spec in startFrom
4341        )
4342
4343        # Find all ancestor zones & all relevant domains
4344        allAncestors = set()
4345        relevantDomains = set()
4346        for startingDecision in searchArea:
4347            allAncestors |= self.zoneAncestors(startingDecision)
4348            relevantDomains.add(self.domainFor(startingDecision))
4349
4350        # Build layers dictionary
4351        ancestorLayers: Dict[int, Set[base.Zone]] = {}
4352        for zone in allAncestors:
4353            info = self.getZoneInfo(zone)
4354            assert info is not None
4355            level = info.level
4356            ancestorLayers.setdefault(level, set()).add(zone)
4357
4358        searchLayers: LookupLayersList = (
4359            cast(LookupLayersList, [None])
4360          + cast(LookupLayersList, sorted(ancestorLayers.keys()))
4361          + cast(LookupLayersList, ["domains"])
4362        )
4363        if fallbackLayerName is not None:
4364            searchLayers.append("fallback")
4365
4366        if fallbackToAllDecisions:
4367            searchLayers.append("all")
4368
4369        # Continue our search through zone layers
4370        for layer in searchLayers:
4371            # Update search area on subsequent iterations
4372            if layer == "domains":
4373                searchArea = set()
4374                for relevant in relevantDomains:
4375                    searchArea |= self.allDecisionsInDomain(relevant)
4376            elif layer == "fallback":
4377                assert fallbackLayerName is not None
4378                searchArea = fallbackLayerName
4379            elif layer == "all":
4380                searchArea = set(self.nodes)
4381            elif layer is not None:
4382                layer = cast(int, layer)  # must be an integer
4383                searchZones = ancestorLayers[layer]
4384                searchArea = set()
4385                for zone in searchZones:
4386                    searchArea |= self.allDecisionsInZone(zone)
4387            # else it's the first iteration and we use the starting
4388            # searchArea
4389
4390            try:
4391                searchResult: Optional[LookupResult] = findAmong(
4392                    self,
4393                    searchArea
4394                )
4395            except Exception as e:
4396                note = f" (Search started from: {startFrom!r})"
4397                if hasattr(e, "add_note"):
4398                    e.add_note(note)
4399                else:
4400                    e.args = (e.args[0] + note,) + e.args[1:]
4401                raise e
4402
4403            if searchResult is not None:
4404                return searchResult
4405
4406        # Didn't find any non-None results.
4407        return None

Looks up some kind of result in the graph by starting from a base set of decisions and widening the search iteratively based on zones. This first searches for result(s) in the set of decisions given, then in the set of all decisions which are in level-0 zones containing those decisions, then in level-1 zones, etc. When it runs out of relevant zones, it will check all decisions which are in any domain that a decision from the initial search set is in, and then if fallbackLayerName is a string, it will provide that string instead of a set of decision IDs to the findAmong function as the next layer to search. After the fallbackLayerName is used, if fallbackToAllDecisions is True (the default) a final search will be run on all decisions in the graph. The provided findAmong function is called on each successive decision ID set, until it generates a non-None result. We stop and return that non-None result as soon as one is generated. But if none of the decision sets consulted generate non-None results, then the entire result will be None.

@staticmethod
def uniqueMechanismFinder( name: str) -> Callable[[DecisionGraph, Union[Set[int], str]], Optional[int]]:
4409    @staticmethod
4410    def uniqueMechanismFinder(name: base.MechanismName) -> Callable[
4411        ['DecisionGraph', Union[Set[base.DecisionID], str]],
4412        Optional[base.MechanismID]
4413    ]:
4414        """
4415        Returns a search function that looks for the given mechanism ID,
4416        suitable for use with `localLookup`. The finder will raise a
4417        `AmbiguousMechanismError` if it finds more than one mechanism
4418        with the specified name at the same level of the search.
4419        """
4420        def namedMechanismFinder(
4421            graph: 'DecisionGraph',
4422            searchIn: Union[Set[base.DecisionID], str]
4423        ) -> Optional[base.MechanismID]:
4424            """
4425            Generated finder function for `localLookup` to find a unique
4426            mechanism by name.
4427            """
4428            candidates: List[base.MechanismID] = []
4429
4430            if searchIn == "fallback":
4431                if name in graph.globalMechanisms:
4432                    candidates = [graph.globalMechanisms[name]]
4433
4434            else:
4435                assert isinstance(searchIn, set)
4436                for dID in searchIn:
4437                    mechs = graph.nodes[dID].get('mechanisms', {})
4438                    if name in mechs:
4439                        candidates.append(mechs[name])
4440
4441            if len(candidates) > 1:
4442                raise AmbiguousMechanismError(
4443                    f"There are {len(candidates)} mechanisms named {name!r}"
4444                    f" in the search area ({len(searchIn)} decisions(s))."
4445                )
4446            elif len(candidates) == 1:
4447                return candidates[0]
4448            else:
4449                return None
4450
4451        return namedMechanismFinder

Returns a search function that looks for the given mechanism ID, suitable for use with localLookup. The finder will raise a AmbiguousMechanismError if it finds more than one mechanism with the specified name at the same level of the search.

def lookupMechanism( self, startFrom: Union[int, exploration.base.DecisionSpecifier, str, Collection[Union[int, exploration.base.DecisionSpecifier, str]]], name: str) -> int:
4453    def lookupMechanism(
4454        self,
4455        startFrom: Union[
4456            base.AnyDecisionSpecifier,
4457            Collection[base.AnyDecisionSpecifier]
4458        ],
4459        name: base.MechanismName
4460    ) -> base.MechanismID:
4461        """
4462        Looks up the mechanism with the given name 'closest' to the
4463        given decision or set of decisions. First it looks for a
4464        mechanism with that name that's at one of those decisions. Then
4465        it starts looking in level-0 zones which contain any of them,
4466        then in level-1 zones, and so on. If it finds two mechanisms
4467        with the target name during the same search pass, it raises a
4468        `AmbiguousMechanismError`, but if it finds one it returns it.
4469        Raises a `MissingMechanismError` if there is no mechanisms with
4470        that name among global mechanisms (searched after the last
4471        applicable level of zones) or anywhere in the graph (which is the
4472        final level of search after checking global mechanisms).
4473
4474        For example:
4475
4476        >>> d = DecisionGraph()
4477        >>> d.addDecision('A')
4478        0
4479        >>> d.addDecision('B')
4480        1
4481        >>> d.addDecision('C')
4482        2
4483        >>> d.addDecision('D')
4484        3
4485        >>> d.addDecision('E')
4486        4
4487        >>> d.addMechanism('switch', 'A')
4488        0
4489        >>> d.addMechanism('switch', 'B')
4490        1
4491        >>> d.addMechanism('switch', 'C')
4492        2
4493        >>> d.addMechanism('lever', 'D')
4494        3
4495        >>> d.addMechanism('lever', None)  # global
4496        4
4497        >>> d.createZone('Z1', 0)
4498        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
4499 annotations=[])
4500        >>> d.createZone('Z2', 0)
4501        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
4502 annotations=[])
4503        >>> d.createZone('Zup', 1)
4504        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
4505 annotations=[])
4506        >>> d.addDecisionToZone('A', 'Z1')
4507        >>> d.addDecisionToZone('B', 'Z1')
4508        >>> d.addDecisionToZone('C', 'Z2')
4509        >>> d.addDecisionToZone('D', 'Z2')
4510        >>> d.addDecisionToZone('E', 'Z1')
4511        >>> d.addZoneToZone('Z1', 'Zup')
4512        >>> d.addZoneToZone('Z2', 'Zup')
4513        >>> d.lookupMechanism(set(), 'switch')  # 3x among all decisions
4514        Traceback (most recent call last):
4515        ...
4516        exploration.core.AmbiguousMechanismError...
4517        >>> d.lookupMechanism(set(), 'lever')  # 1x global > 1x all
4518        4
4519        >>> d.lookupMechanism({'D'}, 'lever')  # local
4520        3
4521        >>> d.lookupMechanism({'A'}, 'lever')  # found at D via Zup
4522        3
4523        >>> d.lookupMechanism({'A', 'D'}, 'lever')  # local again
4524        3
4525        >>> d.lookupMechanism({'A'}, 'switch')  # local
4526        0
4527        >>> d.lookupMechanism({'B'}, 'switch')  # local
4528        1
4529        >>> d.lookupMechanism({'C'}, 'switch')  # local
4530        2
4531        >>> d.lookupMechanism({'A', 'B'}, 'switch')  # ambiguous
4532        Traceback (most recent call last):
4533        ...
4534        exploration.core.AmbiguousMechanismError...
4535        >>> d.lookupMechanism({'A', 'B', 'C'}, 'switch')  # ambiguous
4536        Traceback (most recent call last):
4537        ...
4538        exploration.core.AmbiguousMechanismError...
4539        >>> d.lookupMechanism({'B', 'D'}, 'switch')  # not ambiguous
4540        1
4541        >>> d.lookupMechanism({'E', 'D'}, 'switch')  # ambiguous at L0 zone
4542        Traceback (most recent call last):
4543        ...
4544        exploration.core.AmbiguousMechanismError...
4545        >>> d.lookupMechanism({'E'}, 'switch')  # ambiguous at L0 zone
4546        Traceback (most recent call last):
4547        ...
4548        exploration.core.AmbiguousMechanismError...
4549        >>> d.lookupMechanism({'D'}, 'switch')  # found at L0 zone
4550        2
4551        """
4552        result = self.localLookup(
4553            startFrom,
4554            DecisionGraph.uniqueMechanismFinder(name)
4555        )
4556        if result is None:
4557            raise MissingMechanismError(
4558                f"No mechanism named {name!r}"
4559            )
4560        else:
4561            return result

Looks up the mechanism with the given name 'closest' to the given decision or set of decisions. First it looks for a mechanism with that name that's at one of those decisions. Then it starts looking in level-0 zones which contain any of them, then in level-1 zones, and so on. If it finds two mechanisms with the target name during the same search pass, it raises a AmbiguousMechanismError, but if it finds one it returns it. Raises a MissingMechanismError if there is no mechanisms with that name among global mechanisms (searched after the last applicable level of zones) or anywhere in the graph (which is the final level of search after checking global mechanisms).

For example:

>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addDecision('C')
2
>>> d.addDecision('D')
3
>>> d.addDecision('E')
4
>>> d.addMechanism('switch', 'A')
0
>>> d.addMechanism('switch', 'B')
1
>>> d.addMechanism('switch', 'C')
2
>>> d.addMechanism('lever', 'D')
3
>>> d.addMechanism('lever', None)  # global
4
>>> d.createZone('Z1', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('Zup', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('A', 'Z1')
>>> d.addDecisionToZone('B', 'Z1')
>>> d.addDecisionToZone('C', 'Z2')
>>> d.addDecisionToZone('D', 'Z2')
>>> d.addDecisionToZone('E', 'Z1')
>>> d.addZoneToZone('Z1', 'Zup')
>>> d.addZoneToZone('Z2', 'Zup')
>>> d.lookupMechanism(set(), 'switch')  # 3x among all decisions
Traceback (most recent call last):
...
AmbiguousMechanismError...
>>> d.lookupMechanism(set(), 'lever')  # 1x global > 1x all
4
>>> d.lookupMechanism({'D'}, 'lever')  # local
3
>>> d.lookupMechanism({'A'}, 'lever')  # found at D via Zup
3
>>> d.lookupMechanism({'A', 'D'}, 'lever')  # local again
3
>>> d.lookupMechanism({'A'}, 'switch')  # local
0
>>> d.lookupMechanism({'B'}, 'switch')  # local
1
>>> d.lookupMechanism({'C'}, 'switch')  # local
2
>>> d.lookupMechanism({'A', 'B'}, 'switch')  # ambiguous
Traceback (most recent call last):
...
AmbiguousMechanismError...
>>> d.lookupMechanism({'A', 'B', 'C'}, 'switch')  # ambiguous
Traceback (most recent call last):
...
AmbiguousMechanismError...
>>> d.lookupMechanism({'B', 'D'}, 'switch')  # not ambiguous
1
>>> d.lookupMechanism({'E', 'D'}, 'switch')  # ambiguous at L0 zone
Traceback (most recent call last):
...
AmbiguousMechanismError...
>>> d.lookupMechanism({'E'}, 'switch')  # ambiguous at L0 zone
Traceback (most recent call last):
...
AmbiguousMechanismError...
>>> d.lookupMechanism({'D'}, 'switch')  # found at L0 zone
2
def resolveMechanism( self, specifier: Union[int, str, exploration.base.MechanismSpecifier], startFrom: Union[NoneType, int, exploration.base.DecisionSpecifier, str, Collection[Union[int, exploration.base.DecisionSpecifier, str]]] = None) -> int:
4563    def resolveMechanism(
4564        self,
4565        specifier: base.AnyMechanismSpecifier,
4566        startFrom: Union[
4567            None,
4568            base.AnyDecisionSpecifier,
4569            Collection[base.AnyDecisionSpecifier]
4570        ] = None
4571    ) -> base.MechanismID:
4572        """
4573        Works like `lookupMechanism`, except it accepts a
4574        `base.AnyMechanismSpecifier` which may have position information
4575        baked in, and so the `startFrom` information is optional. If
4576        position information isn't specified in the mechanism specifier
4577        and startFrom is not provided, the mechanism is searched for at
4578        the global scope and then in the entire graph. On the other
4579        hand, if the specifier includes any position information, the
4580        startFrom value provided here will be ignored.
4581        """
4582        if isinstance(specifier, base.MechanismID):
4583            return specifier
4584
4585        elif isinstance(specifier, base.MechanismName):
4586            if startFrom is None:
4587                startFrom = set()
4588            return self.lookupMechanism(startFrom, specifier)
4589
4590        elif isinstance(specifier, base.MechanismSpecifier):
4591            domain, zone, decision, mechanism = specifier
4592            if domain is None and zone is None and decision is None:
4593                if startFrom is None:
4594                    startFrom = set()
4595                return self.lookupMechanism(startFrom, mechanism)
4596
4597            elif isinstance(decision, base.DecisionID):
4598                # Specifying a decision ID restricts the mechanism to
4599                # appear at exactly that decision and NOT be global...
4600                if domain is not None or zone is not None:
4601                    warnings.warn(
4602                        (
4603                            f"Mechanism specifier includes domain and/or"
4604                            f" zone in addition to decision-by-ID:"
4605                            f" {specifier!r}"
4606                        ),
4607                        InvalidMechanismSpecifierWarning
4608                    )
4609
4610                mechs = self.nodes[decision].get('mechanisms', {})
4611                found = mechs.get(mechanism)
4612                if found is None:
4613                    raise MissingMechanismError(
4614                        f"No mechanism named {mechanism!r} at specific"
4615                        f" decision {self.identityOf(decision)}"
4616                    )
4617                return found
4618
4619            elif decision is not None:
4620                startFrom = self.resolveDecisions(
4621                    base.DecisionSpecifier(domain, zone, decision)
4622                )
4623                return self.lookupMechanism(startFrom, mechanism)
4624
4625            else:  # decision is None but domain and/or zone aren't
4626                startFrom = set()
4627                if zone is not None:
4628                    baseStart = self.allDecisionsInZone(zone)
4629                else:
4630                    baseStart = set(self)
4631
4632                if domain is None:
4633                    startFrom = baseStart
4634                else:
4635                    for dID in baseStart:
4636                        if self.domainFor(dID) == domain:
4637                            startFrom.add(dID)
4638                return self.lookupMechanism(startFrom, mechanism)
4639
4640        else:
4641            raise TypeError(
4642                f"Invalid mechanism specifier: {repr(specifier)}"
4643                f"\n(Must be a mechanism ID, mechanism name, or"
4644                f" mechanism specifier tuple)"
4645            )

Works like lookupMechanism, except it accepts a base.AnyMechanismSpecifier which may have position information baked in, and so the startFrom information is optional. If position information isn't specified in the mechanism specifier and startFrom is not provided, the mechanism is searched for at the global scope and then in the entire graph. On the other hand, if the specifier includes any position information, the startFrom value provided here will be ignored.

def legibleMechanismSpecifier( self, mID: int, minimal: bool = False) -> Union[exploration.base.MechanismSpecifier, int]:
4647    def legibleMechanismSpecifier(
4648        self,
4649        mID: base.MechanismID,
4650        minimal: bool = False
4651    ) -> Union[base.MechanismSpecifier, base.MechanismID]:
4652        '''
4653        Given a mechanism ID, returns an unambiguous
4654        `base.MechanismSpecifier` for that mechanism, including
4655        domain/zone/decision-name parts as necessary. If there is no
4656        unambiguous specifier for that mechanism, returns the mechanism
4657        ID as-is. Also returns the ID as-is if no mechanism with that ID
4658        exists.
4659
4660        Set `minimal` to True (default is `False`) to use a minimal
4661        unambiguous specifier (more likely to be made ambiguous by
4662        future decision/mechanism additions).
4663
4664        Some examples:
4665
4666        >>> g = DecisionGraph()
4667        >>> g.addDecision('A')
4668        0
4669        >>> g.addDecision('B')
4670        1
4671        >>> g.addDecision('C')
4672        2
4673        >>> g.addDecision('A')
4674        3
4675        >>> g.addDecision('C')
4676        4
4677        >>> g.createZone('Z', 0)
4678        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
4679 annotations=[])
4680        >>> g.createZone('Q', 0)
4681        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
4682 annotations=[])
4683        >>> g.addDecisionToZone(0, 'Z')
4684        >>> g.addDecisionToZone('B', 'Z')
4685        >>> g.addDecisionToZone(3, 'Q')
4686        >>> g.addMechanism('global')
4687        0
4688        >>> g.addMechanism('door', 0)
4689        1
4690        >>> g.addMechanism('door', 2)
4691        2
4692        >>> g.addMechanism('block', 2)
4693        3
4694        >>> g.addMechanism('lever', 0)
4695        4
4696        >>> g.addMechanism('lever', 'B')
4697        5
4698        >>> g.addMechanism('door', 3)
4699        6
4700        >>> g.addMechanism('block', 4)
4701        7
4702        >>> g.legibleMechanismSpecifier(0)
4703        MechanismSpecifier(domain=None, zone=None, decision=None, name='global')
4704        >>> g.legibleMechanismSpecifier(1)
4705        MechanismSpecifier(domain='main', zone='Z', decision='A', name='door')
4706        >>> g.legibleMechanismSpecifier(2)
4707        MechanismSpecifier(domain='main', zone=None, decision='C', name='door')
4708        >>> g.legibleMechanismSpecifier(3)  # ambiguous with 'block' at C[4]
4709        3
4710        >>> g.legibleMechanismSpecifier(4)
4711        MechanismSpecifier(domain='main', zone='Z', decision='A', name='lever')
4712        >>> g.legibleMechanismSpecifier(5)
4713        MechanismSpecifier(domain='main', zone='Z', decision='B', name='lever')
4714        >>> g.legibleMechanismSpecifier(6)
4715        MechanismSpecifier(domain='main', zone='Q', decision='A', name='door')
4716        >>> g.legibleMechanismSpecifier(7)  # ambiguous with 'block' at C[2]
4717        7
4718        '''
4719        details = self.mechanismDetails(mID)
4720        if details is None:
4721            return mID
4722        elif not minimal:
4723            # Go straight to as full a specifier as we can
4724            dID, mName = details
4725            if dID is None:
4726                maybe = base.MechanismSpecifier(None, None, None, mName)
4727                try:
4728                    resolved = self.resolveMechanism(maybe)
4729                    if resolved == mID:
4730                        return maybe
4731                    else:
4732                        return mID
4733                    # Else got wrong one without specifying more info
4734                except (
4735                    AmbiguousMechanismError,
4736                    AmbiguousDecisionSpecifierError
4737                ):
4738                    # Not specific enough
4739                    return mID
4740                except MissingMechanismError:
4741                    # Nothing findable with that name; return mID as-is
4742                    # Note: This *should* be caught by case above instead I
4743                    # think, but doesn't hurt to be defensive here
4744                    return mID
4745            else:
4746                # Look up decision's info
4747                dInfo = self.decisionInfo(dID)
4748                dName = dInfo["name"]
4749                dDomain = dInfo["domain"]
4750
4751                # Include domain and first zone we can find that's
4752                # unambiguous
4753                for zone in self.zoneParents(dID):
4754                    maybe = base.MechanismSpecifier(
4755                        dDomain,
4756                        zone,
4757                        dName,
4758                        mName
4759                    )
4760                    try:
4761                        resolved = self.resolveMechanism(maybe)
4762                        if resolved == mID:
4763                            return maybe
4764                        else:
4765                            # Got wrong one for this zone
4766                            continue
4767                    except (
4768                        AmbiguousMechanismError,
4769                        AmbiguousDecisionSpecifierError
4770                    ):
4771                        # Not specific enough
4772                        continue
4773                    except MissingMechanismError:
4774                        # Shouldn't be possible, but just in case
4775                        return mID
4776
4777                # Try without a zone
4778                maybe = base.MechanismSpecifier(
4779                    dDomain,
4780                    None,
4781                    dName,
4782                    mName
4783                )
4784                try:
4785                    resolved = self.resolveMechanism(maybe)
4786                    if resolved == mID:
4787                        return maybe
4788                    else:
4789                        # Got wrong one; no altenratives
4790                        return mID
4791                except (
4792                    AmbiguousMechanismError,
4793                    AmbiguousDecisionSpecifierError
4794                ) as e:
4795                    # Not specific enough
4796                    return mID
4797                except MissingMechanismError:
4798                    # Shouldn't be possible, but just in case
4799                    return mID
4800        else:
4801            # Minimal requested; details were available
4802            dID, mName = details
4803            # First try bare specifier with just name. Should catch
4804            # global mechanisms as well as unique locals
4805            maybe = base.MechanismSpecifier(None, None, None, mName)
4806            try:
4807                resolved = self.resolveMechanism(maybe)
4808                if resolved == mID:
4809                    return maybe
4810                # Else got wrong one without specifying more info
4811            except (
4812                AmbiguousMechanismError,
4813                AmbiguousDecisionSpecifierError
4814            ):
4815                # Not specific enough
4816                pass
4817            except MissingMechanismError:
4818                # Nothing findable with that name; return mID as-is
4819                # Note: This *should* be caught by case above instead I
4820                # think, but doesn't hurt to be defensive here
4821                return mID
4822
4823            # A global mechanism we couldn't resolve
4824            if dID is None:
4825                return mID
4826
4827            # Look up decision's info
4828            dInfo = self.decisionInfo(dID)
4829            dName = dInfo["name"]
4830            dDomain = dInfo["domain"]
4831
4832            # Try with just decision name
4833            maybe = base.MechanismSpecifier(None, None, dName, mName)
4834            try:
4835                resolved = self.resolveMechanism(maybe)
4836                if resolved == mID:
4837                    return maybe
4838                # Else got wrong one without specifying more info
4839            except (
4840                AmbiguousMechanismError,
4841                AmbiguousDecisionSpecifierError
4842            ):
4843                # Not specific enough
4844                pass
4845            except MissingMechanismError:
4846                # Shouldn't be possible, but just in case
4847                return mID
4848
4849            # Try each possible direct parent zone
4850            for zone in self.zoneParents(dID):
4851                maybe = base.MechanismSpecifier(None, zone, dName, mName)
4852                try:
4853                    resolved = self.resolveMechanism(maybe)
4854                    if resolved == mID:
4855                        return maybe
4856                    # Else got wrong one without specifying more info
4857                except (
4858                    AmbiguousMechanismError,
4859                    AmbiguousDecisionSpecifierError
4860                ):
4861                    # Not specific enough
4862                    pass
4863                except MissingMechanismError:
4864                    # Shouldn't be possible, but just in case
4865                    return mID
4866
4867            # No zones or none specific enough: try adding domain w/
4868            # each zone
4869            for zone in self.zoneParents(dID):
4870                maybe = base.MechanismSpecifier(dDomain, zone, dName, mName)
4871                try:
4872                    resolved = self.resolveMechanism(maybe)
4873                    if resolved == mID:
4874                        return maybe
4875                    # Else got wrong one without specifying more info
4876                except (
4877                    AmbiguousMechanismError,
4878                    AmbiguousDecisionSpecifierError
4879                ):
4880                    # Not specific enough
4881                    pass
4882                except MissingMechanismError:
4883                    # Shouldn't be possible, but just in case
4884                    return mID
4885
4886            # Nothing but ID is specific enough
4887            return mID

Given a mechanism ID, returns an unambiguous base.MechanismSpecifier for that mechanism, including domain/zone/decision-name parts as necessary. If there is no unambiguous specifier for that mechanism, returns the mechanism ID as-is. Also returns the ID as-is if no mechanism with that ID exists.

Set minimal to True (default is False) to use a minimal unambiguous specifier (more likely to be made ambiguous by future decision/mechanism additions).

Some examples:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addDecision('A')
3
>>> g.addDecision('C')
4
>>> g.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('Q', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone(0, 'Z')
>>> g.addDecisionToZone('B', 'Z')
>>> g.addDecisionToZone(3, 'Q')
>>> g.addMechanism('global')
0
>>> g.addMechanism('door', 0)
1
>>> g.addMechanism('door', 2)
2
>>> g.addMechanism('block', 2)
3
>>> g.addMechanism('lever', 0)
4
>>> g.addMechanism('lever', 'B')
5
>>> g.addMechanism('door', 3)
6
>>> g.addMechanism('block', 4)
7
>>> g.legibleMechanismSpecifier(0)
MechanismSpecifier(domain=None, zone=None, decision=None, name='global')
>>> g.legibleMechanismSpecifier(1)
MechanismSpecifier(domain='main', zone='Z', decision='A', name='door')
>>> g.legibleMechanismSpecifier(2)
MechanismSpecifier(domain='main', zone=None, decision='C', name='door')
>>> g.legibleMechanismSpecifier(3)  # ambiguous with 'block' at C[4]
3
>>> g.legibleMechanismSpecifier(4)
MechanismSpecifier(domain='main', zone='Z', decision='A', name='lever')
>>> g.legibleMechanismSpecifier(5)
MechanismSpecifier(domain='main', zone='Z', decision='B', name='lever')
>>> g.legibleMechanismSpecifier(6)
MechanismSpecifier(domain='main', zone='Q', decision='A', name='door')
>>> g.legibleMechanismSpecifier(7)  # ambiguous with 'block' at C[2]
7
def walkConsequenceMechanisms( self, consequence: List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]], searchFrom: Set[int], replaceNames: int = 0) -> Generator[int, NoneType, NoneType]:
4889    def walkConsequenceMechanisms(
4890        self,
4891        consequence: base.Consequence,
4892        searchFrom: Set[base.DecisionID],
4893        replaceNames: int = 0
4894    ) -> Generator[base.MechanismID, None, None]:
4895        """
4896        Yields each requirement in the given `base.Consequence`,
4897        including those in `base.Condition`s, `base.ConditionalSkill`s
4898        within `base.Challenge`s, and those set or toggled by
4899        `base.Effect`s. The `searchFrom` argument specifies where to
4900        start searching for mechanisms, since requirements include them
4901        by name, not by ID.
4902
4903        If `replaceNames` is set to 1, any mechanism names resolved
4904        during this process will be replaced by full mechanism
4905        specifiers (see `DecisionGraph.legibleMechanismSpecifier`) that
4906        include domain and a zone. Note that if the process crashes due
4907        to an ambiguous mechanism name, requirements up to that point
4908        will still have been changed.
4909        
4910        The default for `replaceNames` (0) will not change any mechanism
4911        names/specifiers. Setting it to 2 instead of 1 will cause it to
4912        use the least-specific unambiguous mechanism specifier it can
4913        find (but note that if you're going to keep adding to the graph,
4914        such specifiers are more likely to become ambiguous in the
4915        future).
4916
4917        Set `replaceNames` to 3 to replace names with mechanism IDs
4918        only.
4919        """
4920        for (index, part) in base.walkParts(consequence):
4921            if isinstance(part, dict):
4922                if 'skills' in part:  # a Challenge
4923                    part = cast(base.Challenge, part)
4924                    for cSkill in part['skills'].walk():
4925                        if isinstance(cSkill, base.ConditionalSkill):
4926                            yield from self.walkRequirementMechanisms(
4927                                cSkill.requirement,
4928                                searchFrom,
4929                                replaceNames
4930                            )
4931                elif 'condition' in part:  # a Condition
4932                    part = cast(base.Condition, part)
4933                    yield from self.walkRequirementMechanisms(
4934                        part['condition'],
4935                        searchFrom,
4936                        replaceNames
4937                    )
4938                elif 'value' in part:  # an Effect
4939                    part = cast(base.Effect, part)
4940                    val = part['value']
4941                    if part['type'] == 'set':
4942                        if (
4943                            isinstance(val, tuple)
4944                        and len(val) == 2
4945                        and isinstance(val[1], base.MechanismState)
4946                        ):
4947                            resolved = self.resolveMechanism(
4948                                cast(base.AnyMechanismSpecifier, val[0]),
4949                                searchFrom
4950                            )
4951                            if replaceNames == 1:
4952                                spec = self.legibleMechanismSpecifier(
4953                                    resolved,
4954                                    False
4955                                )
4956                            elif replaceNames == 2:
4957                                spec = self.legibleMechanismSpecifier(
4958                                    resolved,
4959                                    True
4960                                )
4961                            elif replaceNames == 3:
4962                                spec = resolved
4963                            elif replaceNames != 0:
4964                                raise ValueError(
4965                                    f"Invalid replaceNames value:"
4966                                    f" {replaceNames!r}"
4967                                )
4968                            if replaceNames > 0:
4969                                part['value'] = (spec, val[1])
4970                            yield resolved
4971                    elif part['type'] == 'toggle':
4972                        if isinstance(val, tuple):
4973                            assert len(val) == 2
4974                            between = cast(List[base.MechanismState], val[1])
4975                            resolved = self.resolveMechanism(
4976                                cast(base.AnyMechanismSpecifier, val[0]),
4977                                searchFrom
4978                            )
4979                            if replaceNames == 1:
4980                                spec = self.legibleMechanismSpecifier(
4981                                    resolved,
4982                                    False
4983                                )
4984                            elif replaceNames == 2:
4985                                spec = self.legibleMechanismSpecifier(
4986                                    resolved,
4987                                    True
4988                                )
4989                            elif replaceNames == 3:
4990                                spec = resolved
4991                            elif replaceNames != 0:
4992                                raise ValueError(
4993                                    f"Invalid replaceNames value:"
4994                                    f" {replaceNames!r}"
4995                                )
4996                            if replaceNames > 0:
4997                                part['value'] = (spec, between)
4998                            yield resolved
4999            else:
5000                # Sub-parts will get walked in separate iterations
5001                pass

Yields each requirement in the given base.Consequence, including those in base.Conditions, base.ConditionalSkills within base.Challenges, and those set or toggled by base.Effects. The searchFrom argument specifies where to start searching for mechanisms, since requirements include them by name, not by ID.

If replaceNames is set to 1, any mechanism names resolved during this process will be replaced by full mechanism specifiers (see DecisionGraph.legibleMechanismSpecifier) that include domain and a zone. Note that if the process crashes due to an ambiguous mechanism name, requirements up to that point will still have been changed.

The default for replaceNames (0) will not change any mechanism names/specifiers. Setting it to 2 instead of 1 will cause it to use the least-specific unambiguous mechanism specifier it can find (but note that if you're going to keep adding to the graph, such specifiers are more likely to become ambiguous in the future).

Set replaceNames to 3 to replace names with mechanism IDs only.

def walkRequirementMechanisms( self, req: exploration.base.Requirement, searchFrom: Set[int], replaceNames: int = 0) -> Generator[int, NoneType, NoneType]:
5003    def walkRequirementMechanisms(
5004        self,
5005        req: base.Requirement,
5006        searchFrom: Set[base.DecisionID],
5007        replaceNames: int = 0
5008    ) -> Generator[base.MechanismID, None, None]:
5009        """
5010        Given a requirement, yields any mechanisms mentioned in that
5011        requirement, in depth-first traversal order.
5012
5013        If `replaceNames` is 1, 2, or 3 (default is 0) then the
5014        requirement is actually edited to replace any mechanism names
5015        with either a specifier or their resolved IDs. See
5016        `walkConsequenceMechanisms` for `replaceNames` details.
5017        """
5018        for part in req.walk():
5019            if isinstance(part, base.ReqMechanism):
5020                mech = part.mechanism
5021                resolved = self.resolveMechanism(
5022                    mech,
5023                    startFrom=searchFrom
5024                )
5025                if replaceNames in (1, 2, 3):
5026                    if replaceNames == 1:
5027                        spec = self.legibleMechanismSpecifier(
5028                            resolved,
5029                            False
5030                        )
5031                    elif replaceNames == 2:
5032                        spec = self.legibleMechanismSpecifier(
5033                            resolved,
5034                            True
5035                        )
5036                    elif replaceNames == 3:
5037                        spec = resolved
5038                    part.mechanism = spec
5039                elif replaceNames != 0:
5040                    raise ValueError(
5041                        f"Invalid replaceNames value:"
5042                        f" {replaceNames!r}"
5043                    )
5044                yield resolved

Given a requirement, yields any mechanisms mentioned in that requirement, in depth-first traversal order.

If replaceNames is 1, 2, or 3 (default is 0) then the requirement is actually edited to replace any mechanism names with either a specifier or their resolved IDs. See walkConsequenceMechanisms for replaceNames details.

def addUnexploredEdge( self, fromDecision: Union[int, exploration.base.DecisionSpecifier, str], name: str, destinationName: Optional[str] = None, reciprocal: Optional[str] = None, toDomain: Optional[str] = None, placeInZone: Optional[str] = None, tags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, annotations: Optional[List[str]] = None, revTags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, revAnnotations: Optional[List[str]] = None, requires: Optional[exploration.base.Requirement] = None, consequence: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None, revRequires: Optional[exploration.base.Requirement] = None, revConsequece: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None) -> int:
5046    def addUnexploredEdge(
5047        self,
5048        fromDecision: base.AnyDecisionSpecifier,
5049        name: base.Transition,
5050        destinationName: Optional[base.DecisionName] = None,
5051        reciprocal: Optional[base.Transition] = None,
5052        toDomain: Optional[base.Domain] = None,
5053        placeInZone: Optional[base.Zone] = None,
5054        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
5055        annotations: Optional[List[base.Annotation]] = None,
5056        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
5057        revAnnotations: Optional[List[base.Annotation]] = None,
5058        requires: Optional[base.Requirement] = None,
5059        consequence: Optional[base.Consequence] = None,
5060        revRequires: Optional[base.Requirement] = None,
5061        revConsequece: Optional[base.Consequence] = None
5062    ) -> base.DecisionID:
5063        """
5064        Adds a transition connecting to a new decision named `'_u.-n-'`
5065        where '-n-' is the number of unknown decisions (named or not)
5066        that have ever been created in this graph (or using the
5067        specified destination name if one is provided). This represents
5068        a transition to an unknown destination. The destination node
5069        gets tagged 'unconfirmed'.
5070
5071        This also adds a reciprocal transition in the reverse direction,
5072        unless `reciprocal` is left as the default `None`. The reciprocal
5073        will use the provided name. The new decision will be in the same
5074        domain as the decision it's connected to, unless `toDecision` is
5075        specified, in which case it will be in that domain.
5076
5077        The new decision will not be placed into any zones, unless
5078        `placeInZone` is specified, in which case it will be placed into
5079        that zone. If that zone needs to be created, it will be created
5080        at level 0; in that case that zone will be added to any
5081        grandparent zones of the decision we're branching off of. If
5082        `placeInZone` is set to `base.DefaultZone`, then the new
5083        decision will be placed into each parent zone of the decision
5084        we're branching off of, as long as the new decision is in the
5085        same domain as the decision we're branching from (otherwise only
5086        an explicit `placeInZone` would apply).
5087
5088        The ID of the decision that was created is returned.
5089
5090        A `MissingDecisionError` will be raised if the starting decision
5091        does not exist, a `TransitionCollisionError` will be raised if
5092        it exists but already has a transition with the given name, and a
5093        `DecisionCollisionWarning` will be issued if a decision with the
5094        specified destination name already exists (won't happen when
5095        using an automatic name).
5096
5097        Lists of tags and/or annotations (strings in both cases) may be
5098        provided. These may also be provided for the reciprocal edge.
5099
5100        Similarly, requirements and/or consequences for either edge may
5101        be provided.
5102
5103        ## Example
5104
5105        >>> g = DecisionGraph()
5106        >>> g.addDecision('A')
5107        0
5108        >>> g.addUnexploredEdge('A', 'up')
5109        1
5110        >>> g.nameFor(1)
5111        '_u.0'
5112        >>> g.decisionTags(1)
5113        {'unconfirmed': 1}
5114        >>> g.getReciprocal('A', 'up') is None
5115        True
5116        >>> g.addUnexploredEdge('A', 'right', 'B', 'left')
5117        2
5118        >>> g.nameFor(2)
5119        'B'
5120        >>> g.decisionTags(2)
5121        {'unconfirmed': 1}
5122        >>> g.getReciprocal('A', 'right')
5123        'left'
5124        >>> g.addUnexploredEdge('A', 'down', None, 'up')
5125        3
5126        >>> g.nameFor(3)
5127        '_u.2'
5128        >>> g.addUnexploredEdge(
5129        ...    '_u.0',
5130        ...    'beyond',
5131        ...    None,
5132        ...    'return',
5133        ...    toDomain='otherDomain',
5134        ...    tags={'fast':1},
5135        ...    revTags={'slow':1},
5136        ...    annotations=['comment'],
5137        ...    revAnnotations=['one', 'two'],
5138        ...    requires=base.ReqCapability('dash'),
5139        ...    revRequires=base.ReqCapability('super dash'),
5140        ...    consequence=[base.effect(gain='super dash')],
5141        ...    revConsequece=[base.effect(lose='super dash')]
5142        ... )
5143        4
5144        >>> g.nameFor(4)
5145        '_u.3'
5146        >>> g.domainFor(4)
5147        'otherDomain'
5148        >>> g.transitionTags('_u.0', 'beyond')
5149        {'fast': 1}
5150        >>> g.transitionAnnotations('_u.0', 'beyond')
5151        ['comment']
5152        >>> g.getTransitionRequirement('_u.0', 'beyond')
5153        ReqCapability('dash')
5154        >>> e = g.getConsequence('_u.0', 'beyond')
5155        >>> e == [base.effect(gain='super dash')]
5156        True
5157        >>> g.transitionTags('_u.3', 'return')
5158        {'slow': 1}
5159        >>> g.transitionAnnotations('_u.3', 'return')
5160        ['one', 'two']
5161        >>> g.getTransitionRequirement('_u.3', 'return')
5162        ReqCapability('super dash')
5163        >>> e = g.getConsequence('_u.3', 'return')
5164        >>> e == [base.effect(lose='super dash')]
5165        True
5166        """
5167        # Defaults
5168        if tags is None:
5169            tags = {}
5170        if annotations is None:
5171            annotations = []
5172        if revTags is None:
5173            revTags = {}
5174        if revAnnotations is None:
5175            revAnnotations = []
5176
5177        # Resolve ID
5178        fromID = self.resolveDecision(fromDecision)
5179        if toDomain is None:
5180            toDomain = self.domainFor(fromID)
5181
5182        if name in self.destinationsFrom(fromID):
5183            raise TransitionCollisionError(
5184                f"Cannot add a new edge {name!r}:"
5185                f" {self.identityOf(fromDecision)} already has an"
5186                f" outgoing edge with that name."
5187            )
5188
5189        if destinationName in self.nameLookup and WARN_OF_NAME_COLLISIONS:
5190            warnings.warn(
5191                (
5192                    f"Cannot add a new unexplored node"
5193                    f" {destinationName!r}: A decision with that name"
5194                    f" already exists.\n(Leave destinationName as None"
5195                    f" to use an automatic name.)"
5196                ),
5197                DecisionCollisionWarning
5198            )
5199
5200        # Create the new unexplored decision and add the edge
5201        if destinationName is None:
5202            toName = '_u.' + str(self.unknownCount)
5203        else:
5204            toName = destinationName
5205        self.unknownCount += 1
5206        newID = self.addDecision(toName, domain=toDomain)
5207        self.addTransition(
5208            fromID,
5209            name,
5210            newID,
5211            tags=tags,
5212            annotations=annotations
5213        )
5214        self.setTransitionRequirement(fromID, name, requires)
5215        if consequence is not None:
5216            self.setConsequence(fromID, name, consequence)
5217
5218        # Add it to a zone if requested
5219        if (
5220            placeInZone == base.DefaultZone
5221        and toDomain == self.domainFor(fromID)
5222        ):
5223            # Add to each parent of the from decision
5224            for parent in self.zoneParents(fromID):
5225                self.addDecisionToZone(newID, parent)
5226        elif placeInZone is not None:
5227            # Otherwise add it to one specific zone, creating that zone
5228            # at level 0 if necessary
5229            assert isinstance(placeInZone, base.Zone)
5230            if self.getZoneInfo(placeInZone) is None:
5231                self.createZone(placeInZone, 0)
5232                # Add new zone to each grandparent of the from decision
5233                for parent in self.zoneParents(fromID):
5234                    for grandparent in self.zoneParents(parent):
5235                        self.addZoneToZone(placeInZone, grandparent)
5236            self.addDecisionToZone(newID, placeInZone)
5237
5238        # Create the reciprocal edge
5239        if reciprocal is not None:
5240            self.addTransition(
5241                newID,
5242                reciprocal,
5243                fromID,
5244                tags=revTags,
5245                annotations=revAnnotations
5246            )
5247            self.setTransitionRequirement(newID, reciprocal, revRequires)
5248            if revConsequece is not None:
5249                self.setConsequence(newID, reciprocal, revConsequece)
5250            # Set as a reciprocal
5251            self.setReciprocal(fromID, name, reciprocal)
5252
5253        # Tag the destination as 'unconfirmed'
5254        self.tagDecision(newID, 'unconfirmed')
5255
5256        # Return ID of new destination
5257        return newID

Adds a transition connecting to a new decision named '_u.-n-' where '-n-' is the number of unknown decisions (named or not) that have ever been created in this graph (or using the specified destination name if one is provided). This represents a transition to an unknown destination. The destination node gets tagged 'unconfirmed'.

This also adds a reciprocal transition in the reverse direction, unless reciprocal is left as the default None. The reciprocal will use the provided name. The new decision will be in the same domain as the decision it's connected to, unless toDecision is specified, in which case it will be in that domain.

The new decision will not be placed into any zones, unless placeInZone is specified, in which case it will be placed into that zone. If that zone needs to be created, it will be created at level 0; in that case that zone will be added to any grandparent zones of the decision we're branching off of. If placeInZone is set to base.DefaultZone, then the new decision will be placed into each parent zone of the decision we're branching off of, as long as the new decision is in the same domain as the decision we're branching from (otherwise only an explicit placeInZone would apply).

The ID of the decision that was created is returned.

A MissingDecisionError will be raised if the starting decision does not exist, a TransitionCollisionError will be raised if it exists but already has a transition with the given name, and a DecisionCollisionWarning will be issued if a decision with the specified destination name already exists (won't happen when using an automatic name).

Lists of tags and/or annotations (strings in both cases) may be provided. These may also be provided for the reciprocal edge.

Similarly, requirements and/or consequences for either edge may be provided.

Example

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addUnexploredEdge('A', 'up')
1
>>> g.nameFor(1)
'_u.0'
>>> g.decisionTags(1)
{'unconfirmed': 1}
>>> g.getReciprocal('A', 'up') is None
True
>>> g.addUnexploredEdge('A', 'right', 'B', 'left')
2
>>> g.nameFor(2)
'B'
>>> g.decisionTags(2)
{'unconfirmed': 1}
>>> g.getReciprocal('A', 'right')
'left'
>>> g.addUnexploredEdge('A', 'down', None, 'up')
3
>>> g.nameFor(3)
'_u.2'
>>> g.addUnexploredEdge(
...    '_u.0',
...    'beyond',
...    None,
...    'return',
...    toDomain='otherDomain',
...    tags={'fast':1},
...    revTags={'slow':1},
...    annotations=['comment'],
...    revAnnotations=['one', 'two'],
...    requires=base.ReqCapability('dash'),
...    revRequires=base.ReqCapability('super dash'),
...    consequence=[base.effect(gain='super dash')],
...    revConsequece=[base.effect(lose='super dash')]
... )
4
>>> g.nameFor(4)
'_u.3'
>>> g.domainFor(4)
'otherDomain'
>>> g.transitionTags('_u.0', 'beyond')
{'fast': 1}
>>> g.transitionAnnotations('_u.0', 'beyond')
['comment']
>>> g.getTransitionRequirement('_u.0', 'beyond')
ReqCapability('dash')
>>> e = g.getConsequence('_u.0', 'beyond')
>>> e == [base.effect(gain='super dash')]
True
>>> g.transitionTags('_u.3', 'return')
{'slow': 1}
>>> g.transitionAnnotations('_u.3', 'return')
['one', 'two']
>>> g.getTransitionRequirement('_u.3', 'return')
ReqCapability('super dash')
>>> e = g.getConsequence('_u.3', 'return')
>>> e == [base.effect(lose='super dash')]
True
def retargetTransition( self, fromDecision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, newDestination: Union[int, exploration.base.DecisionSpecifier, str], swapReciprocal=True, errorOnNameColision=True) -> Optional[str]:
5259    def retargetTransition(
5260        self,
5261        fromDecision: base.AnyDecisionSpecifier,
5262        transition: base.Transition,
5263        newDestination: base.AnyDecisionSpecifier,
5264        swapReciprocal=True,
5265        errorOnNameColision=True
5266    ) -> Optional[base.Transition]:
5267        """
5268        Given a particular decision and a transition at that decision,
5269        changes that transition so that it goes to the specified new
5270        destination instead of wherever it was connected to before. If
5271        the new destination is the same as the old one, no changes are
5272        made.
5273
5274        If `swapReciprocal` is set to True (the default) then any
5275        reciprocal edge at the old destination will be deleted, and a
5276        new reciprocal edge from the new destination with equivalent
5277        properties to the original reciprocal will be created, pointing
5278        to the origin of the specified transition. If `swapReciprocal`
5279        is set to False, then the reciprocal relationship with any old
5280        reciprocal edge will be removed, but the old reciprocal edge
5281        will not be changed.
5282
5283        Note that if `errorOnNameColision` is True (the default), then
5284        if the reciprocal transition has the same name as a transition
5285        which already exists at the new destination node, a
5286        `TransitionCollisionError` will be thrown. However, if it is set
5287        to False, the reciprocal transition will be renamed with a suffix
5288        to avoid any possible name collisions. Either way, the name of
5289        the reciprocal transition (possibly just changed) will be
5290        returned, or None if there was no reciprocal transition.
5291
5292        ## Example
5293
5294        >>> g = DecisionGraph()
5295        >>> for fr, to, nm in [
5296        ...     ('A', 'B', 'up'),
5297        ...     ('A', 'B', 'up2'),
5298        ...     ('B', 'A', 'down'),
5299        ...     ('B', 'B', 'self'),
5300        ...     ('B', 'C', 'next'),
5301        ...     ('C', 'B', 'prev')
5302        ... ]:
5303        ...     if g.getDecision(fr) is None:
5304        ...        g.addDecision(fr)
5305        ...     if g.getDecision(to) is None:
5306        ...         g.addDecision(to)
5307        ...     g.addTransition(fr, nm, to)
5308        0
5309        1
5310        2
5311        >>> g.setReciprocal('A', 'up', 'down')
5312        >>> g.setReciprocal('B', 'next', 'prev')
5313        >>> g.destination('A', 'up')
5314        1
5315        >>> g.destination('B', 'down')
5316        0
5317        >>> g.retargetTransition('A', 'up', 'C')
5318        'down'
5319        >>> g.destination('A', 'up')
5320        2
5321        >>> g.getDestination('B', 'down') is None
5322        True
5323        >>> g.destination('C', 'down')
5324        0
5325        >>> g.addTransition('A', 'next', 'B')
5326        >>> g.addTransition('B', 'prev', 'A')
5327        >>> g.setReciprocal('A', 'next', 'prev')
5328        >>> # Can't swap a reciprocal in a way that would collide names
5329        >>> g.getReciprocal('C', 'prev')
5330        'next'
5331        >>> g.retargetTransition('C', 'prev', 'A')
5332        Traceback (most recent call last):
5333        ...
5334        exploration.core.TransitionCollisionError...
5335        >>> g.retargetTransition('C', 'prev', 'A', swapReciprocal=False)
5336        'next'
5337        >>> g.destination('C', 'prev')
5338        0
5339        >>> g.destination('A', 'next') # not changed
5340        1
5341        >>> # Reciprocal relationship is severed:
5342        >>> g.getReciprocal('C', 'prev') is None
5343        True
5344        >>> g.getReciprocal('B', 'next') is None
5345        True
5346        >>> # Swap back so we can do another demo
5347        >>> g.retargetTransition('C', 'prev', 'B', swapReciprocal=False)
5348        >>> # Note return value was None here because there was no reciprocal
5349        >>> g.setReciprocal('C', 'prev', 'next')
5350        >>> # Swap reciprocal by renaming it
5351        >>> g.retargetTransition('C', 'prev', 'A', errorOnNameColision=False)
5352        'next.1'
5353        >>> g.getReciprocal('C', 'prev')
5354        'next.1'
5355        >>> g.destination('C', 'prev')
5356        0
5357        >>> g.destination('A', 'next.1')
5358        2
5359        >>> g.destination('A', 'next')
5360        1
5361        >>> # Note names are the same but these are from different nodes
5362        >>> g.getReciprocal('A', 'next')
5363        'prev'
5364        >>> g.getReciprocal('A', 'next.1')
5365        'prev'
5366        """
5367        fromID = self.resolveDecision(fromDecision)
5368        newDestID = self.resolveDecision(newDestination)
5369
5370        # Figure out the old destination of the transition we're swapping
5371        oldDestID = self.destination(fromID, transition)
5372        reciprocal = self.getReciprocal(fromID, transition)
5373
5374        # If thew new destination is the same, we don't do anything!
5375        if oldDestID == newDestID:
5376            return reciprocal
5377
5378        # First figure out reciprocal business so we can error out
5379        # without making changes if we need to
5380        if swapReciprocal and reciprocal is not None:
5381            reciprocal = self.rebaseTransition(
5382                oldDestID,
5383                reciprocal,
5384                newDestID,
5385                swapReciprocal=False,
5386                errorOnNameColision=errorOnNameColision
5387            )
5388
5389        # Handle the forward transition...
5390        # Find the transition properties
5391        tProps = self.getTransitionProperties(fromID, transition)
5392
5393        # Delete the edge
5394        self.removeEdgeByKey(fromID, transition)
5395
5396        # Add the new edge
5397        self.addTransition(fromID, transition, newDestID)
5398
5399        # Reapply the transition properties
5400        self.setTransitionProperties(fromID, transition, **tProps)
5401
5402        # Handle the reciprocal transition if there is one...
5403        if reciprocal is not None:
5404            if not swapReciprocal:
5405                # Then sever the relationship, but only if that edge
5406                # still exists (we might be in the middle of a rebase)
5407                check = self.getDestination(oldDestID, reciprocal)
5408                if check is not None:
5409                    self.setReciprocal(
5410                        oldDestID,
5411                        reciprocal,
5412                        None,
5413                        setBoth=False # Other transition was deleted already
5414                    )
5415            else:
5416                # Establish new reciprocal relationship
5417                self.setReciprocal(
5418                    fromID,
5419                    transition,
5420                    reciprocal
5421                )
5422
5423        return reciprocal

Given a particular decision and a transition at that decision, changes that transition so that it goes to the specified new destination instead of wherever it was connected to before. If the new destination is the same as the old one, no changes are made.

If swapReciprocal is set to True (the default) then any reciprocal edge at the old destination will be deleted, and a new reciprocal edge from the new destination with equivalent properties to the original reciprocal will be created, pointing to the origin of the specified transition. If swapReciprocal is set to False, then the reciprocal relationship with any old reciprocal edge will be removed, but the old reciprocal edge will not be changed.

Note that if errorOnNameColision is True (the default), then if the reciprocal transition has the same name as a transition which already exists at the new destination node, a TransitionCollisionError will be thrown. However, if it is set to False, the reciprocal transition will be renamed with a suffix to avoid any possible name collisions. Either way, the name of the reciprocal transition (possibly just changed) will be returned, or None if there was no reciprocal transition.

Example

>>> g = DecisionGraph()
>>> for fr, to, nm in [
...     ('A', 'B', 'up'),
...     ('A', 'B', 'up2'),
...     ('B', 'A', 'down'),
...     ('B', 'B', 'self'),
...     ('B', 'C', 'next'),
...     ('C', 'B', 'prev')
... ]:
...     if g.getDecision(fr) is None:
...        g.addDecision(fr)
...     if g.getDecision(to) is None:
...         g.addDecision(to)
...     g.addTransition(fr, nm, to)
0
1
2
>>> g.setReciprocal('A', 'up', 'down')
>>> g.setReciprocal('B', 'next', 'prev')
>>> g.destination('A', 'up')
1
>>> g.destination('B', 'down')
0
>>> g.retargetTransition('A', 'up', 'C')
'down'
>>> g.destination('A', 'up')
2
>>> g.getDestination('B', 'down') is None
True
>>> g.destination('C', 'down')
0
>>> g.addTransition('A', 'next', 'B')
>>> g.addTransition('B', 'prev', 'A')
>>> g.setReciprocal('A', 'next', 'prev')
>>> # Can't swap a reciprocal in a way that would collide names
>>> g.getReciprocal('C', 'prev')
'next'
>>> g.retargetTransition('C', 'prev', 'A')
Traceback (most recent call last):
...
TransitionCollisionError...
>>> g.retargetTransition('C', 'prev', 'A', swapReciprocal=False)
'next'
>>> g.destination('C', 'prev')
0
>>> g.destination('A', 'next') # not changed
1
>>> # Reciprocal relationship is severed:
>>> g.getReciprocal('C', 'prev') is None
True
>>> g.getReciprocal('B', 'next') is None
True
>>> # Swap back so we can do another demo
>>> g.retargetTransition('C', 'prev', 'B', swapReciprocal=False)
>>> # Note return value was None here because there was no reciprocal
>>> g.setReciprocal('C', 'prev', 'next')
>>> # Swap reciprocal by renaming it
>>> g.retargetTransition('C', 'prev', 'A', errorOnNameColision=False)
'next.1'
>>> g.getReciprocal('C', 'prev')
'next.1'
>>> g.destination('C', 'prev')
0
>>> g.destination('A', 'next.1')
2
>>> g.destination('A', 'next')
1
>>> # Note names are the same but these are from different nodes
>>> g.getReciprocal('A', 'next')
'prev'
>>> g.getReciprocal('A', 'next.1')
'prev'
def rebaseTransition( self, fromDecision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, newBase: Union[int, exploration.base.DecisionSpecifier, str], swapReciprocal=True, errorOnNameColision=True) -> str:
5425    def rebaseTransition(
5426        self,
5427        fromDecision: base.AnyDecisionSpecifier,
5428        transition: base.Transition,
5429        newBase: base.AnyDecisionSpecifier,
5430        swapReciprocal=True,
5431        errorOnNameColision=True
5432    ) -> base.Transition:
5433        """
5434        Given a particular destination and a transition at that
5435        destination, changes that transition's origin to a new base
5436        decision. If the new source is the same as the old one, no
5437        changes are made.
5438
5439        If `swapReciprocal` is set to True (the default) then any
5440        reciprocal edge at the destination will be retargeted to point
5441        to the new source so that it can remain a reciprocal. If
5442        `swapReciprocal` is set to False, then the reciprocal
5443        relationship with any old reciprocal edge will be removed, but
5444        the old reciprocal edge will not be otherwise changed.
5445
5446        Note that if `errorOnNameColision` is True (the default), then
5447        if the transition has the same name as a transition which
5448        already exists at the new source node, a
5449        `TransitionCollisionError` will be raised. However, if it is set
5450        to False, the transition will be renamed with a suffix to avoid
5451        any possible name collisions. Either way, the (possibly new) name
5452        of the transition that was rebased will be returned.
5453
5454        ## Example
5455
5456        >>> g = DecisionGraph()
5457        >>> for fr, to, nm in [
5458        ...     ('A', 'B', 'up'),
5459        ...     ('A', 'B', 'up2'),
5460        ...     ('B', 'A', 'down'),
5461        ...     ('B', 'B', 'self'),
5462        ...     ('B', 'C', 'next'),
5463        ...     ('C', 'B', 'prev')
5464        ... ]:
5465        ...     if g.getDecision(fr) is None:
5466        ...        g.addDecision(fr)
5467        ...     if g.getDecision(to) is None:
5468        ...         g.addDecision(to)
5469        ...     g.addTransition(fr, nm, to)
5470        0
5471        1
5472        2
5473        >>> g.setReciprocal('A', 'up', 'down')
5474        >>> g.setReciprocal('B', 'next', 'prev')
5475        >>> g.destination('A', 'up')
5476        1
5477        >>> g.destination('B', 'down')
5478        0
5479        >>> g.rebaseTransition('B', 'down', 'C')
5480        'down'
5481        >>> g.destination('A', 'up')
5482        2
5483        >>> g.getDestination('B', 'down') is None
5484        True
5485        >>> g.destination('C', 'down')
5486        0
5487        >>> g.addTransition('A', 'next', 'B')
5488        >>> g.addTransition('B', 'prev', 'A')
5489        >>> g.setReciprocal('A', 'next', 'prev')
5490        >>> # Can't rebase in a way that would collide names
5491        >>> g.rebaseTransition('B', 'next', 'A')
5492        Traceback (most recent call last):
5493        ...
5494        exploration.core.TransitionCollisionError...
5495        >>> g.rebaseTransition('B', 'next', 'A', errorOnNameColision=False)
5496        'next.1'
5497        >>> g.destination('C', 'prev')
5498        0
5499        >>> g.destination('A', 'next') # not changed
5500        1
5501        >>> # Collision is avoided by renaming
5502        >>> g.destination('A', 'next.1')
5503        2
5504        >>> # Swap without reciprocal
5505        >>> g.getReciprocal('A', 'next.1')
5506        'prev'
5507        >>> g.getReciprocal('C', 'prev')
5508        'next.1'
5509        >>> g.rebaseTransition('A', 'next.1', 'B', swapReciprocal=False)
5510        'next.1'
5511        >>> g.getReciprocal('C', 'prev') is None
5512        True
5513        >>> g.destination('C', 'prev')
5514        0
5515        >>> g.getDestination('A', 'next.1') is None
5516        True
5517        >>> g.destination('A', 'next')
5518        1
5519        >>> g.destination('B', 'next.1')
5520        2
5521        >>> g.getReciprocal('B', 'next.1') is None
5522        True
5523        >>> # Rebase in a way that creates a self-edge
5524        >>> g.rebaseTransition('A', 'next', 'B')
5525        'next'
5526        >>> g.getDestination('A', 'next') is None
5527        True
5528        >>> g.destination('B', 'next')
5529        1
5530        >>> g.destination('B', 'prev') # swapped as a reciprocal
5531        1
5532        >>> g.getReciprocal('B', 'next') # still reciprocals
5533        'prev'
5534        >>> g.getReciprocal('B', 'prev')
5535        'next'
5536        >>> # And rebasing of a self-edge also works
5537        >>> g.rebaseTransition('B', 'prev', 'A')
5538        'prev'
5539        >>> g.destination('A', 'prev')
5540        1
5541        >>> g.destination('B', 'next')
5542        0
5543        >>> g.getReciprocal('B', 'next') # still reciprocals
5544        'prev'
5545        >>> g.getReciprocal('A', 'prev')
5546        'next'
5547        >>> # We've effectively reversed this edge/reciprocal pair
5548        >>> # by rebasing twice
5549        """
5550        fromID = self.resolveDecision(fromDecision)
5551        newBaseID = self.resolveDecision(newBase)
5552
5553        # If thew new base is the same, we don't do anything!
5554        if newBaseID == fromID:
5555            return transition
5556
5557        # First figure out reciprocal business so we can swap it later
5558        # without making changes if we need to
5559        destination = self.destination(fromID, transition)
5560        reciprocal = self.getReciprocal(fromID, transition)
5561        # Check for an already-deleted reciprocal
5562        if (
5563            reciprocal is not None
5564        and self.getDestination(destination, reciprocal) is None
5565        ):
5566            reciprocal = None
5567
5568        # Handle the base swap...
5569        # Find the transition properties
5570        tProps = self.getTransitionProperties(fromID, transition)
5571
5572        # Check for a collision
5573        targetDestinations = self.destinationsFrom(newBaseID)
5574        if transition in targetDestinations:
5575            if errorOnNameColision:
5576                raise TransitionCollisionError(
5577                    f"Cannot rebase transition {transition!r} from"
5578                    f" {self.identityOf(fromDecision)}: it would be a"
5579                    f" duplicate transition name at the new base"
5580                    f" decision {self.identityOf(newBase)}."
5581                )
5582            else:
5583                # Figure out a good fresh name
5584                newName = utils.uniqueName(
5585                    transition,
5586                    targetDestinations
5587                )
5588        else:
5589            newName = transition
5590
5591        # Delete the edge
5592        self.removeEdgeByKey(fromID, transition)
5593
5594        # Add the new edge
5595        self.addTransition(newBaseID, newName, destination)
5596
5597        # Reapply the transition properties
5598        self.setTransitionProperties(newBaseID, newName, **tProps)
5599
5600        # Handle the reciprocal transition if there is one...
5601        if reciprocal is not None:
5602            if not swapReciprocal:
5603                # Then sever the relationship
5604                self.setReciprocal(
5605                    destination,
5606                    reciprocal,
5607                    None,
5608                    setBoth=False # Other transition was deleted already
5609                )
5610            else:
5611                # Otherwise swap the reciprocal edge
5612                self.retargetTransition(
5613                    destination,
5614                    reciprocal,
5615                    newBaseID,
5616                    swapReciprocal=False
5617                )
5618
5619                # And establish a new reciprocal relationship
5620                self.setReciprocal(
5621                    newBaseID,
5622                    newName,
5623                    reciprocal
5624                )
5625
5626        # Return the new name in case it was changed
5627        return newName

Given a particular destination and a transition at that destination, changes that transition's origin to a new base decision. If the new source is the same as the old one, no changes are made.

If swapReciprocal is set to True (the default) then any reciprocal edge at the destination will be retargeted to point to the new source so that it can remain a reciprocal. If swapReciprocal is set to False, then the reciprocal relationship with any old reciprocal edge will be removed, but the old reciprocal edge will not be otherwise changed.

Note that if errorOnNameColision is True (the default), then if the transition has the same name as a transition which already exists at the new source node, a TransitionCollisionError will be raised. However, if it is set to False, the transition will be renamed with a suffix to avoid any possible name collisions. Either way, the (possibly new) name of the transition that was rebased will be returned.

Example

>>> g = DecisionGraph()
>>> for fr, to, nm in [
...     ('A', 'B', 'up'),
...     ('A', 'B', 'up2'),
...     ('B', 'A', 'down'),
...     ('B', 'B', 'self'),
...     ('B', 'C', 'next'),
...     ('C', 'B', 'prev')
... ]:
...     if g.getDecision(fr) is None:
...        g.addDecision(fr)
...     if g.getDecision(to) is None:
...         g.addDecision(to)
...     g.addTransition(fr, nm, to)
0
1
2
>>> g.setReciprocal('A', 'up', 'down')
>>> g.setReciprocal('B', 'next', 'prev')
>>> g.destination('A', 'up')
1
>>> g.destination('B', 'down')
0
>>> g.rebaseTransition('B', 'down', 'C')
'down'
>>> g.destination('A', 'up')
2
>>> g.getDestination('B', 'down') is None
True
>>> g.destination('C', 'down')
0
>>> g.addTransition('A', 'next', 'B')
>>> g.addTransition('B', 'prev', 'A')
>>> g.setReciprocal('A', 'next', 'prev')
>>> # Can't rebase in a way that would collide names
>>> g.rebaseTransition('B', 'next', 'A')
Traceback (most recent call last):
...
TransitionCollisionError...
>>> g.rebaseTransition('B', 'next', 'A', errorOnNameColision=False)
'next.1'
>>> g.destination('C', 'prev')
0
>>> g.destination('A', 'next') # not changed
1
>>> # Collision is avoided by renaming
>>> g.destination('A', 'next.1')
2
>>> # Swap without reciprocal
>>> g.getReciprocal('A', 'next.1')
'prev'
>>> g.getReciprocal('C', 'prev')
'next.1'
>>> g.rebaseTransition('A', 'next.1', 'B', swapReciprocal=False)
'next.1'
>>> g.getReciprocal('C', 'prev') is None
True
>>> g.destination('C', 'prev')
0
>>> g.getDestination('A', 'next.1') is None
True
>>> g.destination('A', 'next')
1
>>> g.destination('B', 'next.1')
2
>>> g.getReciprocal('B', 'next.1') is None
True
>>> # Rebase in a way that creates a self-edge
>>> g.rebaseTransition('A', 'next', 'B')
'next'
>>> g.getDestination('A', 'next') is None
True
>>> g.destination('B', 'next')
1
>>> g.destination('B', 'prev') # swapped as a reciprocal
1
>>> g.getReciprocal('B', 'next') # still reciprocals
'prev'
>>> g.getReciprocal('B', 'prev')
'next'
>>> # And rebasing of a self-edge also works
>>> g.rebaseTransition('B', 'prev', 'A')
'prev'
>>> g.destination('A', 'prev')
1
>>> g.destination('B', 'next')
0
>>> g.getReciprocal('B', 'next') # still reciprocals
'prev'
>>> g.getReciprocal('A', 'prev')
'next'
>>> # We've effectively reversed this edge/reciprocal pair
>>> # by rebasing twice
def mergeDecisions( self, merge: Union[int, exploration.base.DecisionSpecifier, str], mergeInto: Union[int, exploration.base.DecisionSpecifier, str], errorOnNameColision=True) -> Dict[str, str]:
5633    def mergeDecisions(
5634        self,
5635        merge: base.AnyDecisionSpecifier,
5636        mergeInto: base.AnyDecisionSpecifier,
5637        errorOnNameColision=True
5638    ) -> Dict[base.Transition, base.Transition]:
5639        """
5640        Merges two decisions, deleting the first after transferring all
5641        of its incoming and outgoing edges to target the second one,
5642        whose name is retained. The second decision will be added to any
5643        zones that the first decision was a member of. If either decision
5644        does not exist, a `MissingDecisionError` will be raised. If
5645        `merge` and `mergeInto` are the same, then nothing will be
5646        changed.
5647
5648        Unless `errorOnNameColision` is set to False, a
5649        `TransitionCollisionError` will be raised if the two decisions
5650        have outgoing transitions with the same name. If
5651        `errorOnNameColision` is set to False, then such edges will be
5652        renamed using a suffix to avoid name collisions, with edges
5653        connected to the second decision retaining their original names
5654        and edges that were connected to the first decision getting
5655        renamed.
5656
5657        Any mechanisms located at the first decision will be moved to the
5658        merged decision.
5659
5660        The tags and annotations of the merged decision are added to the
5661        tags and annotations of the merge target. If there are shared
5662        tags, the values from the merge target will override those of
5663        the merged decision. If this is undesired behavior, clear/edit
5664        the tags/annotations of the merged decision before the merge.
5665
5666        The 'unconfirmed' tag is treated specially: if both decisions have
5667        it it will be retained, but otherwise it will be dropped even if
5668        one of the situations had it before.
5669
5670        The domain of the second decision is retained.
5671
5672        Returns a dictionary mapping each original transition name to
5673        its new name in cases where transitions get renamed; this will
5674        be empty when no re-naming occurs, including when
5675        `errorOnNameColision` is True. If there were any transitions
5676        connecting the nodes that were merged, these become self-edges
5677        of the merged node (and may be renamed if necessary).
5678        Note that all renamed transitions were originally based on the
5679        first (merged) node, since transitions of the second (merge
5680        target) node are not renamed.
5681
5682        ## Example
5683
5684        >>> g = DecisionGraph()
5685        >>> for fr, to, nm in [
5686        ...     ('A', 'B', 'up'),
5687        ...     ('A', 'B', 'up2'),
5688        ...     ('B', 'A', 'down'),
5689        ...     ('B', 'B', 'self'),
5690        ...     ('B', 'C', 'next'),
5691        ...     ('C', 'B', 'prev'),
5692        ...     ('A', 'C', 'right')
5693        ... ]:
5694        ...     if g.getDecision(fr) is None:
5695        ...        g.addDecision(fr)
5696        ...     if g.getDecision(to) is None:
5697        ...         g.addDecision(to)
5698        ...     g.addTransition(fr, nm, to)
5699        0
5700        1
5701        2
5702        >>> g.getDestination('A', 'up')
5703        1
5704        >>> g.getDestination('B', 'down')
5705        0
5706        >>> sorted(g)
5707        [0, 1, 2]
5708        >>> g.setReciprocal('A', 'up', 'down')
5709        >>> g.setReciprocal('B', 'next', 'prev')
5710        >>> g.mergeDecisions('C', 'B')
5711        {}
5712        >>> g.destinationsFrom('A')
5713        {'up': 1, 'up2': 1, 'right': 1}
5714        >>> g.destinationsFrom('B')
5715        {'down': 0, 'self': 1, 'prev': 1, 'next': 1}
5716        >>> 'C' in g
5717        False
5718        >>> g.mergeDecisions('A', 'A') # does nothing
5719        {}
5720        >>> # Can't merge non-existent decision
5721        >>> g.mergeDecisions('A', 'Z')
5722        Traceback (most recent call last):
5723        ...
5724        exploration.core.MissingDecisionError...
5725        >>> g.mergeDecisions('Z', 'A')
5726        Traceback (most recent call last):
5727        ...
5728        exploration.core.MissingDecisionError...
5729        >>> # Can't merge decisions w/ shared edge names
5730        >>> g.addDecision('D')
5731        3
5732        >>> g.addTransition('D', 'next', 'A')
5733        >>> g.addTransition('A', 'prev', 'D')
5734        >>> g.setReciprocal('D', 'next', 'prev')
5735        >>> g.mergeDecisions('D', 'B') # both have a 'next' transition
5736        Traceback (most recent call last):
5737        ...
5738        exploration.core.TransitionCollisionError...
5739        >>> # Auto-rename colliding edges
5740        >>> g.mergeDecisions('D', 'B', errorOnNameColision=False)
5741        {'next': 'next.1'}
5742        >>> g.destination('B', 'next') # merge target unchanged
5743        1
5744        >>> g.destination('B', 'next.1') # merged decision name changed
5745        0
5746        >>> g.destination('B', 'prev') # name unchanged (no collision)
5747        1
5748        >>> g.getReciprocal('B', 'next') # unchanged (from B)
5749        'prev'
5750        >>> g.getReciprocal('B', 'next.1') # from A
5751        'prev'
5752        >>> g.getReciprocal('A', 'prev') # from B
5753        'next.1'
5754
5755        ## Folding four nodes into a 2-node loop
5756
5757        >>> g = DecisionGraph()
5758        >>> g.addDecision('X')
5759        0
5760        >>> g.addDecision('Y')
5761        1
5762        >>> g.addTransition('X', 'next', 'Y', 'prev')
5763        >>> g.addDecision('preX')
5764        2
5765        >>> g.addDecision('postY')
5766        3
5767        >>> g.addTransition('preX', 'next', 'X', 'prev')
5768        >>> g.addTransition('Y', 'next', 'postY', 'prev')
5769        >>> g.mergeDecisions('preX', 'Y', errorOnNameColision=False)
5770        {'next': 'next.1'}
5771        >>> g.destinationsFrom('X')
5772        {'next': 1, 'prev': 1}
5773        >>> g.destinationsFrom('Y')
5774        {'prev': 0, 'next': 3, 'next.1': 0}
5775        >>> 2 in g
5776        False
5777        >>> g.destinationsFrom('postY')
5778        {'prev': 1}
5779        >>> g.mergeDecisions('postY', 'X', errorOnNameColision=False)
5780        {'prev': 'prev.1'}
5781        >>> g.destinationsFrom('X')
5782        {'next': 1, 'prev': 1, 'prev.1': 1}
5783        >>> g.destinationsFrom('Y') # order 'cause of 'next' re-target
5784        {'prev': 0, 'next.1': 0, 'next': 0}
5785        >>> 2 in g
5786        False
5787        >>> 3 in g
5788        False
5789        >>> # Reciprocals are tangled...
5790        >>> g.getReciprocal(0, 'prev')
5791        'next.1'
5792        >>> g.getReciprocal(0, 'prev.1')
5793        'next'
5794        >>> g.getReciprocal(1, 'next')
5795        'prev.1'
5796        >>> g.getReciprocal(1, 'next.1')
5797        'prev'
5798        >>> # Note: one merge cannot handle both extra transitions
5799        >>> # because their reciprocals are crossed (e.g., prev.1 <-> next)
5800        >>> # (It would merge both edges but the result would retain
5801        >>> # 'next.1' instead of retaining 'next'.)
5802        >>> g.mergeTransitions('X', 'prev.1', 'prev', mergeReciprocal=False)
5803        >>> g.mergeTransitions('Y', 'next.1', 'next', mergeReciprocal=True)
5804        >>> g.destinationsFrom('X')
5805        {'next': 1, 'prev': 1}
5806        >>> g.destinationsFrom('Y')
5807        {'prev': 0, 'next': 0}
5808        >>> # Reciprocals were salvaged in second merger
5809        >>> g.getReciprocal('X', 'prev')
5810        'next'
5811        >>> g.getReciprocal('Y', 'next')
5812        'prev'
5813
5814        ## Merging with tags/requirements/annotations/consequences
5815
5816        >>> g = DecisionGraph()
5817        >>> g.addDecision('X')
5818        0
5819        >>> g.addDecision('Y')
5820        1
5821        >>> g.addDecision('Z')
5822        2
5823        >>> g.addTransition('X', 'next', 'Y', 'prev')
5824        >>> g.addTransition('X', 'down', 'Z', 'up')
5825        >>> g.tagDecision('X', 'tag0', 1)
5826        >>> g.tagDecision('Y', 'tag1', 10)
5827        >>> g.tagDecision('Y', 'unconfirmed')
5828        >>> g.tagDecision('Z', 'tag1', 20)
5829        >>> g.tagDecision('Z', 'tag2', 30)
5830        >>> g.tagTransition('X', 'next', 'ttag1', 11)
5831        >>> g.tagTransition('Y', 'prev', 'ttag2', 22)
5832        >>> g.tagTransition('X', 'down', 'ttag3', 33)
5833        >>> g.tagTransition('Z', 'up', 'ttag4', 44)
5834        >>> g.annotateDecision('Y', 'annotation 1')
5835        >>> g.annotateDecision('Z', 'annotation 2')
5836        >>> g.annotateDecision('Z', 'annotation 3')
5837        >>> g.annotateTransition('Y', 'prev', 'trans annotation 1')
5838        >>> g.annotateTransition('Y', 'prev', 'trans annotation 2')
5839        >>> g.annotateTransition('Z', 'up', 'trans annotation 3')
5840        >>> g.setTransitionRequirement(
5841        ...     'X',
5842        ...     'next',
5843        ...     base.ReqCapability('power')
5844        ... )
5845        >>> g.setTransitionRequirement(
5846        ...     'Y',
5847        ...     'prev',
5848        ...     base.ReqTokens('token', 1)
5849        ... )
5850        >>> g.setTransitionRequirement(
5851        ...     'X',
5852        ...     'down',
5853        ...     base.ReqCapability('power2')
5854        ... )
5855        >>> g.setTransitionRequirement(
5856        ...     'Z',
5857        ...     'up',
5858        ...     base.ReqTokens('token2', 2)
5859        ... )
5860        >>> g.setConsequence(
5861        ...     'Y',
5862        ...     'prev',
5863        ...     [base.effect(gain="power2")]
5864        ... )
5865        >>> g.mergeDecisions('Y', 'Z')
5866        {}
5867        >>> g.destination('X', 'next')
5868        2
5869        >>> g.destination('X', 'down')
5870        2
5871        >>> g.destination('Z', 'prev')
5872        0
5873        >>> g.destination('Z', 'up')
5874        0
5875        >>> g.decisionTags('X')
5876        {'tag0': 1}
5877        >>> g.decisionTags('Z')  # note that 'unconfirmed' is removed
5878        {'tag1': 20, 'tag2': 30}
5879        >>> g.transitionTags('X', 'next')
5880        {'ttag1': 11}
5881        >>> g.transitionTags('X', 'down')
5882        {'ttag3': 33}
5883        >>> g.transitionTags('Z', 'prev')
5884        {'ttag2': 22}
5885        >>> g.transitionTags('Z', 'up')
5886        {'ttag4': 44}
5887        >>> g.decisionAnnotations('Z')
5888        ['annotation 2', 'annotation 3', 'annotation 1']
5889        >>> g.transitionAnnotations('Z', 'prev')
5890        ['trans annotation 1', 'trans annotation 2']
5891        >>> g.transitionAnnotations('Z', 'up')
5892        ['trans annotation 3']
5893        >>> g.getTransitionRequirement('X', 'next')
5894        ReqCapability('power')
5895        >>> g.getTransitionRequirement('Z', 'prev')
5896        ReqTokens('token', 1)
5897        >>> g.getTransitionRequirement('X', 'down')
5898        ReqCapability('power2')
5899        >>> g.getTransitionRequirement('Z', 'up')
5900        ReqTokens('token2', 2)
5901        >>> g.getConsequence('Z', 'prev') == [
5902        ...     {
5903        ...         'type': 'gain',
5904        ...         'applyTo': 'active',
5905        ...         'value': 'power2',
5906        ...         'charges': None,
5907        ...         'delay': None,
5908        ...         'hidden': False
5909        ...     }
5910        ... ]
5911        True
5912
5913        ## Merging into node without tags
5914
5915        >>> g = DecisionGraph()
5916        >>> g.addDecision('X')
5917        0
5918        >>> g.addDecision('Y')
5919        1
5920        >>> g.tagDecision('Y', 'unconfirmed')  # special handling
5921        >>> g.tagDecision('Y', 'tag', 'value')
5922        >>> g.mergeDecisions('Y', 'X')
5923        {}
5924        >>> g.decisionTags('X')
5925        {'tag': 'value'}
5926        >>> 0 in g  # Second argument remains
5927        True
5928        >>> 1 in g  # First argument is deleted
5929        False
5930        """
5931        # Resolve IDs
5932        mergeID = self.resolveDecision(merge)
5933        mergeIntoID = self.resolveDecision(mergeInto)
5934
5935        # Create our result as an empty dictionary
5936        result: Dict[base.Transition, base.Transition] = {}
5937
5938        # Short-circuit if the two decisions are the same
5939        if mergeID == mergeIntoID:
5940            return result
5941
5942        # MissingDecisionErrors from here if either doesn't exist
5943        allNewOutgoing = set(self.destinationsFrom(mergeID))
5944        allOldOutgoing = set(self.destinationsFrom(mergeIntoID))
5945        # Find colliding transition names
5946        collisions = allNewOutgoing & allOldOutgoing
5947        if len(collisions) > 0 and errorOnNameColision:
5948            raise TransitionCollisionError(
5949                f"Cannot merge decision {self.identityOf(merge)} into"
5950                f" decision {self.identityOf(mergeInto)}: the decisions"
5951                f" share {len(collisions)} transition names:"
5952                f" {collisions}\n(Note that errorOnNameColision was set"
5953                f" to True, set it to False to allow the operation by"
5954                f" renaming half of those transitions.)"
5955            )
5956
5957        # Record zones that will have to change after the merge
5958        zoneParents = self.zoneParents(mergeID)
5959
5960        # First, swap all incoming edges, along with their reciprocals
5961        # This will include self-edges, which will be retargeted and
5962        # whose reciprocals will be rebased in the process, leading to
5963        # the possibility of a missing edge during the loop
5964        for source, incoming in self.allEdgesTo(mergeID):
5965            # Skip this edge if it was already swapped away because it's
5966            # a self-loop with a reciprocal whose reciprocal was
5967            # processed earlier in the loop
5968            if incoming not in self.destinationsFrom(source):
5969                continue
5970
5971            # Find corresponding outgoing edge
5972            outgoing = self.getReciprocal(source, incoming)
5973
5974            # Swap both edges to new destination
5975            newOutgoing = self.retargetTransition(
5976                source,
5977                incoming,
5978                mergeIntoID,
5979                swapReciprocal=True,
5980                errorOnNameColision=False # collisions were detected above
5981            )
5982            # Add to our result if the name of the reciprocal was
5983            # changed
5984            if (
5985                outgoing is not None
5986            and newOutgoing is not None
5987            and outgoing != newOutgoing
5988            ):
5989                result[outgoing] = newOutgoing
5990
5991        # Next, swap any remaining outgoing edges (which didn't have
5992        # reciprocals, or they'd already be swapped, unless they were
5993        # self-edges previously). Note that in this loop, there can't be
5994        # any self-edges remaining, although there might be connections
5995        # between the merging nodes that need to become self-edges
5996        # because they used to be a self-edge that was half-retargeted
5997        # by the previous loop.
5998        # Note: a copy is used here to avoid iterating over a changing
5999        # dictionary
6000        for stillOutgoing in copy.copy(self.destinationsFrom(mergeID)):
6001            newOutgoing = self.rebaseTransition(
6002                mergeID,
6003                stillOutgoing,
6004                mergeIntoID,
6005                swapReciprocal=True,
6006                errorOnNameColision=False # collisions were detected above
6007            )
6008            if stillOutgoing != newOutgoing:
6009                result[stillOutgoing] = newOutgoing
6010
6011        # At this point, there shouldn't be any remaining incoming or
6012        # outgoing edges!
6013        assert self.degree(mergeID) == 0
6014
6015        # Merge tags & annotations
6016        # Note that these operations affect the underlying graph
6017        destTags = self.decisionTags(mergeIntoID)
6018        destUnvisited = 'unconfirmed' in destTags
6019        sourceTags = self.decisionTags(mergeID)
6020        sourceUnvisited = 'unconfirmed' in sourceTags
6021        # Copy over only new tags, leaving existing tags alone
6022        for key in sourceTags:
6023            if key not in destTags:
6024                destTags[key] = sourceTags[key]
6025
6026        if int(destUnvisited) + int(sourceUnvisited) == 1:
6027            del destTags['unconfirmed']
6028
6029        self.decisionAnnotations(mergeIntoID).extend(
6030            self.decisionAnnotations(mergeID)
6031        )
6032
6033        # Transfer zones
6034        for zone in zoneParents:
6035            self.addDecisionToZone(mergeIntoID, zone)
6036
6037        # Delete the old node
6038        self.removeDecision(mergeID)
6039
6040        return result

Merges two decisions, deleting the first after transferring all of its incoming and outgoing edges to target the second one, whose name is retained. The second decision will be added to any zones that the first decision was a member of. If either decision does not exist, a MissingDecisionError will be raised. If merge and mergeInto are the same, then nothing will be changed.

Unless errorOnNameColision is set to False, a TransitionCollisionError will be raised if the two decisions have outgoing transitions with the same name. If errorOnNameColision is set to False, then such edges will be renamed using a suffix to avoid name collisions, with edges connected to the second decision retaining their original names and edges that were connected to the first decision getting renamed.

Any mechanisms located at the first decision will be moved to the merged decision.

The tags and annotations of the merged decision are added to the tags and annotations of the merge target. If there are shared tags, the values from the merge target will override those of the merged decision. If this is undesired behavior, clear/edit the tags/annotations of the merged decision before the merge.

The 'unconfirmed' tag is treated specially: if both decisions have it it will be retained, but otherwise it will be dropped even if one of the situations had it before.

The domain of the second decision is retained.

Returns a dictionary mapping each original transition name to its new name in cases where transitions get renamed; this will be empty when no re-naming occurs, including when errorOnNameColision is True. If there were any transitions connecting the nodes that were merged, these become self-edges of the merged node (and may be renamed if necessary). Note that all renamed transitions were originally based on the first (merged) node, since transitions of the second (merge target) node are not renamed.

Example

>>> g = DecisionGraph()
>>> for fr, to, nm in [
...     ('A', 'B', 'up'),
...     ('A', 'B', 'up2'),
...     ('B', 'A', 'down'),
...     ('B', 'B', 'self'),
...     ('B', 'C', 'next'),
...     ('C', 'B', 'prev'),
...     ('A', 'C', 'right')
... ]:
...     if g.getDecision(fr) is None:
...        g.addDecision(fr)
...     if g.getDecision(to) is None:
...         g.addDecision(to)
...     g.addTransition(fr, nm, to)
0
1
2
>>> g.getDestination('A', 'up')
1
>>> g.getDestination('B', 'down')
0
>>> sorted(g)
[0, 1, 2]
>>> g.setReciprocal('A', 'up', 'down')
>>> g.setReciprocal('B', 'next', 'prev')
>>> g.mergeDecisions('C', 'B')
{}
>>> g.destinationsFrom('A')
{'up': 1, 'up2': 1, 'right': 1}
>>> g.destinationsFrom('B')
{'down': 0, 'self': 1, 'prev': 1, 'next': 1}
>>> 'C' in g
False
>>> g.mergeDecisions('A', 'A') # does nothing
{}
>>> # Can't merge non-existent decision
>>> g.mergeDecisions('A', 'Z')
Traceback (most recent call last):
...
MissingDecisionError...
>>> g.mergeDecisions('Z', 'A')
Traceback (most recent call last):
...
MissingDecisionError...
>>> # Can't merge decisions w/ shared edge names
>>> g.addDecision('D')
3
>>> g.addTransition('D', 'next', 'A')
>>> g.addTransition('A', 'prev', 'D')
>>> g.setReciprocal('D', 'next', 'prev')
>>> g.mergeDecisions('D', 'B') # both have a 'next' transition
Traceback (most recent call last):
...
TransitionCollisionError...
>>> # Auto-rename colliding edges
>>> g.mergeDecisions('D', 'B', errorOnNameColision=False)
{'next': 'next.1'}
>>> g.destination('B', 'next') # merge target unchanged
1
>>> g.destination('B', 'next.1') # merged decision name changed
0
>>> g.destination('B', 'prev') # name unchanged (no collision)
1
>>> g.getReciprocal('B', 'next') # unchanged (from B)
'prev'
>>> g.getReciprocal('B', 'next.1') # from A
'prev'
>>> g.getReciprocal('A', 'prev') # from B
'next.1'

Folding four nodes into a 2-node loop

>>> g = DecisionGraph()
>>> g.addDecision('X')
0
>>> g.addDecision('Y')
1
>>> g.addTransition('X', 'next', 'Y', 'prev')
>>> g.addDecision('preX')
2
>>> g.addDecision('postY')
3
>>> g.addTransition('preX', 'next', 'X', 'prev')
>>> g.addTransition('Y', 'next', 'postY', 'prev')
>>> g.mergeDecisions('preX', 'Y', errorOnNameColision=False)
{'next': 'next.1'}
>>> g.destinationsFrom('X')
{'next': 1, 'prev': 1}
>>> g.destinationsFrom('Y')
{'prev': 0, 'next': 3, 'next.1': 0}
>>> 2 in g
False
>>> g.destinationsFrom('postY')
{'prev': 1}
>>> g.mergeDecisions('postY', 'X', errorOnNameColision=False)
{'prev': 'prev.1'}
>>> g.destinationsFrom('X')
{'next': 1, 'prev': 1, 'prev.1': 1}
>>> g.destinationsFrom('Y') # order 'cause of 'next' re-target
{'prev': 0, 'next.1': 0, 'next': 0}
>>> 2 in g
False
>>> 3 in g
False
>>> # Reciprocals are tangled...
>>> g.getReciprocal(0, 'prev')
'next.1'
>>> g.getReciprocal(0, 'prev.1')
'next'
>>> g.getReciprocal(1, 'next')
'prev.1'
>>> g.getReciprocal(1, 'next.1')
'prev'
>>> # Note: one merge cannot handle both extra transitions
>>> # because their reciprocals are crossed (e.g., prev.1 <-> next)
>>> # (It would merge both edges but the result would retain
>>> # 'next.1' instead of retaining 'next'.)
>>> g.mergeTransitions('X', 'prev.1', 'prev', mergeReciprocal=False)
>>> g.mergeTransitions('Y', 'next.1', 'next', mergeReciprocal=True)
>>> g.destinationsFrom('X')
{'next': 1, 'prev': 1}
>>> g.destinationsFrom('Y')
{'prev': 0, 'next': 0}
>>> # Reciprocals were salvaged in second merger
>>> g.getReciprocal('X', 'prev')
'next'
>>> g.getReciprocal('Y', 'next')
'prev'

Merging with tags/requirements/annotations/consequences

>>> g = DecisionGraph()
>>> g.addDecision('X')
0
>>> g.addDecision('Y')
1
>>> g.addDecision('Z')
2
>>> g.addTransition('X', 'next', 'Y', 'prev')
>>> g.addTransition('X', 'down', 'Z', 'up')
>>> g.tagDecision('X', 'tag0', 1)
>>> g.tagDecision('Y', 'tag1', 10)
>>> g.tagDecision('Y', 'unconfirmed')
>>> g.tagDecision('Z', 'tag1', 20)
>>> g.tagDecision('Z', 'tag2', 30)
>>> g.tagTransition('X', 'next', 'ttag1', 11)
>>> g.tagTransition('Y', 'prev', 'ttag2', 22)
>>> g.tagTransition('X', 'down', 'ttag3', 33)
>>> g.tagTransition('Z', 'up', 'ttag4', 44)
>>> g.annotateDecision('Y', 'annotation 1')
>>> g.annotateDecision('Z', 'annotation 2')
>>> g.annotateDecision('Z', 'annotation 3')
>>> g.annotateTransition('Y', 'prev', 'trans annotation 1')
>>> g.annotateTransition('Y', 'prev', 'trans annotation 2')
>>> g.annotateTransition('Z', 'up', 'trans annotation 3')
>>> g.setTransitionRequirement(
...     'X',
...     'next',
...     base.ReqCapability('power')
... )
>>> g.setTransitionRequirement(
...     'Y',
...     'prev',
...     base.ReqTokens('token', 1)
... )
>>> g.setTransitionRequirement(
...     'X',
...     'down',
...     base.ReqCapability('power2')
... )
>>> g.setTransitionRequirement(
...     'Z',
...     'up',
...     base.ReqTokens('token2', 2)
... )
>>> g.setConsequence(
...     'Y',
...     'prev',
...     [base.effect(gain="power2")]
... )
>>> g.mergeDecisions('Y', 'Z')
{}
>>> g.destination('X', 'next')
2
>>> g.destination('X', 'down')
2
>>> g.destination('Z', 'prev')
0
>>> g.destination('Z', 'up')
0
>>> g.decisionTags('X')
{'tag0': 1}
>>> g.decisionTags('Z')  # note that 'unconfirmed' is removed
{'tag1': 20, 'tag2': 30}
>>> g.transitionTags('X', 'next')
{'ttag1': 11}
>>> g.transitionTags('X', 'down')
{'ttag3': 33}
>>> g.transitionTags('Z', 'prev')
{'ttag2': 22}
>>> g.transitionTags('Z', 'up')
{'ttag4': 44}
>>> g.decisionAnnotations('Z')
['annotation 2', 'annotation 3', 'annotation 1']
>>> g.transitionAnnotations('Z', 'prev')
['trans annotation 1', 'trans annotation 2']
>>> g.transitionAnnotations('Z', 'up')
['trans annotation 3']
>>> g.getTransitionRequirement('X', 'next')
ReqCapability('power')
>>> g.getTransitionRequirement('Z', 'prev')
ReqTokens('token', 1)
>>> g.getTransitionRequirement('X', 'down')
ReqCapability('power2')
>>> g.getTransitionRequirement('Z', 'up')
ReqTokens('token2', 2)
>>> g.getConsequence('Z', 'prev') == [
...     {
...         'type': 'gain',
...         'applyTo': 'active',
...         'value': 'power2',
...         'charges': None,
...         'delay': None,
...         'hidden': False
...     }
... ]
True

Merging into node without tags

>>> g = DecisionGraph()
>>> g.addDecision('X')
0
>>> g.addDecision('Y')
1
>>> g.tagDecision('Y', 'unconfirmed')  # special handling
>>> g.tagDecision('Y', 'tag', 'value')
>>> g.mergeDecisions('Y', 'X')
{}
>>> g.decisionTags('X')
{'tag': 'value'}
>>> 0 in g  # Second argument remains
True
>>> 1 in g  # First argument is deleted
False
def removeDecision( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> None:
6042    def removeDecision(self, decision: base.AnyDecisionSpecifier) -> None:
6043        """
6044        Deletes the specified decision from the graph, updating
6045        attendant structures like zones. Note that the ID of the deleted
6046        node will NOT be reused, unless it's specifically provided to
6047        `addIdentifiedDecision`.
6048
6049        For example:
6050
6051        >>> dg = DecisionGraph()
6052        >>> dg.addDecision('A')
6053        0
6054        >>> dg.addDecision('B')
6055        1
6056        >>> list(dg)
6057        [0, 1]
6058        >>> 1 in dg
6059        True
6060        >>> 'B' in dg.nameLookup
6061        True
6062        >>> dg.removeDecision('B')
6063        >>> 1 in dg
6064        False
6065        >>> list(dg)
6066        [0]
6067        >>> 'B' in dg.nameLookup
6068        False
6069        >>> dg.addDecision('C')  # doesn't re-use ID
6070        2
6071        """
6072        dID = self.resolveDecision(decision)
6073
6074        # Remove the target from all zones:
6075        for zone in self.zones:
6076            self.removeDecisionFromZone(dID, zone)
6077
6078        # Remove the node but record the current name
6079        name = self.nodes[dID]['name']
6080        self.remove_node(dID)
6081
6082        # Clean up the nameLookup entry
6083        luInfo = self.nameLookup[name]
6084        luInfo.remove(dID)
6085        if len(luInfo) == 0:
6086            self.nameLookup.pop(name)
6087
6088        # TODO: Clean up edges?

Deletes the specified decision from the graph, updating attendant structures like zones. Note that the ID of the deleted node will NOT be reused, unless it's specifically provided to addIdentifiedDecision.

For example:

>>> dg = DecisionGraph()
>>> dg.addDecision('A')
0
>>> dg.addDecision('B')
1
>>> list(dg)
[0, 1]
>>> 1 in dg
True
>>> 'B' in dg.nameLookup
True
>>> dg.removeDecision('B')
>>> 1 in dg
False
>>> list(dg)
[0]
>>> 'B' in dg.nameLookup
False
>>> dg.addDecision('C')  # doesn't re-use ID
2
def renameDecision( self, decision: Union[int, exploration.base.DecisionSpecifier, str], newName: str):
6090    def renameDecision(
6091        self,
6092        decision: base.AnyDecisionSpecifier,
6093        newName: base.DecisionName
6094    ):
6095        """
6096        Renames a decision. The decision retains its old ID.
6097
6098        Generates a `DecisionCollisionWarning` if a decision using the new
6099        name already exists and `WARN_OF_NAME_COLLISIONS` is enabled.
6100
6101        Example:
6102
6103        >>> g = DecisionGraph()
6104        >>> g.addDecision('one')
6105        0
6106        >>> g.addDecision('three')
6107        1
6108        >>> g.addTransition('one', '>', 'three')
6109        >>> g.addTransition('three', '<', 'one')
6110        >>> g.tagDecision('three', 'hi')
6111        >>> g.annotateDecision('three', 'note')
6112        >>> g.destination('one', '>')
6113        1
6114        >>> g.destination('three', '<')
6115        0
6116        >>> g.renameDecision('three', 'two')
6117        >>> g.resolveDecision('one')
6118        0
6119        >>> g.resolveDecision('two')
6120        1
6121        >>> g.resolveDecision('three')
6122        Traceback (most recent call last):
6123        ...
6124        exploration.core.MissingDecisionError...
6125        >>> g.destination('one', '>')
6126        1
6127        >>> g.nameFor(1)
6128        'two'
6129        >>> g.getDecision('three') is None
6130        True
6131        >>> g.destination('two', '<')
6132        0
6133        >>> g.decisionTags('two')
6134        {'hi': 1}
6135        >>> g.decisionAnnotations('two')
6136        ['note']
6137        """
6138        dID = self.resolveDecision(decision)
6139
6140        if newName in self.nameLookup and WARN_OF_NAME_COLLISIONS:
6141            warnings.warn(
6142                (
6143                    f"Can't rename {self.identityOf(decision)} as"
6144                    f" {newName!r} because a decision with that name"
6145                    f" already exists."
6146                ),
6147                DecisionCollisionWarning
6148            )
6149
6150        # Update name in node
6151        oldName = self.nodes[dID]['name']
6152        self.nodes[dID]['name'] = newName
6153
6154        # Update nameLookup entries
6155        oldNL = self.nameLookup[oldName]
6156        oldNL.remove(dID)
6157        if len(oldNL) == 0:
6158            self.nameLookup.pop(oldName)
6159        self.nameLookup.setdefault(newName, []).append(dID)

Renames a decision. The decision retains its old ID.

Generates a DecisionCollisionWarning if a decision using the new name already exists and WARN_OF_NAME_COLLISIONS is enabled.

Example:

>>> g = DecisionGraph()
>>> g.addDecision('one')
0
>>> g.addDecision('three')
1
>>> g.addTransition('one', '>', 'three')
>>> g.addTransition('three', '<', 'one')
>>> g.tagDecision('three', 'hi')
>>> g.annotateDecision('three', 'note')
>>> g.destination('one', '>')
1
>>> g.destination('three', '<')
0
>>> g.renameDecision('three', 'two')
>>> g.resolveDecision('one')
0
>>> g.resolveDecision('two')
1
>>> g.resolveDecision('three')
Traceback (most recent call last):
...
MissingDecisionError...
>>> g.destination('one', '>')
1
>>> g.nameFor(1)
'two'
>>> g.getDecision('three') is None
True
>>> g.destination('two', '<')
0
>>> g.decisionTags('two')
{'hi': 1}
>>> g.decisionAnnotations('two')
['note']
def renameTransition( self, fromDecision: Union[int, exploration.base.DecisionSpecifier, str], oldName: str, newName: str):
6161    def renameTransition(
6162        self,
6163        fromDecision: base.AnyDecisionSpecifier,
6164        oldName: base.Transition,
6165        newName: base.Transition
6166    ):
6167        """
6168        Renames a transition. The transition retains its reciprocal
6169        association if it had one. The new name must not already exist as
6170        a transition name at the specified decision (see
6171        `mergeTransitions` for an alternative), or a
6172        `TransitionCollisionError` will be raised. Renaming to the same
6173        name does nothing.
6174
6175        Example:
6176
6177        >>> g = DecisionGraph()
6178        >>> g.addDecision('A')
6179        0
6180        >>> g.addDecision('B')
6181        1
6182        >>> g.addTransition('A', 'right', 'B', 'left')
6183        >>> g.getDestination('A', 'right')
6184        1
6185        >>> g.renameTransition('A', 'right', 'up')
6186        >>> g.getDestination('A', 'right') is None
6187        True
6188        >>> g.getDestination('A', 'up')
6189        1
6190        >>> g.getReciprocal('A', 'up')
6191        'left'
6192        >>> g.renameTransition('B', 'left', 'left')
6193        >>> g.getDestination('B', 'left')
6194        0
6195        >>> g.addTransition('B', 'down', 'A')
6196        >>> g.renameTransition('B', 'left', 'down')
6197        Traceback (most recent call last):
6198        ...
6199        exploration.core.TransitionCollisionError...
6200        >>> g.renameTransition('A', 'madeup', 'any')
6201        Traceback (most recent call last):
6202        ...
6203        exploration.core.MissingTransitionError...
6204        """
6205        if oldName == newName:
6206            return
6207
6208        dID = self.resolveDecision(fromDecision)
6209        dest = self.destination(dID, oldName)
6210          # this will raise MissingTransitionError if necessary
6211        if self.getDestination(dID, newName) is not None:
6212            raise TransitionCollisionError(
6213                f"Decision {self.shortIdentity(dID)} already has an"
6214                f" outgoing transition named {newName!r} so you cannot"
6215                f" rename transition {oldName!r} to that name."
6216            )
6217
6218        # Add a new transition without a reciprocal or any properties
6219        self.addTransition(dID, newName, dest)
6220
6221        # Merge old one into new one, setting new's reciprocal to old's
6222        self.mergeTransitions(dID, oldName, newName, mergeReciprocal=True)

Renames a transition. The transition retains its reciprocal association if it had one. The new name must not already exist as a transition name at the specified decision (see mergeTransitions for an alternative), or a TransitionCollisionError will be raised. Renaming to the same name does nothing.

Example:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addTransition('A', 'right', 'B', 'left')
>>> g.getDestination('A', 'right')
1
>>> g.renameTransition('A', 'right', 'up')
>>> g.getDestination('A', 'right') is None
True
>>> g.getDestination('A', 'up')
1
>>> g.getReciprocal('A', 'up')
'left'
>>> g.renameTransition('B', 'left', 'left')
>>> g.getDestination('B', 'left')
0
>>> g.addTransition('B', 'down', 'A')
>>> g.renameTransition('B', 'left', 'down')
Traceback (most recent call last):
...
TransitionCollisionError...
>>> g.renameTransition('A', 'madeup', 'any')
Traceback (most recent call last):
...
MissingTransitionError...
def mergeTransitions( self, fromDecision: Union[int, exploration.base.DecisionSpecifier, str], merge: str, mergeInto: str, mergeReciprocal=True) -> None:
6224    def mergeTransitions(
6225        self,
6226        fromDecision: base.AnyDecisionSpecifier,
6227        merge: base.Transition,
6228        mergeInto: base.Transition,
6229        mergeReciprocal=True
6230    ) -> None:
6231        """
6232        Given a decision and two transitions that start at that decision,
6233        merges the first transition into the second transition, combining
6234        their transition properties (using `mergeProperties`) and
6235        deleting the first transition. By default any reciprocal of the
6236        first transition is also merged into the reciprocal of the
6237        second, although you can set `mergeReciprocal` to `False` to
6238        disable this in which case the old reciprocal will lose its
6239        reciprocal relationship, even if the transition that was merged
6240        into does not have a reciprocal.
6241
6242        If the two names provided are the same, nothing will happen.
6243
6244        If the two transitions do not share the same destination, they
6245        cannot be merged, and an `InvalidDestinationError` will result.
6246        Use `retargetTransition` beforehand to ensure that they do if you
6247        want to merge transitions with different destinations.
6248
6249        A `MissingDecisionError` or `MissingTransitionError` will result
6250        if the decision or either transition does not exist.
6251
6252        If merging reciprocal properties was requested and the first
6253        transition does not have a reciprocal, then no reciprocal
6254        properties change. However, if the second transition does not
6255        have a reciprocal and the first does, the first transition's
6256        reciprocal will be set as the reciprocal of the second
6257        transition, and that transition will not be deleted as usual.
6258
6259        ## Example
6260
6261        >>> g = DecisionGraph()
6262        >>> g.addDecision('A')
6263        0
6264        >>> g.addDecision('B')
6265        1
6266        >>> g.addTransition('A', 'up', 'B')
6267        >>> g.addTransition('B', 'down', 'A')
6268        >>> g.setReciprocal('A', 'up', 'down')
6269        >>> # Merging a transition with no reciprocal
6270        >>> g.addTransition('A', 'up2', 'B')
6271        >>> g.mergeTransitions('A', 'up2', 'up')
6272        >>> g.getDestination('A', 'up2') is None
6273        True
6274        >>> g.getDestination('A', 'up')
6275        1
6276        >>> # Merging a transition with a reciprocal & tags
6277        >>> g.addTransition('A', 'up2', 'B')
6278        >>> g.addTransition('B', 'down2', 'A')
6279        >>> g.setReciprocal('A', 'up2', 'down2')
6280        >>> g.tagTransition('A', 'up2', 'one')
6281        >>> g.tagTransition('B', 'down2', 'two')
6282        >>> g.mergeTransitions('B', 'down2', 'down')
6283        >>> g.getDestination('A', 'up2') is None
6284        True
6285        >>> g.getDestination('A', 'up')
6286        1
6287        >>> g.getDestination('B', 'down2') is None
6288        True
6289        >>> g.getDestination('B', 'down')
6290        0
6291        >>> # Merging requirements uses ReqAll (i.e., 'and' logic)
6292        >>> g.addTransition('A', 'up2', 'B')
6293        >>> g.setTransitionProperties(
6294        ...     'A',
6295        ...     'up2',
6296        ...     requirement=base.ReqCapability('dash')
6297        ... )
6298        >>> g.setTransitionProperties('A', 'up',
6299        ...     requirement=base.ReqCapability('slide'))
6300        >>> g.mergeTransitions('A', 'up2', 'up')
6301        >>> g.getDestination('A', 'up2') is None
6302        True
6303        >>> repr(g.getTransitionRequirement('A', 'up'))
6304        "ReqAll([ReqCapability('dash'), ReqCapability('slide')])"
6305        >>> # Errors if destinations differ, or if something is missing
6306        >>> g.mergeTransitions('A', 'down', 'up')
6307        Traceback (most recent call last):
6308        ...
6309        exploration.core.MissingTransitionError...
6310        >>> g.mergeTransitions('Z', 'one', 'two')
6311        Traceback (most recent call last):
6312        ...
6313        exploration.core.MissingDecisionError...
6314        >>> g.addDecision('C')
6315        2
6316        >>> g.addTransition('A', 'down', 'C')
6317        >>> g.mergeTransitions('A', 'down', 'up')
6318        Traceback (most recent call last):
6319        ...
6320        exploration.core.InvalidDestinationError...
6321        >>> # Merging a reciprocal onto an edge that doesn't have one
6322        >>> g.addTransition('A', 'down2', 'C')
6323        >>> g.addTransition('C', 'up2', 'A')
6324        >>> g.setReciprocal('A', 'down2', 'up2')
6325        >>> g.tagTransition('C', 'up2', 'narrow')
6326        >>> g.getReciprocal('A', 'down') is None
6327        True
6328        >>> g.mergeTransitions('A', 'down2', 'down')
6329        >>> g.getDestination('A', 'down2') is None
6330        True
6331        >>> g.getDestination('A', 'down')
6332        2
6333        >>> g.getDestination('C', 'up2')
6334        0
6335        >>> g.getReciprocal('A', 'down')
6336        'up2'
6337        >>> g.getReciprocal('C', 'up2')
6338        'down'
6339        >>> g.transitionTags('C', 'up2')
6340        {'narrow': 1}
6341        >>> # Merging without a reciprocal
6342        >>> g.addTransition('C', 'up', 'A')
6343        >>> g.mergeTransitions('C', 'up2', 'up', mergeReciprocal=False)
6344        >>> g.getDestination('C', 'up2') is None
6345        True
6346        >>> g.getDestination('C', 'up')
6347        0
6348        >>> g.transitionTags('C', 'up') # tag gets merged
6349        {'narrow': 1}
6350        >>> g.getDestination('A', 'down')
6351        2
6352        >>> g.getReciprocal('A', 'down') is None
6353        True
6354        >>> g.getReciprocal('C', 'up') is None
6355        True
6356        >>> # Merging w/ normal reciprocals
6357        >>> g.addDecision('D')
6358        3
6359        >>> g.addDecision('E')
6360        4
6361        >>> g.addTransition('D', 'up', 'E', 'return')
6362        >>> g.addTransition('E', 'down', 'D')
6363        >>> g.mergeTransitions('E', 'return', 'down')
6364        >>> g.getDestination('D', 'up')
6365        4
6366        >>> g.getDestination('E', 'down')
6367        3
6368        >>> g.getDestination('E', 'return') is None
6369        True
6370        >>> g.getReciprocal('D', 'up')
6371        'down'
6372        >>> g.getReciprocal('E', 'down')
6373        'up'
6374        >>> # Merging w/ weird reciprocals
6375        >>> g.addTransition('E', 'return', 'D')
6376        >>> g.setReciprocal('E', 'return', 'up', setBoth=False)
6377        >>> g.getReciprocal('D', 'up')
6378        'down'
6379        >>> g.getReciprocal('E', 'down')
6380        'up'
6381        >>> g.getReciprocal('E', 'return') # shared
6382        'up'
6383        >>> g.mergeTransitions('E', 'return', 'down')
6384        >>> g.getDestination('D', 'up')
6385        4
6386        >>> g.getDestination('E', 'down')
6387        3
6388        >>> g.getDestination('E', 'return') is None
6389        True
6390        >>> g.getReciprocal('D', 'up')
6391        'down'
6392        >>> g.getReciprocal('E', 'down')
6393        'up'
6394        """
6395        fromID = self.resolveDecision(fromDecision)
6396
6397        # Short-circuit in the no-op case
6398        if merge == mergeInto:
6399            return
6400
6401        # These lines will raise a MissingDecisionError or
6402        # MissingTransitionError if needed
6403        dest1 = self.destination(fromID, merge)
6404        dest2 = self.destination(fromID, mergeInto)
6405
6406        if dest1 != dest2:
6407            raise InvalidDestinationError(
6408                f"Cannot merge transition {merge!r} into transition"
6409                f" {mergeInto!r} from decision"
6410                f" {self.identityOf(fromDecision)} because their"
6411                f" destinations are different ({self.identityOf(dest1)}"
6412                f" and {self.identityOf(dest2)}).\nNote: you can use"
6413                f" `retargetTransition` to change the destination of a"
6414                f" transition."
6415            )
6416
6417        # Find and the transition properties
6418        props1 = self.getTransitionProperties(fromID, merge)
6419        props2 = self.getTransitionProperties(fromID, mergeInto)
6420        merged = mergeProperties(props1, props2)
6421        # Note that this doesn't change the reciprocal:
6422        self.setTransitionProperties(fromID, mergeInto, **merged)
6423
6424        # Merge the reciprocal properties if requested
6425        # Get reciprocal to merge into
6426        reciprocal = self.getReciprocal(fromID, mergeInto)
6427        # Get reciprocal that needs cleaning up
6428        altReciprocal = self.getReciprocal(fromID, merge)
6429        # If the reciprocal to be merged actually already was the
6430        # reciprocal to merge into, there's nothing to do here
6431        if altReciprocal != reciprocal:
6432            if not mergeReciprocal:
6433                # In this case, we sever the reciprocal relationship if
6434                # there is a reciprocal
6435                if altReciprocal is not None:
6436                    self.setReciprocal(dest1, altReciprocal, None)
6437                    # By default setBoth takes care of the other half
6438            else:
6439                # In this case, we try to merge reciprocals
6440                # If altReciprocal is None, we don't need to do anything
6441                if altReciprocal is not None:
6442                    # Was there already a reciprocal or not?
6443                    if reciprocal is None:
6444                        # altReciprocal becomes the new reciprocal and is
6445                        # not deleted
6446                        self.setReciprocal(
6447                            fromID,
6448                            mergeInto,
6449                            altReciprocal
6450                        )
6451                    else:
6452                        # merge reciprocal properties
6453                        props1 = self.getTransitionProperties(
6454                            dest1,
6455                            altReciprocal
6456                        )
6457                        props2 = self.getTransitionProperties(
6458                            dest2,
6459                            reciprocal
6460                        )
6461                        merged = mergeProperties(props1, props2)
6462                        self.setTransitionProperties(
6463                            dest1,
6464                            reciprocal,
6465                            **merged
6466                        )
6467
6468                        # delete the old reciprocal transition
6469                        self.remove_edge(dest1, fromID, altReciprocal)
6470
6471        # Delete the old transition (reciprocal deletion/severance is
6472        # handled above if necessary)
6473        self.remove_edge(fromID, dest1, merge)

Given a decision and two transitions that start at that decision, merges the first transition into the second transition, combining their transition properties (using mergeProperties) and deleting the first transition. By default any reciprocal of the first transition is also merged into the reciprocal of the second, although you can set mergeReciprocal to False to disable this in which case the old reciprocal will lose its reciprocal relationship, even if the transition that was merged into does not have a reciprocal.

If the two names provided are the same, nothing will happen.

If the two transitions do not share the same destination, they cannot be merged, and an InvalidDestinationError will result. Use retargetTransition beforehand to ensure that they do if you want to merge transitions with different destinations.

A MissingDecisionError or MissingTransitionError will result if the decision or either transition does not exist.

If merging reciprocal properties was requested and the first transition does not have a reciprocal, then no reciprocal properties change. However, if the second transition does not have a reciprocal and the first does, the first transition's reciprocal will be set as the reciprocal of the second transition, and that transition will not be deleted as usual.

Example

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addTransition('A', 'up', 'B')
>>> g.addTransition('B', 'down', 'A')
>>> g.setReciprocal('A', 'up', 'down')
>>> # Merging a transition with no reciprocal
>>> g.addTransition('A', 'up2', 'B')
>>> g.mergeTransitions('A', 'up2', 'up')
>>> g.getDestination('A', 'up2') is None
True
>>> g.getDestination('A', 'up')
1
>>> # Merging a transition with a reciprocal & tags
>>> g.addTransition('A', 'up2', 'B')
>>> g.addTransition('B', 'down2', 'A')
>>> g.setReciprocal('A', 'up2', 'down2')
>>> g.tagTransition('A', 'up2', 'one')
>>> g.tagTransition('B', 'down2', 'two')
>>> g.mergeTransitions('B', 'down2', 'down')
>>> g.getDestination('A', 'up2') is None
True
>>> g.getDestination('A', 'up')
1
>>> g.getDestination('B', 'down2') is None
True
>>> g.getDestination('B', 'down')
0
>>> # Merging requirements uses ReqAll (i.e., 'and' logic)
>>> g.addTransition('A', 'up2', 'B')
>>> g.setTransitionProperties(
...     'A',
...     'up2',
...     requirement=base.ReqCapability('dash')
... )
>>> g.setTransitionProperties('A', 'up',
...     requirement=base.ReqCapability('slide'))
>>> g.mergeTransitions('A', 'up2', 'up')
>>> g.getDestination('A', 'up2') is None
True
>>> repr(g.getTransitionRequirement('A', 'up'))
"ReqAll([ReqCapability('dash'), ReqCapability('slide')])"
>>> # Errors if destinations differ, or if something is missing
>>> g.mergeTransitions('A', 'down', 'up')
Traceback (most recent call last):
...
MissingTransitionError...
>>> g.mergeTransitions('Z', 'one', 'two')
Traceback (most recent call last):
...
MissingDecisionError...
>>> g.addDecision('C')
2
>>> g.addTransition('A', 'down', 'C')
>>> g.mergeTransitions('A', 'down', 'up')
Traceback (most recent call last):
...
InvalidDestinationError...
>>> # Merging a reciprocal onto an edge that doesn't have one
>>> g.addTransition('A', 'down2', 'C')
>>> g.addTransition('C', 'up2', 'A')
>>> g.setReciprocal('A', 'down2', 'up2')
>>> g.tagTransition('C', 'up2', 'narrow')
>>> g.getReciprocal('A', 'down') is None
True
>>> g.mergeTransitions('A', 'down2', 'down')
>>> g.getDestination('A', 'down2') is None
True
>>> g.getDestination('A', 'down')
2
>>> g.getDestination('C', 'up2')
0
>>> g.getReciprocal('A', 'down')
'up2'
>>> g.getReciprocal('C', 'up2')
'down'
>>> g.transitionTags('C', 'up2')
{'narrow': 1}
>>> # Merging without a reciprocal
>>> g.addTransition('C', 'up', 'A')
>>> g.mergeTransitions('C', 'up2', 'up', mergeReciprocal=False)
>>> g.getDestination('C', 'up2') is None
True
>>> g.getDestination('C', 'up')
0
>>> g.transitionTags('C', 'up') # tag gets merged
{'narrow': 1}
>>> g.getDestination('A', 'down')
2
>>> g.getReciprocal('A', 'down') is None
True
>>> g.getReciprocal('C', 'up') is None
True
>>> # Merging w/ normal reciprocals
>>> g.addDecision('D')
3
>>> g.addDecision('E')
4
>>> g.addTransition('D', 'up', 'E', 'return')
>>> g.addTransition('E', 'down', 'D')
>>> g.mergeTransitions('E', 'return', 'down')
>>> g.getDestination('D', 'up')
4
>>> g.getDestination('E', 'down')
3
>>> g.getDestination('E', 'return') is None
True
>>> g.getReciprocal('D', 'up')
'down'
>>> g.getReciprocal('E', 'down')
'up'
>>> # Merging w/ weird reciprocals
>>> g.addTransition('E', 'return', 'D')
>>> g.setReciprocal('E', 'return', 'up', setBoth=False)
>>> g.getReciprocal('D', 'up')
'down'
>>> g.getReciprocal('E', 'down')
'up'
>>> g.getReciprocal('E', 'return') # shared
'up'
>>> g.mergeTransitions('E', 'return', 'down')
>>> g.getDestination('D', 'up')
4
>>> g.getDestination('E', 'down')
3
>>> g.getDestination('E', 'return') is None
True
>>> g.getReciprocal('D', 'up')
'down'
>>> g.getReciprocal('E', 'down')
'up'
def renameZone(self, oldName: str, newName: str):
6475    def renameZone(self, oldName: base.Zone, newName: base.Zone):
6476        """
6477        Renames the specified zone. Raises a `ZoneCollisionError` if the
6478        new name is already taken.
6479
6480        Example:
6481
6482        >>> g = DecisionGraph()
6483        >>> g.addDecision("A")
6484        0
6485        >>> g.addDecision("B")
6486        1
6487        >>> g.createZone('Z', 0)
6488        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
6489 annotations=[])
6490        >>> g.createZone('ZZ', 1)
6491        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
6492 annotations=[])
6493        >>> g.addZoneToZone("Z", "ZZ")
6494        >>> g.addDecisionToZone("A", "Z")
6495        >>> g.renameZone("Z", "Q")
6496        >>> sorted(g.zoneAncestors(0))
6497        ['Q', 'ZZ']
6498        >>> g.decisionsInZone('Z')
6499        Traceback (most recent call last):
6500        ...
6501        exploration.core.MissingZoneError...
6502        >>> g.decisionsInZone('Q')
6503        {0}
6504        """
6505        if newName in self.zones:
6506            raise ZoneCollisionError(
6507               f"Cannot rename zone {oldName!r} to {newName!r} because"
6508               f" a zone with that new name already exists."
6509            )
6510        # Transfer zone info & delete old entry
6511        self.zones[newName] = self.zones[oldName]
6512        del self.zones[oldName]
6513
6514        # Fix up child/contents info in ALL zones
6515        for zoneInfo in self.zones.values():
6516            if oldName in zoneInfo.parents:
6517                zoneInfo.parents.remove(oldName)
6518                zoneInfo.parents.add(newName)
6519            if oldName in zoneInfo.contents:
6520                zoneInfo.contents.remove(oldName)
6521                zoneInfo.contents.add(newName)
6522
6523        # Fix up decision parent info
6524        for n in self.nodes():
6525            zones = self.nodes[n].get('zones')
6526            if zones is not None:
6527                if oldName in zones:
6528                    zones.remove(oldName)
6529                    zones.add(newName)

Renames the specified zone. Raises a ZoneCollisionError if the new name is already taken.

Example:

>>> g = DecisionGraph()
>>> g.addDecision("A")
0
>>> g.addDecision("B")
1
>>> g.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('ZZ', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addZoneToZone("Z", "ZZ")
>>> g.addDecisionToZone("A", "Z")
>>> g.renameZone("Z", "Q")
>>> sorted(g.zoneAncestors(0))
['Q', 'ZZ']
>>> g.decisionsInZone('Z')
Traceback (most recent call last):
...
MissingZoneError...
>>> g.decisionsInZone('Q')
{0}
def isConfirmed( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> bool:
6531    def isConfirmed(self, decision: base.AnyDecisionSpecifier) -> bool:
6532        """
6533        Returns `True` or `False` depending on whether or not the
6534        specified decision has been confirmed. Uses the presence or
6535        absence of the 'unconfirmed' tag to determine this.
6536
6537        Note: 'unconfirmed' is used instead of 'confirmed' so that large
6538        graphs with many confirmed nodes will be smaller when saved.
6539        """
6540        dID = self.resolveDecision(decision)
6541
6542        return 'unconfirmed' not in self.nodes[dID]['tags']

Returns True or False depending on whether or not the specified decision has been confirmed. Uses the presence or absence of the 'unconfirmed' tag to determine this.

Note: 'unconfirmed' is used instead of 'confirmed' so that large graphs with many confirmed nodes will be smaller when saved.

def replaceUnconfirmed( self, fromDecision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, connectTo: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None, reciprocal: Optional[str] = None, requirement: Optional[exploration.base.Requirement] = None, applyConsequence: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None, placeInZone: Optional[str] = None, forceNew: bool = False, tags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, annotations: Optional[List[str]] = None, revRequires: Optional[exploration.base.Requirement] = None, revConsequence: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None, revTags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, revAnnotations: Optional[List[str]] = None, decisionTags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, decisionAnnotations: Optional[List[str]] = None) -> Tuple[Dict[str, str], Dict[str, str]]:
6544    def replaceUnconfirmed(
6545        self,
6546        fromDecision: base.AnyDecisionSpecifier,
6547        transition: base.Transition,
6548        connectTo: Optional[base.AnyDecisionSpecifier] = None,
6549        reciprocal: Optional[base.Transition] = None,
6550        requirement: Optional[base.Requirement] = None,
6551        applyConsequence: Optional[base.Consequence] = None,
6552        placeInZone: Optional[base.Zone] = None,
6553        forceNew: bool = False,
6554        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
6555        annotations: Optional[List[base.Annotation]] = None,
6556        revRequires: Optional[base.Requirement] = None,
6557        revConsequence: Optional[base.Consequence] = None,
6558        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
6559        revAnnotations: Optional[List[base.Annotation]] = None,
6560        decisionTags: Optional[Dict[base.Tag, base.TagValue]] = None,
6561        decisionAnnotations: Optional[List[base.Annotation]] = None
6562    ) -> Tuple[
6563        Dict[base.Transition, base.Transition],
6564        Dict[base.Transition, base.Transition]
6565    ]:
6566        """
6567        Given a decision and an edge name in that decision, where the
6568        named edge leads to a decision with an unconfirmed exploration
6569        state (see `isConfirmed`), renames the unexplored decision on
6570        the other end of that edge using the given `connectTo` name, or
6571        if a decision using that name already exists, merges the
6572        unexplored decision into that decision. If `connectTo` is a
6573        `DecisionSpecifier` whose target doesn't exist, it will be
6574        treated as just a name, but if it's an ID and it doesn't exist,
6575        you'll get a `MissingDecisionError`. If a `reciprocal` is provided,
6576        a reciprocal edge will be added using that name connecting the
6577        `connectTo` decision back to the original decision. If this
6578        transition already exists, it must also point to a node which is
6579        also unexplored, and which will also be merged into the
6580        `fromDecision` node.
6581
6582        If `connectTo` is not given (or is set to `None` explicitly)
6583        then the name of the unexplored decision will not be changed,
6584        unless that name has the form `'_u.-n-'` where `-n-` is a positive
6585        integer (i.e., the form given to automatically-named unknown
6586        nodes). In that case, the name will be changed to `'_x.-n-'` using
6587        the same number, or a higher number if that name is already taken.
6588
6589        If the destination is being renamed or if the destination's
6590        exploration state counts as unexplored, the exploration state of
6591        the destination will be set to 'exploring'.
6592
6593        If a `placeInZone` is specified, the destination will be placed
6594        directly into that zone (even if it already existed and has zone
6595        information), and it will be removed from any other zones it had
6596        been a direct member of. If `placeInZone` is set to
6597        `base.DefaultZone`, then the destination will be placed into
6598        each zone which is a direct parent of the origin, but only if
6599        the destination is not an already-explored existing decision AND
6600        it is not already in any zones (in those cases no zone changes
6601        are made). This will also remove it from any previous zones it
6602        had been a part of. If `placeInZone` is left as `None` (the
6603        default) no zone changes are made.
6604
6605        If `placeInZone` is specified and that zone didn't already exist,
6606        it will be created as a new level-0 zone and will be added as a
6607        sub-zone of each zone that's a direct parent of any level-0 zone
6608        that the origin is a member of.
6609
6610        If `forceNew` is specified, then the destination will just be
6611        renamed, even if another decision with the same name already
6612        exists. It's an error to use `forceNew` with a decision ID as
6613        the destination.
6614
6615        Any additional edges pointing to or from the unknown node(s)
6616        being replaced will also be re-targeted at the now-discovered
6617        known destination(s) if necessary. These edges will retain their
6618        reciprocal names, or if this would cause a name clash, they will
6619        be renamed with a suffix (see `retargetTransition`).
6620
6621        The return value is a pair of dictionaries mapping old names to
6622        new ones that just includes the names which were changed. The
6623        first dictionary contains renamed transitions that are outgoing
6624        from the new destination node (which used to be outgoing from
6625        the unexplored node). The second dictionary contains renamed
6626        transitions that are outgoing from the source node (which used
6627        to be outgoing from the unexplored node attached to the
6628        reciprocal transition; if there was no reciprocal transition
6629        specified then this will always be an empty dictionary).
6630
6631        An `ExplorationStatusError` will be raised if the destination
6632        of the specified transition counts as visited (see
6633        `hasBeenVisited`). An `ExplorationStatusError` will also be
6634        raised if the `connectTo`'s `reciprocal` transition does not lead
6635        to an unconfirmed decision (it's okay if this second transition
6636        doesn't exist). A `TransitionCollisionError` will be raised if
6637        the unconfirmed destination decision already has an outgoing
6638        transition with the specified `reciprocal` which does not lead
6639        back to the `fromDecision`.
6640
6641        The transition properties (requirement, consequences, tags,
6642        and/or annotations) of the replaced transition will be copied
6643        over to the new transition. Transition properties from the
6644        reciprocal transition will also be copied for the newly created
6645        reciprocal edge. Properties for any additional edges to/from the
6646        unknown node will also be copied.
6647
6648        Also, any transition properties on existing forward or reciprocal
6649        edges from the destination node with the indicated reverse name
6650        will be merged with those from the target transition. Note that
6651        this merging process may introduce corruption of complex
6652        transition consequences. TODO: Fix that!
6653
6654        Any tags and annotations are added to copied tags/annotations,
6655        but specified requirements, and/or consequences will replace
6656        previous requirements/consequences, rather than being added to
6657        them.
6658
6659        ## Example
6660
6661        >>> g = DecisionGraph()
6662        >>> g.addDecision('A')
6663        0
6664        >>> g.addUnexploredEdge('A', 'up')
6665        1
6666        >>> g.destination('A', 'up')
6667        1
6668        >>> g.degree(1)
6669        1
6670        >>> g.replaceUnconfirmed('A', 'up', 'B', 'down')
6671        ({}, {})
6672        >>> g.destination('A', 'up')
6673        1
6674        >>> g.nameFor(1)
6675        'B'
6676        >>> g.destination('B', 'down')
6677        0
6678        >>> g.getDestination('B', 'return') is None
6679        True
6680        >>> '_u.0' in g.nameLookup
6681        False
6682        >>> g.getReciprocal('A', 'up')
6683        'down'
6684        >>> g.getReciprocal('B', 'down')
6685        'up'
6686        >>> # Two unexplored edges to the same node:
6687        >>> g.addDecision('C')
6688        2
6689        >>> g.addTransition('B', 'next', 'C')
6690        >>> g.addTransition('C', 'prev', 'B')
6691        >>> g.setReciprocal('B', 'next', 'prev')
6692        >>> g.addUnexploredEdge('A', 'next', 'D', 'prev')
6693        3
6694        >>> g.addTransition('C', 'down', 'D')
6695        >>> g.addTransition('D', 'up', 'C')
6696        >>> g.setReciprocal('C', 'down', 'up')
6697        >>> g.replaceUnconfirmed('C', 'down')
6698        ({}, {})
6699        >>> g.destination('C', 'down')
6700        3
6701        >>> g.destination('A', 'next')
6702        3
6703        >>> g.destinationsFrom('D')
6704        {'prev': 0, 'up': 2}
6705        >>> g.decisionTags('D')
6706        {}
6707        >>> # An unexplored transition which turns out to connect to a
6708        >>> # known decision, with name collisions
6709        >>> g.addUnexploredEdge('D', 'next', reciprocal='prev')
6710        4
6711        >>> g.tagDecision('_u.2', 'wet')
6712        >>> g.addUnexploredEdge('B', 'next', reciprocal='prev') # edge taken
6713        Traceback (most recent call last):
6714        ...
6715        exploration.core.TransitionCollisionError...
6716        >>> g.addUnexploredEdge('A', 'prev', reciprocal='next')
6717        5
6718        >>> g.tagDecision('_u.3', 'dry')
6719        >>> # Add transitions that will collide when merged
6720        >>> g.addUnexploredEdge('_u.2', 'up') # collides with A/up
6721        6
6722        >>> g.addUnexploredEdge('_u.3', 'prev') # collides with D/prev
6723        7
6724        >>> g.getReciprocal('A', 'prev')
6725        'next'
6726        >>> g.replaceUnconfirmed('A', 'prev', 'D', 'next') # two gone
6727        ({'prev': 'prev.1'}, {'up': 'up.1'})
6728        >>> g.destination('A', 'prev')
6729        3
6730        >>> g.destination('D', 'next')
6731        0
6732        >>> g.getReciprocal('A', 'prev')
6733        'next'
6734        >>> g.getReciprocal('D', 'next')
6735        'prev'
6736        >>> # Note that further unexplored structures are NOT merged
6737        >>> # even if they match against existing structures...
6738        >>> g.destination('A', 'up.1')
6739        6
6740        >>> g.destination('D', 'prev.1')
6741        7
6742        >>> '_u.2' in g.nameLookup
6743        False
6744        >>> '_u.3' in g.nameLookup
6745        False
6746        >>> g.decisionTags('D') # tags are merged
6747        {'dry': 1}
6748        >>> g.decisionTags('A')
6749        {'wet': 1}
6750        >>> # Auto-renaming an anonymous unexplored node
6751        >>> g.addUnexploredEdge('B', 'out')
6752        8
6753        >>> g.replaceUnconfirmed('B', 'out', None, 'return')
6754        ({}, {})
6755        >>> '_u.6' in g
6756        False
6757        >>> g.destination('B', 'out')
6758        8
6759        >>> g.nameFor(8)
6760        '_x.6'
6761        >>> g.destination('_x.6', 'return')
6762        1
6763        >>> # Placing a node into a zone
6764        >>> g.addUnexploredEdge('B', 'through')
6765        9
6766        >>> g.getDecision('E') is None
6767        True
6768        >>> g.replaceUnconfirmed(
6769        ...     'B',
6770        ...     'through',
6771        ...     'E',
6772        ...     'back',
6773        ...     placeInZone='Zone'
6774        ... )
6775        ({}, {})
6776        >>> g.getDecision('E')
6777        9
6778        >>> g.destination('B', 'through')
6779        9
6780        >>> g.destination('E', 'back')
6781        1
6782        >>> g.zoneParents(9)
6783        {'Zone'}
6784        >>> g.addUnexploredEdge('E', 'farther')
6785        10
6786        >>> g.replaceUnconfirmed(
6787        ...     'E',
6788        ...     'farther',
6789        ...     'F',
6790        ...     'closer',
6791        ...     placeInZone=base.DefaultZone
6792        ... )
6793        ({}, {})
6794        >>> g.destination('E', 'farther')
6795        10
6796        >>> g.destination('F', 'closer')
6797        9
6798        >>> g.zoneParents(10)
6799        {'Zone'}
6800        >>> g.addUnexploredEdge('F', 'backwards', placeInZone='Enoz')
6801        11
6802        >>> g.replaceUnconfirmed(
6803        ...     'F',
6804        ...     'backwards',
6805        ...     'G',
6806        ...     'forwards',
6807        ...     placeInZone=base.DefaultZone
6808        ... )
6809        ({}, {})
6810        >>> g.destination('F', 'backwards')
6811        11
6812        >>> g.destination('G', 'forwards')
6813        10
6814        >>> g.zoneParents(11)  # not changed since it already had a zone
6815        {'Enoz'}
6816        >>> # TODO: forceNew example
6817        """
6818
6819        # Defaults
6820        if tags is None:
6821            tags = {}
6822        if annotations is None:
6823            annotations = []
6824        if revTags is None:
6825            revTags = {}
6826        if revAnnotations is None:
6827            revAnnotations = []
6828        if decisionTags is None:
6829            decisionTags = {}
6830        if decisionAnnotations is None:
6831            decisionAnnotations = []
6832
6833        # Resolve source
6834        fromID = self.resolveDecision(fromDecision)
6835
6836        # Figure out destination decision
6837        oldUnexplored = self.destination(fromID, transition)
6838        if self.isConfirmed(oldUnexplored):
6839            raise ExplorationStatusError(
6840                f"Transition {transition!r} from"
6841                f" {self.identityOf(fromDecision)} does not lead to an"
6842                f" unconfirmed decision (it leads to"
6843                f" {self.identityOf(oldUnexplored)} which is not tagged"
6844                f" 'unconfirmed')."
6845            )
6846
6847        # Resolve destination
6848        newName: Optional[base.DecisionName] = None
6849        connectID: Optional[base.DecisionID] = None
6850        if forceNew:
6851            if isinstance(connectTo, base.DecisionID):
6852                raise TypeError(
6853                    f"connectTo cannot be a decision ID when forceNew"
6854                    f" is True. Got: {self.identityOf(connectTo)}"
6855                )
6856            elif isinstance(connectTo, base.DecisionSpecifier):
6857                newName = connectTo.name
6858            elif isinstance(connectTo, base.DecisionName):
6859                newName = connectTo
6860            elif connectTo is None:
6861                oldName = self.nameFor(oldUnexplored)
6862                if (
6863                    oldName.startswith('_u.')
6864                and oldName[3:].isdigit()
6865                ):
6866                    newName = utils.uniqueName('_x.' + oldName[3:], self)
6867                else:
6868                    newName = oldName
6869            else:
6870                raise TypeError(
6871                    f"Invalid connectTo value: {connectTo!r}"
6872                )
6873        elif connectTo is not None:
6874            try:
6875                connectID = self.resolveDecision(connectTo)
6876                # leave newName as None
6877            except MissingDecisionError:
6878                if isinstance(connectTo, int):
6879                    raise
6880                elif isinstance(connectTo, base.DecisionSpecifier):
6881                    newName = connectTo.name
6882                    # The domain & zone are ignored here
6883                else:  # Must just be a string
6884                    assert isinstance(connectTo, str)
6885                    newName = connectTo
6886        else:
6887            # If connectTo name wasn't specified, use current name of
6888            # unknown node unless it's a default name
6889            oldName = self.nameFor(oldUnexplored)
6890            if (
6891                oldName.startswith('_u.')
6892            and oldName[3:].isdigit()
6893            ):
6894                newName = utils.uniqueName('_x.' + oldName[3:], self)
6895            else:
6896                newName = oldName
6897
6898        # One or the other should be valid at this point
6899        assert connectID is not None or newName is not None
6900
6901        # Check that the old unknown doesn't have a reciprocal edge that
6902        # would collide with the specified return edge
6903        if reciprocal is not None:
6904            revFromUnknown = self.getDestination(oldUnexplored, reciprocal)
6905            if revFromUnknown not in (None, fromID):
6906                raise TransitionCollisionError(
6907                    f"Transition {reciprocal!r} from"
6908                    f" {self.identityOf(oldUnexplored)} exists and does"
6909                    f" not lead back to {self.identityOf(fromDecision)}"
6910                    f" (it leads to {self.identityOf(revFromUnknown)})."
6911                )
6912
6913        # Remember old reciprocal edge for future merging in case
6914        # it's not reciprocal
6915        oldReciprocal = self.getReciprocal(fromID, transition)
6916
6917        # Apply any new tags or annotations, or create a new node
6918        needsZoneInfo = False
6919        if connectID is not None:
6920            # Before applying tags, check if we need to error out
6921            # because of a reciprocal edge that points to a known
6922            # destination:
6923            if reciprocal is not None:
6924                otherOldUnknown: Optional[
6925                    base.DecisionID
6926                ] = self.getDestination(
6927                    connectID,
6928                    reciprocal
6929                )
6930                if (
6931                    otherOldUnknown is not None
6932                and self.isConfirmed(otherOldUnknown)
6933                ):
6934                    raise ExplorationStatusError(
6935                        f"Reciprocal transition {reciprocal!r} from"
6936                        f" {self.identityOf(connectTo)} does not lead"
6937                        f" to an unconfirmed decision (it leads to"
6938                        f" {self.identityOf(otherOldUnknown)})."
6939                    )
6940            self.tagDecision(connectID, decisionTags)
6941            self.annotateDecision(connectID, decisionAnnotations)
6942            # Still needs zone info if the place we're connecting to was
6943            # unconfirmed up until now, since unconfirmed nodes don't
6944            # normally get zone info when they're created.
6945            if not self.isConfirmed(connectID):
6946                needsZoneInfo = True
6947
6948            # First, merge the old unknown with the connectTo node...
6949            destRenames = self.mergeDecisions(
6950                oldUnexplored,
6951                connectID,
6952                errorOnNameColision=False
6953            )
6954        else:
6955            needsZoneInfo = True
6956            if len(self.zoneParents(oldUnexplored)) > 0:
6957                needsZoneInfo = False
6958            assert newName is not None
6959            self.renameDecision(oldUnexplored, newName)
6960            connectID = oldUnexplored
6961            # In this case there can't be an other old unknown
6962            otherOldUnknown = None
6963            destRenames = {}  # empty
6964
6965        # Check for domain mismatch to stifle zone updates:
6966        fromDomain = self.domainFor(fromID)
6967        if connectID is None:
6968            destDomain = self.domainFor(oldUnexplored)
6969        else:
6970            destDomain = self.domainFor(connectID)
6971
6972        # Stifle zone updates if there's a mismatch
6973        if fromDomain != destDomain:
6974            needsZoneInfo = False
6975
6976        # Records renames that happen at the source (from node)
6977        sourceRenames = {}  # empty for now
6978
6979        assert connectID is not None
6980
6981        # Apply the new zone if there is one
6982        if placeInZone is not None:
6983            if placeInZone == base.DefaultZone:
6984                # When using DefaultZone, changes are only made for new
6985                # destinations which don't already have any zones and
6986                # which are in the same domain as the departing node:
6987                # they get placed into each zone parent of the source
6988                # decision.
6989                if needsZoneInfo:
6990                    # Remove destination from all current parents
6991                    removeFrom = set(self.zoneParents(connectID))  # copy
6992                    for parent in removeFrom:
6993                        self.removeDecisionFromZone(connectID, parent)
6994                    # Add it to parents of origin
6995                    for parent in self.zoneParents(fromID):
6996                        self.addDecisionToZone(connectID, parent)
6997            else:
6998                placeInZone = cast(base.Zone, placeInZone)
6999                # Create the zone if it doesn't already exist
7000                if self.getZoneInfo(placeInZone) is None:
7001                    self.createZone(placeInZone, 0)
7002                    # Add it to each grandparent of the from decision
7003                    for parent in self.zoneParents(fromID):
7004                        for grandparent in self.zoneParents(parent):
7005                            self.addZoneToZone(placeInZone, grandparent)
7006                # Remove destination from all current parents
7007                for parent in set(self.zoneParents(connectID)):
7008                    self.removeDecisionFromZone(connectID, parent)
7009                # Add it to the specified zone
7010                self.addDecisionToZone(connectID, placeInZone)
7011
7012        # Next, if there is a reciprocal name specified, we do more...
7013        if reciprocal is not None:
7014            # Figure out what kind of merging needs to happen
7015            if otherOldUnknown is None:
7016                if revFromUnknown is None:
7017                    # Just create the desired reciprocal transition, which
7018                    # we know does not already exist
7019                    self.addTransition(connectID, reciprocal, fromID)
7020                    otherOldReciprocal = None
7021                else:
7022                    # Reciprocal exists, as revFromUnknown
7023                    otherOldReciprocal = None
7024            else:
7025                otherOldReciprocal = self.getReciprocal(
7026                    connectID,
7027                    reciprocal
7028                )
7029                # we need to merge otherOldUnknown into our fromDecision
7030                sourceRenames = self.mergeDecisions(
7031                    otherOldUnknown,
7032                    fromID,
7033                    errorOnNameColision=False
7034                )
7035                # Unvisited tag after merge only if both were
7036
7037            # No matter what happened we ensure the reciprocal
7038            # relationship is set up:
7039            self.setReciprocal(fromID, transition, reciprocal)
7040
7041            # Now we might need to merge some transitions:
7042            # - Any reciprocal of the target transition should be merged
7043            #   with reciprocal (if it was already reciprocal, that's a
7044            #   no-op).
7045            # - Any reciprocal of the reciprocal transition from the target
7046            #   node (leading to otherOldUnknown) should be merged with
7047            #   the target transition, even if it shared a name and was
7048            #   renamed as a result.
7049            # - If reciprocal was renamed during the initial merge, those
7050            #   transitions should be merged.
7051
7052            # Merge old reciprocal into reciprocal
7053            if oldReciprocal is not None:
7054                oldRev = destRenames.get(oldReciprocal, oldReciprocal)
7055                if self.getDestination(connectID, oldRev) is not None:
7056                    # Note that we don't want to auto-merge the reciprocal,
7057                    # which is the target transition
7058                    self.mergeTransitions(
7059                        connectID,
7060                        oldRev,
7061                        reciprocal,
7062                        mergeReciprocal=False
7063                    )
7064                    # Remove it from the renames map
7065                    if oldReciprocal in destRenames:
7066                        del destRenames[oldReciprocal]
7067
7068            # Merge reciprocal reciprocal from otherOldUnknown
7069            if otherOldReciprocal is not None:
7070                otherOldRev = sourceRenames.get(
7071                    otherOldReciprocal,
7072                    otherOldReciprocal
7073                )
7074                # Note that the reciprocal is reciprocal, which we don't
7075                # need to merge
7076                self.mergeTransitions(
7077                    fromID,
7078                    otherOldRev,
7079                    transition,
7080                    mergeReciprocal=False
7081                )
7082                # Remove it from the renames map
7083                if otherOldReciprocal in sourceRenames:
7084                    del sourceRenames[otherOldReciprocal]
7085
7086            # Merge any renamed reciprocal onto reciprocal
7087            if reciprocal in destRenames:
7088                extraRev = destRenames[reciprocal]
7089                self.mergeTransitions(
7090                    connectID,
7091                    extraRev,
7092                    reciprocal,
7093                    mergeReciprocal=False
7094                )
7095                # Remove it from the renames map
7096                del destRenames[reciprocal]
7097
7098        # Accumulate new tags & annotations for the transitions
7099        self.tagTransition(fromID, transition, tags)
7100        self.annotateTransition(fromID, transition, annotations)
7101
7102        if reciprocal is not None:
7103            self.tagTransition(connectID, reciprocal, revTags)
7104            self.annotateTransition(connectID, reciprocal, revAnnotations)
7105
7106        # Override copied requirement/consequences for the transitions
7107        if requirement is not None:
7108            self.setTransitionRequirement(
7109                fromID,
7110                transition,
7111                requirement
7112            )
7113        if applyConsequence is not None:
7114            self.setConsequence(
7115                fromID,
7116                transition,
7117                applyConsequence
7118            )
7119
7120        if reciprocal is not None:
7121            if revRequires is not None:
7122                self.setTransitionRequirement(
7123                    connectID,
7124                    reciprocal,
7125                    revRequires
7126                )
7127            if revConsequence is not None:
7128                self.setConsequence(
7129                    connectID,
7130                    reciprocal,
7131                    revConsequence
7132                )
7133
7134        # Remove 'unconfirmed' tag if it was present
7135        self.untagDecision(connectID, 'unconfirmed')
7136
7137        # Final checks
7138        assert self.getDestination(fromDecision, transition) == connectID
7139        useConnect: base.AnyDecisionSpecifier
7140        useRev: Optional[str]
7141        if connectTo is None:
7142            useConnect = connectID
7143        else:
7144            useConnect = connectTo
7145        if reciprocal is None:
7146            useRev = self.getReciprocal(fromDecision, transition)
7147        else:
7148            useRev = reciprocal
7149        if useRev is not None:
7150            try:
7151                assert self.getDestination(useConnect, useRev) == fromID
7152            except AmbiguousDecisionSpecifierError:
7153                assert self.getDestination(connectID, useRev) == fromID
7154
7155        # Return our final rename dictionaries
7156        return (destRenames, sourceRenames)

Given a decision and an edge name in that decision, where the named edge leads to a decision with an unconfirmed exploration state (see isConfirmed), renames the unexplored decision on the other end of that edge using the given connectTo name, or if a decision using that name already exists, merges the unexplored decision into that decision. If connectTo is a DecisionSpecifier whose target doesn't exist, it will be treated as just a name, but if it's an ID and it doesn't exist, you'll get a MissingDecisionError. If a reciprocal is provided, a reciprocal edge will be added using that name connecting the connectTo decision back to the original decision. If this transition already exists, it must also point to a node which is also unexplored, and which will also be merged into the fromDecision node.

If connectTo is not given (or is set to None explicitly) then the name of the unexplored decision will not be changed, unless that name has the form '_u.-n-' where -n- is a positive integer (i.e., the form given to automatically-named unknown nodes). In that case, the name will be changed to '_x.-n-' using the same number, or a higher number if that name is already taken.

If the destination is being renamed or if the destination's exploration state counts as unexplored, the exploration state of the destination will be set to 'exploring'.

If a placeInZone is specified, the destination will be placed directly into that zone (even if it already existed and has zone information), and it will be removed from any other zones it had been a direct member of. If placeInZone is set to base.DefaultZone, then the destination will be placed into each zone which is a direct parent of the origin, but only if the destination is not an already-explored existing decision AND it is not already in any zones (in those cases no zone changes are made). This will also remove it from any previous zones it had been a part of. If placeInZone is left as None (the default) no zone changes are made.

If placeInZone is specified and that zone didn't already exist, it will be created as a new level-0 zone and will be added as a sub-zone of each zone that's a direct parent of any level-0 zone that the origin is a member of.

If forceNew is specified, then the destination will just be renamed, even if another decision with the same name already exists. It's an error to use forceNew with a decision ID as the destination.

Any additional edges pointing to or from the unknown node(s) being replaced will also be re-targeted at the now-discovered known destination(s) if necessary. These edges will retain their reciprocal names, or if this would cause a name clash, they will be renamed with a suffix (see retargetTransition).

The return value is a pair of dictionaries mapping old names to new ones that just includes the names which were changed. The first dictionary contains renamed transitions that are outgoing from the new destination node (which used to be outgoing from the unexplored node). The second dictionary contains renamed transitions that are outgoing from the source node (which used to be outgoing from the unexplored node attached to the reciprocal transition; if there was no reciprocal transition specified then this will always be an empty dictionary).

An ExplorationStatusError will be raised if the destination of the specified transition counts as visited (see hasBeenVisited). An ExplorationStatusError will also be raised if the connectTo's reciprocal transition does not lead to an unconfirmed decision (it's okay if this second transition doesn't exist). A TransitionCollisionError will be raised if the unconfirmed destination decision already has an outgoing transition with the specified reciprocal which does not lead back to the fromDecision.

The transition properties (requirement, consequences, tags, and/or annotations) of the replaced transition will be copied over to the new transition. Transition properties from the reciprocal transition will also be copied for the newly created reciprocal edge. Properties for any additional edges to/from the unknown node will also be copied.

Also, any transition properties on existing forward or reciprocal edges from the destination node with the indicated reverse name will be merged with those from the target transition. Note that this merging process may introduce corruption of complex transition consequences. TODO: Fix that!

Any tags and annotations are added to copied tags/annotations, but specified requirements, and/or consequences will replace previous requirements/consequences, rather than being added to them.

Example

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addUnexploredEdge('A', 'up')
1
>>> g.destination('A', 'up')
1
>>> g.degree(1)
1
>>> g.replaceUnconfirmed('A', 'up', 'B', 'down')
({}, {})
>>> g.destination('A', 'up')
1
>>> g.nameFor(1)
'B'
>>> g.destination('B', 'down')
0
>>> g.getDestination('B', 'return') is None
True
>>> '_u.0' in g.nameLookup
False
>>> g.getReciprocal('A', 'up')
'down'
>>> g.getReciprocal('B', 'down')
'up'
>>> # Two unexplored edges to the same node:
>>> g.addDecision('C')
2
>>> g.addTransition('B', 'next', 'C')
>>> g.addTransition('C', 'prev', 'B')
>>> g.setReciprocal('B', 'next', 'prev')
>>> g.addUnexploredEdge('A', 'next', 'D', 'prev')
3
>>> g.addTransition('C', 'down', 'D')
>>> g.addTransition('D', 'up', 'C')
>>> g.setReciprocal('C', 'down', 'up')
>>> g.replaceUnconfirmed('C', 'down')
({}, {})
>>> g.destination('C', 'down')
3
>>> g.destination('A', 'next')
3
>>> g.destinationsFrom('D')
{'prev': 0, 'up': 2}
>>> g.decisionTags('D')
{}
>>> # An unexplored transition which turns out to connect to a
>>> # known decision, with name collisions
>>> g.addUnexploredEdge('D', 'next', reciprocal='prev')
4
>>> g.tagDecision('_u.2', 'wet')
>>> g.addUnexploredEdge('B', 'next', reciprocal='prev') # edge taken
Traceback (most recent call last):
...
TransitionCollisionError...
>>> g.addUnexploredEdge('A', 'prev', reciprocal='next')
5
>>> g.tagDecision('_u.3', 'dry')
>>> # Add transitions that will collide when merged
>>> g.addUnexploredEdge('_u.2', 'up') # collides with A/up
6
>>> g.addUnexploredEdge('_u.3', 'prev') # collides with D/prev
7
>>> g.getReciprocal('A', 'prev')
'next'
>>> g.replaceUnconfirmed('A', 'prev', 'D', 'next') # two gone
({'prev': 'prev.1'}, {'up': 'up.1'})
>>> g.destination('A', 'prev')
3
>>> g.destination('D', 'next')
0
>>> g.getReciprocal('A', 'prev')
'next'
>>> g.getReciprocal('D', 'next')
'prev'
>>> # Note that further unexplored structures are NOT merged
>>> # even if they match against existing structures...
>>> g.destination('A', 'up.1')
6
>>> g.destination('D', 'prev.1')
7
>>> '_u.2' in g.nameLookup
False
>>> '_u.3' in g.nameLookup
False
>>> g.decisionTags('D') # tags are merged
{'dry': 1}
>>> g.decisionTags('A')
{'wet': 1}
>>> # Auto-renaming an anonymous unexplored node
>>> g.addUnexploredEdge('B', 'out')
8
>>> g.replaceUnconfirmed('B', 'out', None, 'return')
({}, {})
>>> '_u.6' in g
False
>>> g.destination('B', 'out')
8
>>> g.nameFor(8)
'_x.6'
>>> g.destination('_x.6', 'return')
1
>>> # Placing a node into a zone
>>> g.addUnexploredEdge('B', 'through')
9
>>> g.getDecision('E') is None
True
>>> g.replaceUnconfirmed(
...     'B',
...     'through',
...     'E',
...     'back',
...     placeInZone='Zone'
... )
({}, {})
>>> g.getDecision('E')
9
>>> g.destination('B', 'through')
9
>>> g.destination('E', 'back')
1
>>> g.zoneParents(9)
{'Zone'}
>>> g.addUnexploredEdge('E', 'farther')
10
>>> g.replaceUnconfirmed(
...     'E',
...     'farther',
...     'F',
...     'closer',
...     placeInZone=base.DefaultZone
... )
({}, {})
>>> g.destination('E', 'farther')
10
>>> g.destination('F', 'closer')
9
>>> g.zoneParents(10)
{'Zone'}
>>> g.addUnexploredEdge('F', 'backwards', placeInZone='Enoz')
11
>>> g.replaceUnconfirmed(
...     'F',
...     'backwards',
...     'G',
...     'forwards',
...     placeInZone=base.DefaultZone
... )
({}, {})
>>> g.destination('F', 'backwards')
11
>>> g.destination('G', 'forwards')
10
>>> g.zoneParents(11)  # not changed since it already had a zone
{'Enoz'}
>>> # TODO: forceNew example
def endingID(self, name: str) -> int:
7158    def endingID(self, name: base.DecisionName) -> base.DecisionID:
7159        """
7160        Returns the decision ID for the ending with the specified name.
7161        Endings are disconnected decisions in the `ENDINGS_DOMAIN`; they
7162        don't normally include any zone information. If no ending with
7163        the specified name already existed, then a new ending with that
7164        name will be created and its Decision ID will be returned.
7165
7166        If a new decision is created, it will be tagged as unconfirmed.
7167
7168        Note that endings mostly aren't special: they're normal
7169        decisions in a separate singular-focalized domain. However, some
7170        parts of the exploration and journal machinery treat them
7171        differently (in particular, taking certain actions via
7172        `advanceSituation` while any decision in the `ENDINGS_DOMAIN` is
7173        active is an error.
7174        """
7175        # Create our new ending decision if we need to
7176        try:
7177            endID = self.resolveDecision(
7178                base.DecisionSpecifier(ENDINGS_DOMAIN, None, name)
7179            )
7180        except MissingDecisionError:
7181            # Create a new decision for the ending
7182            endID = self.addDecision(name, domain=ENDINGS_DOMAIN)
7183            # Tag it as unconfirmed
7184            self.tagDecision(endID, 'unconfirmed')
7185
7186        return endID

Returns the decision ID for the ending with the specified name. Endings are disconnected decisions in the ENDINGS_DOMAIN; they don't normally include any zone information. If no ending with the specified name already existed, then a new ending with that name will be created and its Decision ID will be returned.

If a new decision is created, it will be tagged as unconfirmed.

Note that endings mostly aren't special: they're normal decisions in a separate singular-focalized domain. However, some parts of the exploration and journal machinery treat them differently (in particular, taking certain actions via advanceSituation while any decision in the ENDINGS_DOMAIN is active is an error.

def triggerGroupID(self, name: str) -> int:
7188    def triggerGroupID(self, name: base.DecisionName) -> base.DecisionID:
7189        """
7190        Given the name of a trigger group, returns the ID of the special
7191        node representing that trigger group in the `TRIGGERS_DOMAIN`.
7192        If the specified group didn't already exist, it will be created.
7193
7194        Trigger group decisions are not special: they just exist in a
7195        separate spreading-focalized domain and have a few API methods to
7196        access them, but all the normal decision-related API methods
7197        still work. Their intended use is for sets of global triggers,
7198        by attaching actions with the 'trigger' tag to them and then
7199        activating or deactivating them as needed.
7200        """
7201        result = self.getDecision(
7202            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
7203        )
7204        if result is None:
7205            return self.addDecision(name, domain=TRIGGERS_DOMAIN)
7206        else:
7207            return result

Given the name of a trigger group, returns the ID of the special node representing that trigger group in the TRIGGERS_DOMAIN. If the specified group didn't already exist, it will be created.

Trigger group decisions are not special: they just exist in a separate spreading-focalized domain and have a few API methods to access them, but all the normal decision-related API methods still work. Their intended use is for sets of global triggers, by attaching actions with the 'trigger' tag to them and then activating or deactivating them as needed.

@staticmethod
def example(which: Literal['simple', 'abc']) -> DecisionGraph:
7209    @staticmethod
7210    def example(which: Literal['simple', 'abc']) -> 'DecisionGraph':
7211        """
7212        Returns one of a number of example decision graphs, depending on
7213        the string given. It returns a fresh copy each time. The graphs
7214        are:
7215
7216        - 'simple': Three nodes named 'A', 'B', and 'C' with IDs 0, 1,
7217            and 2, each connected to the next in the sequence by a
7218            'next' transition with reciprocal 'prev'. In other words, a
7219            simple little triangle. There are no tags, annotations,
7220            requirements, consequences, mechanisms, or equivalences.
7221        - 'abc': A more complicated 3-node setup that introduces a
7222            little bit of everything. In this graph, we have the same
7223            three nodes, but different transitions:
7224
7225                * From A you can go 'left' to B with reciprocal 'right'.
7226                * From A you can also go 'up_left' to B with reciprocal
7227                    'up_right'. These transitions both require the
7228                    'grate' mechanism (which is at decision A) to be in
7229                    state 'open'.
7230                * From A you can go 'down' to C with reciprocal 'up'.
7231
7232            (In this graph, B and C are not directly connected to each
7233            other.)
7234
7235            The graph has two level-0 zones 'zoneA' and 'zoneB', along
7236            with a level-1 zone 'upZone'. Decisions A and C are in
7237            zoneA while B is in zoneB; zoneA is in upZone, but zoneB is
7238            not.
7239
7240            The decision A has annotation:
7241
7242                'This is a multi-word "annotation."'
7243
7244            The transition 'down' from A has annotation:
7245
7246                "Transition 'annotation.'"
7247
7248            Decision B has tags 'b' with value 1 and 'tag2' with value
7249            '"value"'.
7250
7251            Decision C has tag 'aw"ful' with value "ha'ha'".
7252
7253            Transition 'up' from C has tag 'fast' with value 1.
7254
7255            At decision C there are actions 'grab_helmet' and
7256            'pull_lever'.
7257
7258            The 'grab_helmet' transition requires that you don't have
7259            the 'helmet' capability, and gives you that capability,
7260            deactivating with delay 3.
7261
7262            The 'pull_lever' transition requires that you do have the
7263            'helmet' capability, and takes away that capability, but it
7264            also gives you 1 'token' token, and if you have 2 tokens
7265            (before getting the one extra), it sets the 'grate' mechanism
7266            (which is a decision A) to state 'open' and deactivates.
7267
7268            The graph has an equivalence: having the 'helmet' capability
7269            satisfies requirements for the 'grate' mechanism to be in the
7270            'open' state.
7271        """
7272        result = DecisionGraph()
7273        if which == 'simple':
7274            result.addDecision('A')  # id 0
7275            result.addDecision('B')  # id 1
7276            result.addDecision('C')  # id 2
7277            result.addTransition('A', 'next', 'B', 'prev')
7278            result.addTransition('B', 'next', 'C', 'prev')
7279            result.addTransition('C', 'next', 'A', 'prev')
7280        elif which == 'abc':
7281            result.addDecision('A')  # id 0
7282            result.addDecision('B')  # id 1
7283            result.addDecision('C')  # id 2
7284            result.createZone('zoneA', 0)
7285            result.createZone('zoneB', 0)
7286            result.createZone('upZone', 1)
7287            result.addZoneToZone('zoneA', 'upZone')
7288            result.addDecisionToZone('A', 'zoneA')
7289            result.addDecisionToZone('B', 'zoneB')
7290            result.addDecisionToZone('C', 'zoneA')
7291            result.addTransition('A', 'left', 'B', 'right')
7292            result.addTransition('A', 'up_left', 'B', 'up_right')
7293            result.addTransition('A', 'down', 'C', 'up')
7294            result.setTransitionRequirement(
7295                'A',
7296                'up_left',
7297                base.ReqMechanism('grate', 'open')
7298            )
7299            result.setTransitionRequirement(
7300                'B',
7301                'up_right',
7302                base.ReqMechanism('grate', 'open')
7303            )
7304            result.annotateDecision('A', 'This is a multi-word "annotation."')
7305            result.annotateTransition('A', 'down', "Transition 'annotation.'")
7306            result.tagDecision('B', 'b')
7307            result.tagDecision('B', 'tag2', '"value"')
7308            result.tagDecision('C', 'aw"ful', "ha'ha")
7309            result.tagTransition('C', 'up', 'fast')
7310            result.addMechanism('grate', 'A')
7311            result.addAction(
7312                'C',
7313                'grab_helmet',
7314                base.ReqNot(base.ReqCapability('helmet')),
7315                [
7316                    base.effect(gain='helmet'),
7317                    base.effect(deactivate=True, delay=3)
7318                ]
7319            )
7320            result.addAction(
7321                'C',
7322                'pull_lever',
7323                base.ReqCapability('helmet'),
7324                [
7325                    base.effect(lose='helmet'),
7326                    base.effect(gain=('token', 1)),
7327                    base.condition(
7328                        base.ReqTokens('token', 2),
7329                        [
7330                            base.effect(set=('grate', 'open')),
7331                            base.effect(deactivate=True)
7332                        ]
7333                    )
7334                ]
7335            )
7336            result.addEquivalence(
7337                base.ReqCapability('helmet'),
7338                (0, 'open')
7339            )
7340        else:
7341            raise ValueError(f"Invalid example name: {which!r}")
7342
7343        return result

Returns one of a number of example decision graphs, depending on the string given. It returns a fresh copy each time. The graphs are:

  • 'simple': Three nodes named 'A', 'B', and 'C' with IDs 0, 1, and 2, each connected to the next in the sequence by a 'next' transition with reciprocal 'prev'. In other words, a simple little triangle. There are no tags, annotations, requirements, consequences, mechanisms, or equivalences.
  • 'abc': A more complicated 3-node setup that introduces a little bit of everything. In this graph, we have the same three nodes, but different transitions:

    * From A you can go 'left' to B with reciprocal 'right'.
    * From A you can also go 'up_left' to B with reciprocal
        'up_right'. These transitions both require the
        'grate' mechanism (which is at decision A) to be in
        state 'open'.
    * From A you can go 'down' to C with reciprocal 'up'.
    

    (In this graph, B and C are not directly connected to each other.)

    The graph has two level-0 zones 'zoneA' and 'zoneB', along with a level-1 zone 'upZone'. Decisions A and C are in zoneA while B is in zoneB; zoneA is in upZone, but zoneB is not.

    The decision A has annotation:

    'This is a multi-word "annotation."'
    

    The transition 'down' from A has annotation:

    "Transition 'annotation.'"
    

    Decision B has tags 'b' with value 1 and 'tag2' with value '"value"'.

    Decision C has tag 'aw"ful' with value "ha'ha'".

    Transition 'up' from C has tag 'fast' with value 1.

    At decision C there are actions 'grab_helmet' and 'pull_lever'.

    The 'grab_helmet' transition requires that you don't have the 'helmet' capability, and gives you that capability, deactivating with delay 3.

    The 'pull_lever' transition requires that you do have the 'helmet' capability, and takes away that capability, but it also gives you 1 'token' token, and if you have 2 tokens (before getting the one extra), it sets the 'grate' mechanism (which is a decision A) to state 'open' and deactivates.

    The graph has an equivalence: having the 'helmet' capability satisfies requirements for the 'grate' mechanism to be in the 'open' state.

Inherited Members
exploration.graphs.UniqueExitsGraph
new_edge_key
add_node
add_nodes_from
remove_node
remove_nodes_from
add_edge
add_edges_from
remove_edge
remove_edges_from
clear
clear_edges
reverse
removeEdgeByKey
removeEdgesByKey
connections
allEdgesTo
allEdges
textMapObj
networkx.classes.multidigraph.MultiDiGraph
edge_key_dict_factory
adj
succ
pred
edges
out_edges
in_edges
degree
in_degree
out_degree
is_multigraph
is_directed
to_undirected
networkx.classes.multigraph.MultiGraph
to_directed_class
to_undirected_class
has_edge
get_edge_data
copy
to_directed
number_of_edges
networkx.classes.digraph.DiGraph
graph
has_successor
has_predecessor
successors
neighbors
predecessors
networkx.classes.graph.Graph
node_dict_factory
node_attr_dict_factory
adjlist_outer_dict_factory
adjlist_inner_dict_factory
edge_attr_dict_factory
graph_attr_dict_factory
name
nodes
number_of_nodes
order
has_node
add_weighted_edges_from
update
adjacency
subgraph
edge_subgraph
size
nbunch_iter
def emptySituation() -> exploration.base.Situation:
7350def emptySituation() -> base.Situation:
7351    """
7352    Creates and returns an empty situation: A situation that has an
7353    empty `DecisionGraph`, an empty `State`, a 'pending' decision type
7354    with `None` as the action taken, no tags, and no annotations.
7355    """
7356    return base.Situation(
7357        graph=DecisionGraph(),
7358        state=base.emptyState(),
7359        type='pending',
7360        action=None,
7361        saves={},
7362        tags={},
7363        annotations=[]
7364    )
7365
7366    pass

Creates and returns an empty situation: A situation that has an empty DecisionGraph, an empty State, a 'pending' decision type with None as the action taken, no tags, and no annotations.

class DiscreteExploration:
 7369class DiscreteExploration:
 7370    """
 7371    A list of `Situations` each of which contains a `DecisionGraph`
 7372    representing exploration over time, with `States` containing
 7373    `FocalContext` information for each step and 'taken' values for the
 7374    transition selected (at a particular decision) in that step. Each
 7375    decision graph represents a new state of the world (and/or new
 7376    knowledge about a persisting state of the world), and the 'taken'
 7377    transition in one situation transition indicates which option was
 7378    selected, or what event happened to cause update(s). Depending on the
 7379    resolution, it could represent a close record of every decision made
 7380    or a more coarse set of snapshots from gameplay with more time in
 7381    between.
 7382
 7383    The steps of the exploration can also be tagged and annotated (see
 7384    `tagStep` and `annotateStep`).
 7385
 7386    It also holds a `layouts` field that includes zero or more
 7387    `base.Layout`s by name.
 7388
 7389    When a new `DiscreteExploration` is created, it starts out with an
 7390    empty `Situation` that contains an empty `DecisionGraph`. Use the
 7391    `start` method to name the starting decision point and set things up
 7392    for other methods.
 7393
 7394    Tracking of player goals and destinations is also planned (see the
 7395    `quest`, `progress`, `complete`, `destination`, and `arrive` methods).
 7396    TODO: That
 7397    """
 7398    def __init__(self) -> None:
 7399        self.situations: List[base.Situation] = [
 7400            base.Situation(
 7401                graph=DecisionGraph(),
 7402                state=base.emptyState(),
 7403                type='pending',
 7404                action=None,
 7405                saves={},
 7406                tags={},
 7407                annotations=[]
 7408            )
 7409        ]
 7410        self.layouts: Dict[str, base.Layout] = {}
 7411
 7412    # Note: not hashable
 7413
 7414    def __eq__(self, other):
 7415        """
 7416        Equality checker. `DiscreteExploration`s can only be equal to
 7417        other `DiscreteExploration`s, not to other kinds of things.
 7418        """
 7419        if not isinstance(other, DiscreteExploration):
 7420            return False
 7421        else:
 7422            return self.situations == other.situations
 7423
 7424    @staticmethod
 7425    def fromGraph(
 7426        graph: DecisionGraph,
 7427        state: Optional[base.State] = None
 7428    ) -> 'DiscreteExploration':
 7429        """
 7430        Creates an exploration which has just a single step whose graph
 7431        is the entire specified graph, with the specified decision as
 7432        the primary decision (if any). The graph is copied, so that
 7433        changes to the exploration will not modify it. A starting state
 7434        may also be specified if desired, although if not an empty state
 7435        will be used (a provided starting state is NOT copied, but used
 7436        directly).
 7437
 7438        Example:
 7439
 7440        >>> g = DecisionGraph()
 7441        >>> g.addDecision('Room1')
 7442        0
 7443        >>> g.addDecision('Room2')
 7444        1
 7445        >>> g.addTransition('Room1', 'door', 'Room2', 'door')
 7446        >>> e = DiscreteExploration.fromGraph(g)
 7447        >>> len(e)
 7448        1
 7449        >>> e.getSituation().graph == g
 7450        True
 7451        >>> e.getActiveDecisions()
 7452        set()
 7453        >>> e.primaryDecision() is None
 7454        True
 7455        >>> e.observe('Room1', 'hatch')
 7456        2
 7457        >>> e.getSituation().graph == g
 7458        False
 7459        >>> e.getSituation().graph.destinationsFrom('Room1')
 7460        {'door': 1, 'hatch': 2}
 7461        >>> g.destinationsFrom('Room1')
 7462        {'door': 1}
 7463        """
 7464        result = DiscreteExploration()
 7465        result.situations[0] = base.Situation(
 7466            graph=copy.deepcopy(graph),
 7467            state=base.emptyState() if state is None else state,
 7468            type='pending',
 7469            action=None,
 7470            saves={},
 7471            tags={},
 7472            annotations=[]
 7473        )
 7474        return result
 7475
 7476    def __len__(self) -> int:
 7477        """
 7478        The 'length' of an exploration is the number of steps.
 7479        """
 7480        return len(self.situations)
 7481
 7482    def __getitem__(self, i: int) -> base.Situation:
 7483        """
 7484        Indexing an exploration returns the situation at that step.
 7485        """
 7486        return self.situations[i]
 7487
 7488    def __iter__(self) -> Iterator[base.Situation]:
 7489        """
 7490        Iterating over an exploration yields each `Situation` in order.
 7491        """
 7492        for i in range(len(self)):
 7493            yield self[i]
 7494
 7495    def getSituation(self, step: int = -1) -> base.Situation:
 7496        """
 7497        Returns a `base.Situation` named tuple detailing the state of
 7498        the exploration at a given step (or at the current step if no
 7499        argument is given). Note that this method works the same
 7500        way as indexing the exploration: see `__getitem__`.
 7501
 7502        Raises an `IndexError` if asked for a step that's out-of-range.
 7503        """
 7504        return self[step]
 7505
 7506    def primaryDecision(self, step: int = -1) -> Optional[base.DecisionID]:
 7507        """
 7508        Returns the current primary `base.DecisionID`, or the primary
 7509        decision from a specific step if one is specified. This may be
 7510        `None` for some steps, but mostly it's the destination of the
 7511        transition taken in the previous step.
 7512        """
 7513        return self[step].state['primaryDecision']
 7514
 7515    def effectiveCapabilities(
 7516        self,
 7517        step: int = -1
 7518    ) -> base.CapabilitySet:
 7519        """
 7520        Returns the effective capability set for the specified step
 7521        (default is the last/current step). See
 7522        `base.effectiveCapabilities`.
 7523        """
 7524        return base.effectiveCapabilitySet(self.getSituation(step).state)
 7525
 7526    def getCommonContext(
 7527        self,
 7528        step: Optional[int] = None
 7529    ) -> base.FocalContext:
 7530        """
 7531        Returns the common `FocalContext` at the specified step, or at
 7532        the current step if no argument is given. Raises an `IndexError`
 7533        if an invalid step is specified.
 7534        """
 7535        if step is None:
 7536            step = -1
 7537        state = self.getSituation(step).state
 7538        return state['common']
 7539
 7540    def getActiveContext(
 7541        self,
 7542        step: Optional[int] = None
 7543    ) -> base.FocalContext:
 7544        """
 7545        Returns the active `FocalContext` at the specified step, or at
 7546        the current step if no argument is provided. Raises an
 7547        `IndexError` if an invalid step is specified.
 7548        """
 7549        if step is None:
 7550            step = -1
 7551        state = self.getSituation(step).state
 7552        return state['contexts'][state['activeContext']]
 7553
 7554    def addFocalContext(self, name: base.FocalContextName) -> None:
 7555        """
 7556        Adds a new empty focal context to our set of focal contexts (see
 7557        `emptyFocalContext`). Use `setActiveContext` to swap to it.
 7558        Raises a `FocalContextCollisionError` if the name is already in
 7559        use.
 7560        """
 7561        contextMap = self.getSituation().state['contexts']
 7562        if name in contextMap:
 7563            raise FocalContextCollisionError(
 7564                f"Cannot add focal context {name!r}: a focal context"
 7565                f" with that name already exists."
 7566            )
 7567        contextMap[name] = base.emptyFocalContext()
 7568
 7569    def setActiveContext(self, which: base.FocalContextName) -> None:
 7570        """
 7571        Sets the active context to the named focal context, creating it
 7572        if it did not already exist (makes changes to the current
 7573        situation only). Does not add an exploration step (use
 7574        `advanceSituation` with a 'swap' action for that).
 7575        """
 7576        state = self.getSituation().state
 7577        contextMap = state['contexts']
 7578        if which not in contextMap:
 7579            self.addFocalContext(which)
 7580        state['activeContext'] = which
 7581
 7582    def createDomain(
 7583        self,
 7584        name: base.Domain,
 7585        focalization: base.DomainFocalization = 'singular',
 7586        makeActive: bool = False,
 7587        inCommon: Union[bool, Literal["both"]] = "both"
 7588    ) -> None:
 7589        """
 7590        Creates a new domain with the given focalization type, in either
 7591        the common context (`inCommon` = `True`) the active context
 7592        (`inCommon` = `False`) or both (the default; `inCommon` = 'both').
 7593        The domain's focalization will be set to the given
 7594        `focalization` value (default 'singular') and it will have no
 7595        active decisions. Raises a `DomainCollisionError` if a domain
 7596        with the specified name already exists.
 7597
 7598        Creates the domain in the current situation.
 7599
 7600        If `makeActive` is set to `True` (default is `False`) then the
 7601        domain will be made active in whichever context(s) it's created
 7602        in.
 7603        """
 7604        now = self.getSituation()
 7605        state = now.state
 7606        modify = []
 7607        if inCommon in (True, "both"):
 7608            modify.append(('common', state['common']))
 7609        if inCommon in (False, "both"):
 7610            acName = state['activeContext']
 7611            modify.append(
 7612                ('current ({repr(acName)})', state['contexts'][acName])
 7613            )
 7614
 7615        for (fcType, fc) in modify:
 7616            if name in fc['focalization']:
 7617                raise DomainCollisionError(
 7618                    f"Cannot create domain {repr(name)} because a"
 7619                    f" domain with that name already exists in the"
 7620                    f" {fcType} focal context."
 7621                )
 7622            fc['focalization'][name] = focalization
 7623            if makeActive:
 7624                fc['activeDomains'].add(name)
 7625            if focalization == "spreading":
 7626                fc['activeDecisions'][name] = set()
 7627            elif focalization == "plural":
 7628                fc['activeDecisions'][name] = {}
 7629            else:
 7630                fc['activeDecisions'][name] = None
 7631
 7632    def activateDomain(
 7633        self,
 7634        domain: base.Domain,
 7635        activate: bool = True,
 7636        inContext: base.ContextSpecifier = "active"
 7637    ) -> None:
 7638        """
 7639        Sets the given domain as active (or inactive if 'activate' is
 7640        given as `False`) in the specified context (default "active").
 7641
 7642        Modifies the current situation.
 7643        """
 7644        fc: base.FocalContext
 7645        if inContext == "active":
 7646            fc = self.getActiveContext()
 7647        elif inContext == "common":
 7648            fc = self.getCommonContext()
 7649
 7650        if activate:
 7651            fc['activeDomains'].add(domain)
 7652        else:
 7653            try:
 7654                fc['activeDomains'].remove(domain)
 7655            except KeyError:
 7656                pass
 7657
 7658    def createTriggerGroup(
 7659        self,
 7660        name: base.DecisionName
 7661    ) -> base.DecisionID:
 7662        """
 7663        Creates a new trigger group with the given name, returning the
 7664        decision ID for that trigger group. If this is the first trigger
 7665        group being created, also creates the `TRIGGERS_DOMAIN` domain
 7666        as a spreading-focalized domain that's active in the common
 7667        context (but does NOT set the created trigger group as an active
 7668        decision in that domain).
 7669
 7670        You can use 'goto' effects to activate trigger domains via
 7671        consequences, and 'retreat' effects to deactivate them.
 7672
 7673        Creating a second trigger group with the same name as another
 7674        results in a `ValueError`.
 7675
 7676        TODO: Retreat effects
 7677        """
 7678        ctx = self.getCommonContext()
 7679        if TRIGGERS_DOMAIN not in ctx['focalization']:
 7680            self.createDomain(
 7681                TRIGGERS_DOMAIN,
 7682                focalization='spreading',
 7683                makeActive=True,
 7684                inCommon=True
 7685            )
 7686
 7687        graph = self.getSituation().graph
 7688        if graph.getDecision(
 7689            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
 7690        ) is not None:
 7691            raise ValueError(
 7692                f"Cannot create trigger group {name!r}: a trigger group"
 7693                f" with that name already exists."
 7694            )
 7695
 7696        return self.getSituation().graph.triggerGroupID(name)
 7697
 7698    def toggleTriggerGroup(
 7699        self,
 7700        name: base.DecisionName,
 7701        setActive: Union[bool, None] = None
 7702    ):
 7703        """
 7704        Toggles whether the specified trigger group (a decision in the
 7705        `TRIGGERS_DOMAIN`) is active or not. Pass `True` or `False` as
 7706        the `setActive` argument (instead of the default `None`) to set
 7707        the state directly instead of toggling it.
 7708
 7709        Note that trigger groups are decisions in a spreading-focalized
 7710        domain, so they can be activated or deactivated by the 'goto'
 7711        and 'retreat' effects as well.
 7712
 7713        This does not affect whether the `TRIGGERS_DOMAIN` itself is
 7714        active (normally it would always be active).
 7715
 7716        Raises a `MissingDecisionError` if the specified trigger group
 7717        does not exist yet, including when the entire `TRIGGERS_DOMAIN`
 7718        does not exist. Raises a `KeyError` if the target group exists
 7719        but the `TRIGGERS_DOMAIN` has not been set up properly.
 7720        """
 7721        ctx = self.getCommonContext()
 7722        tID = self.getSituation().graph.resolveDecision(
 7723            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
 7724        )
 7725        activeGroups = ctx['activeDecisions'][TRIGGERS_DOMAIN]
 7726        assert isinstance(activeGroups, set)
 7727        if tID in activeGroups:
 7728            if setActive is not True:
 7729                activeGroups.remove(tID)
 7730        else:
 7731            if setActive is not False:
 7732                activeGroups.add(tID)
 7733
 7734    def getActiveDecisions(
 7735        self,
 7736        step: Optional[int] = None,
 7737        inCommon: Union[bool, Literal["both"]] = "both"
 7738    ) -> Set[base.DecisionID]:
 7739        """
 7740        Returns the set of active decisions at the given step index, or
 7741        at the current step if no step is specified. Raises an
 7742        `IndexError` if the step index is out of bounds (see `__len__`).
 7743        May return an empty set if no decisions are active.
 7744
 7745        If `inCommon` is set to "both" (the default) then decisions
 7746        active in either the common or active context are returned. Set
 7747        it to `True` or `False` to return only decisions active in the
 7748        common (when `True`) or  active (when `False`) context.
 7749        """
 7750        if step is None:
 7751            step = -1
 7752        state = self.getSituation(step).state
 7753        if inCommon == "both":
 7754            return base.combinedDecisionSet(state)
 7755        elif inCommon is True:
 7756            return base.activeDecisionSet(state['common'])
 7757        elif inCommon is False:
 7758            return base.activeDecisionSet(
 7759                state['contexts'][state['activeContext']]
 7760            )
 7761        else:
 7762            raise ValueError(
 7763                f"Invalid inCommon value {repr(inCommon)} (must be"
 7764                f" 'both', True, or False)."
 7765            )
 7766
 7767    def setActiveDecisionsAtStep(
 7768        self,
 7769        step: int,
 7770        domain: base.Domain,
 7771        activate: Union[
 7772            base.DecisionID,
 7773            Dict[base.FocalPointName, Optional[base.DecisionID]],
 7774            Set[base.DecisionID]
 7775        ],
 7776        inCommon: bool = False
 7777    ) -> None:
 7778        """
 7779        Changes the activation status of decisions in the active
 7780        `FocalContext` at the specified step, for the specified domain
 7781        (see `currentActiveContext`). Does this without adding an
 7782        exploration step, which is unusual: normally you should use
 7783        another method like `warp` to update active decisions.
 7784
 7785        Note that this does not change which domains are active, and
 7786        setting active decisions in inactive domains does not make those
 7787        decisions active overall.
 7788
 7789        Which decisions to activate or deactivate are specified as
 7790        either a single `DecisionID`, a list of them, or a set of them,
 7791        depending on the `DomainFocalization` setting in the selected
 7792        `FocalContext` for the specified domain. A `TypeError` will be
 7793        raised if the wrong kind of decision information is provided. If
 7794        the focalization context does not have any focalization value for
 7795        the domain in question, it will be set based on the kind of
 7796        active decision information specified.
 7797
 7798        A `MissingDecisionError` will be raised if a decision is
 7799        included which is not part of the current `DecisionGraph`.
 7800        The provided information will overwrite the previous active
 7801        decision information.
 7802
 7803        If `inCommon` is set to `True`, then decisions are activated or
 7804        deactivated in the common context, instead of in the active
 7805        context.
 7806
 7807        Example:
 7808
 7809        >>> e = DiscreteExploration()
 7810        >>> e.getActiveDecisions()
 7811        set()
 7812        >>> graph = e.getSituation().graph
 7813        >>> graph.addDecision('A')
 7814        0
 7815        >>> graph.addDecision('B')
 7816        1
 7817        >>> graph.addDecision('C')
 7818        2
 7819        >>> e.setActiveDecisionsAtStep(0, 'main', 0)
 7820        >>> e.getActiveDecisions()
 7821        {0}
 7822        >>> e.setActiveDecisionsAtStep(0, 'main', 1)
 7823        >>> e.getActiveDecisions()
 7824        {1}
 7825        >>> graph = e.getSituation().graph
 7826        >>> graph.addDecision('One', domain='numbers')
 7827        3
 7828        >>> graph.addDecision('Two', domain='numbers')
 7829        4
 7830        >>> graph.addDecision('Three', domain='numbers')
 7831        5
 7832        >>> graph.addDecision('Bear', domain='animals')
 7833        6
 7834        >>> graph.addDecision('Spider', domain='animals')
 7835        7
 7836        >>> graph.addDecision('Eel', domain='animals')
 7837        8
 7838        >>> ac = e.getActiveContext()
 7839        >>> ac['focalization']['numbers'] = 'plural'
 7840        >>> ac['focalization']['animals'] = 'spreading'
 7841        >>> ac['activeDecisions']['numbers'] = {'a': None, 'b': None}
 7842        >>> ac['activeDecisions']['animals'] = set()
 7843        >>> cc = e.getCommonContext()
 7844        >>> cc['focalization']['numbers'] = 'plural'
 7845        >>> cc['focalization']['animals'] = 'spreading'
 7846        >>> cc['activeDecisions']['numbers'] = {'z': None}
 7847        >>> cc['activeDecisions']['animals'] = set()
 7848        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 3, 'b': 3})
 7849        >>> e.getActiveDecisions()
 7850        {1}
 7851        >>> e.activateDomain('numbers')
 7852        >>> e.getActiveDecisions()
 7853        {1, 3}
 7854        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 4, 'b': None})
 7855        >>> e.getActiveDecisions()
 7856        {1, 4}
 7857        >>> # Wrong domain for the decision ID:
 7858        >>> e.setActiveDecisionsAtStep(0, 'main', 3)
 7859        Traceback (most recent call last):
 7860        ...
 7861        ValueError...
 7862        >>> # Wrong domain for one of the decision IDs:
 7863        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 2, 'b': None})
 7864        Traceback (most recent call last):
 7865        ...
 7866        ValueError...
 7867        >>> # Wrong kind of decision information provided.
 7868        >>> e.setActiveDecisionsAtStep(0, 'numbers', 3)
 7869        Traceback (most recent call last):
 7870        ...
 7871        TypeError...
 7872        >>> e.getActiveDecisions()
 7873        {1, 4}
 7874        >>> e.setActiveDecisionsAtStep(0, 'animals', {6, 7})
 7875        >>> e.getActiveDecisions()
 7876        {1, 4}
 7877        >>> e.activateDomain('animals')
 7878        >>> e.getActiveDecisions()
 7879        {1, 4, 6, 7}
 7880        >>> e.setActiveDecisionsAtStep(0, 'animals', {8})
 7881        >>> e.getActiveDecisions()
 7882        {8, 1, 4}
 7883        >>> e.setActiveDecisionsAtStep(1, 'main', 2)  # invalid step
 7884        Traceback (most recent call last):
 7885        ...
 7886        IndexError...
 7887        >>> e.setActiveDecisionsAtStep(0, 'novel', 0)  # domain mismatch
 7888        Traceback (most recent call last):
 7889        ...
 7890        ValueError...
 7891
 7892        Example of active/common contexts:
 7893
 7894        >>> e = DiscreteExploration()
 7895        >>> graph = e.getSituation().graph
 7896        >>> graph.addDecision('A')
 7897        0
 7898        >>> graph.addDecision('B')
 7899        1
 7900        >>> e.activateDomain('main', inContext="common")
 7901        >>> e.setActiveDecisionsAtStep(0, 'main', 0, inCommon=True)
 7902        >>> e.getActiveDecisions()
 7903        {0}
 7904        >>> e.setActiveDecisionsAtStep(0, 'main', None)
 7905        >>> e.getActiveDecisions()
 7906        {0}
 7907        >>> # (Still active since it's active in the common context)
 7908        >>> e.setActiveDecisionsAtStep(0, 'main', 1)
 7909        >>> e.getActiveDecisions()
 7910        {0, 1}
 7911        >>> e.setActiveDecisionsAtStep(0, 'main', 1, inCommon=True)
 7912        >>> e.getActiveDecisions()
 7913        {1}
 7914        >>> e.setActiveDecisionsAtStep(0, 'main', None, inCommon=True)
 7915        >>> e.getActiveDecisions()
 7916        {1}
 7917        >>> # (Still active since it's active in the active context)
 7918        >>> e.setActiveDecisionsAtStep(0, 'main', None)
 7919        >>> e.getActiveDecisions()
 7920        set()
 7921        """
 7922        now = self.getSituation(step)
 7923        graph = now.graph
 7924        if inCommon:
 7925            context = self.getCommonContext(step)
 7926        else:
 7927            context = self.getActiveContext(step)
 7928
 7929        defaultFocalization: base.DomainFocalization = 'singular'
 7930        if isinstance(activate, base.DecisionID):
 7931            defaultFocalization = 'singular'
 7932        elif isinstance(activate, dict):
 7933            defaultFocalization = 'plural'
 7934        elif isinstance(activate, set):
 7935            defaultFocalization = 'spreading'
 7936        elif domain not in context['focalization']:
 7937            raise TypeError(
 7938                f"Domain {domain!r} has no focalization in the"
 7939                f" {'common' if inCommon else 'active'} context,"
 7940                f" and the specified position doesn't imply one."
 7941            )
 7942
 7943        focalization = base.getDomainFocalization(
 7944            context,
 7945            domain,
 7946            defaultFocalization
 7947        )
 7948
 7949        # Check domain & existence of decision(s) in question
 7950        if activate is None:
 7951            pass
 7952        elif isinstance(activate, base.DecisionID):
 7953            if activate not in graph:
 7954                raise MissingDecisionError(
 7955                    f"There is no decision {activate} at step {step}."
 7956                )
 7957            if graph.domainFor(activate) != domain:
 7958                raise ValueError(
 7959                    f"Can't set active decisions in domain {domain!r}"
 7960                    f" to decision {graph.identityOf(activate)} because"
 7961                    f" that decision is in actually in domain"
 7962                    f" {graph.domainFor(activate)!r}."
 7963                )
 7964        elif isinstance(activate, dict):
 7965            for fpName, pos in activate.items():
 7966                if pos is None:
 7967                    continue
 7968                if pos not in graph:
 7969                    raise MissingDecisionError(
 7970                        f"There is no decision {pos} at step {step}."
 7971                    )
 7972                if graph.domainFor(pos) != domain:
 7973                    raise ValueError(
 7974                        f"Can't set active decision for focal point"
 7975                        f" {fpName!r} in domain {domain!r}"
 7976                        f" to decision {graph.identityOf(pos)} because"
 7977                        f" that decision is in actually in domain"
 7978                        f" {graph.domainFor(pos)!r}."
 7979                    )
 7980        elif isinstance(activate, set):
 7981            for pos in activate:
 7982                if pos not in graph:
 7983                    raise MissingDecisionError(
 7984                        f"There is no decision {pos} at step {step}."
 7985                    )
 7986                if graph.domainFor(pos) != domain:
 7987                    raise ValueError(
 7988                        f"Can't set {graph.identityOf(pos)} as an"
 7989                        f" active decision in domain {domain!r} to"
 7990                        f" decision because that decision is in"
 7991                        f" actually in domain {graph.domainFor(pos)!r}."
 7992                    )
 7993        else:
 7994            raise TypeError(
 7995                f"Domain {domain!r} has no focalization in the"
 7996                f" {'common' if inCommon else 'active'} context,"
 7997                f" and the specified position doesn't imply one:"
 7998                f"\n{activate!r}"
 7999            )
 8000
 8001        if focalization == 'singular':
 8002            if activate is None or isinstance(activate, base.DecisionID):
 8003                if activate is not None:
 8004                    targetDomain = graph.domainFor(activate)
 8005                    if activate not in graph:
 8006                        raise MissingDecisionError(
 8007                            f"There is no decision {activate} in the"
 8008                            f" graph at step {step}."
 8009                        )
 8010                    elif targetDomain != domain:
 8011                        raise ValueError(
 8012                            f"At step {step}, decision {activate} cannot"
 8013                            f" be the active decision for domain"
 8014                            f" {repr(domain)} because it is in a"
 8015                            f" different domain ({repr(targetDomain)})."
 8016                        )
 8017                context['activeDecisions'][domain] = activate
 8018            else:
 8019                raise TypeError(
 8020                    f"{'Common' if inCommon else 'Active'} focal"
 8021                    f" context at step {step} has {repr(focalization)}"
 8022                    f" focalization for domain {repr(domain)}, so the"
 8023                    f" active decision must be a single decision or"
 8024                    f" None.\n(You provided: {repr(activate)})"
 8025                )
 8026        elif focalization == 'plural':
 8027            if (
 8028                isinstance(activate, dict)
 8029            and all(
 8030                    isinstance(k, base.FocalPointName)
 8031                    for k in activate.keys()
 8032                )
 8033            and all(
 8034                    v is None or isinstance(v, base.DecisionID)
 8035                    for v in activate.values()
 8036                )
 8037            ):
 8038                for v in activate.values():
 8039                    if v is not None:
 8040                        targetDomain = graph.domainFor(v)
 8041                        if v not in graph:
 8042                            raise MissingDecisionError(
 8043                                f"There is no decision {v} in the graph"
 8044                                f" at step {step}."
 8045                            )
 8046                        elif targetDomain != domain:
 8047                            raise ValueError(
 8048                                f"At step {step}, decision {activate}"
 8049                                f" cannot be an active decision for"
 8050                                f" domain {repr(domain)} because it is"
 8051                                f" in a different domain"
 8052                                f" ({repr(targetDomain)})."
 8053                            )
 8054                context['activeDecisions'][domain] = activate
 8055            else:
 8056                raise TypeError(
 8057                    f"{'Common' if inCommon else 'Active'} focal"
 8058                    f" context at step {step} has {repr(focalization)}"
 8059                    f" focalization for domain {repr(domain)}, so the"
 8060                    f" active decision must be a dictionary mapping"
 8061                    f" focal point names to decision IDs (or Nones)."
 8062                    f"\n(You provided: {repr(activate)})"
 8063                )
 8064        elif focalization == 'spreading':
 8065            if (
 8066                isinstance(activate, set)
 8067            and all(isinstance(x, base.DecisionID) for x in activate)
 8068            ):
 8069                for x in activate:
 8070                    targetDomain = graph.domainFor(x)
 8071                    if x not in graph:
 8072                        raise MissingDecisionError(
 8073                            f"There is no decision {x} in the graph"
 8074                            f" at step {step}."
 8075                        )
 8076                    elif targetDomain != domain:
 8077                        raise ValueError(
 8078                            f"At step {step}, decision {activate}"
 8079                            f" cannot be an active decision for"
 8080                            f" domain {repr(domain)} because it is"
 8081                            f" in a different domain"
 8082                            f" ({repr(targetDomain)})."
 8083                        )
 8084                context['activeDecisions'][domain] = activate
 8085            else:
 8086                raise TypeError(
 8087                    f"{'Common' if inCommon else 'Active'} focal"
 8088                    f" context at step {step} has {repr(focalization)}"
 8089                    f" focalization for domain {repr(domain)}, so the"
 8090                    f" active decision must be a set of decision IDs"
 8091                    f"\n(You provided: {repr(activate)})"
 8092                )
 8093        else:
 8094            raise RuntimeError(
 8095                f"Invalid focalization value {repr(focalization)} for"
 8096                f" domain {repr(domain)} at step {step}."
 8097            )
 8098
 8099    def movementAtStep(self, step: int = -1) -> Tuple[
 8100        Union[base.DecisionID, Set[base.DecisionID], None],
 8101        Optional[base.Transition],
 8102        Union[base.DecisionID, Set[base.DecisionID], None]
 8103    ]:
 8104        """
 8105        Given a step number, returns information about the starting
 8106        decision, transition taken, and destination decision for that
 8107        step. Not all steps have all of those, so some items may be
 8108        `None`.
 8109
 8110        For steps where there is no action, where a decision is still
 8111        pending, or where the action type is 'focus', 'swap', 'focalize',
 8112        or 'revertTo', the result will be `(None, None, None)`, unless a
 8113        primary decision is available in which case the first item in the
 8114        tuple will be that decision. For 'start' actions, the starting
 8115        position and transition will be `None` (again unless the step had
 8116        a primary decision) but the destination will be the ID of the
 8117        node started at. For 'revertTo' actions, the destination will be
 8118        the primary decision of the state reverted to, if available.
 8119
 8120        Also, if the action taken has multiple potential or actual start
 8121        or end points, these may be sets of decision IDs instead of
 8122        single IDs.
 8123
 8124        Note that the primary decision of the starting state is usually
 8125        used as the from-decision, but in some cases an action dictates
 8126        taking a transition from a different decision, and this function
 8127        will return that decision as the from-decision.
 8128
 8129        TODO: Examples!
 8130
 8131        TODO: Account for bounce/follow/goto effects!!!
 8132        """
 8133        now = self.getSituation(step)
 8134        action = now.action
 8135        graph = now.graph
 8136        primary = now.state['primaryDecision']
 8137
 8138        if action is None:
 8139            return (primary, None, None)
 8140
 8141        aType = action[0]
 8142        fromID: Optional[base.DecisionID]
 8143        destID: Optional[base.DecisionID]
 8144        transition: base.Transition
 8145        outcomes: List[bool]
 8146
 8147        if aType in ('noAction', 'focus', 'swap', 'focalize'):
 8148            return (primary, None, None)
 8149        elif aType == 'start':
 8150            assert len(action) == 7
 8151            where = cast(
 8152                Union[
 8153                    base.DecisionID,
 8154                    Dict[base.FocalPointName, base.DecisionID],
 8155                    Set[base.DecisionID]
 8156                ],
 8157                action[1]
 8158            )
 8159            if isinstance(where, dict):
 8160                where = set(where.values())
 8161            return (primary, None, where)
 8162        elif aType in ('take', 'explore'):
 8163            if (
 8164                (len(action) == 4 or len(action) == 7)
 8165            and isinstance(action[2], base.DecisionID)
 8166            ):
 8167                fromID = action[2]
 8168                assert isinstance(action[3], tuple)
 8169                transition, outcomes = action[3]
 8170                if (
 8171                    action[0] == "explore"
 8172                and isinstance(action[4], base.DecisionID)
 8173                ):
 8174                    destID = action[4]
 8175                else:
 8176                    destID = graph.getDestination(fromID, transition)
 8177                return (fromID, transition, destID)
 8178            elif (
 8179                (len(action) == 3 or len(action) == 6)
 8180            and isinstance(action[1], tuple)
 8181            and isinstance(action[2], base.Transition)
 8182            and len(action[1]) == 3
 8183            and action[1][0] in get_args(base.ContextSpecifier)
 8184            and isinstance(action[1][1], base.Domain)
 8185            and isinstance(action[1][2], base.FocalPointName)
 8186            ):
 8187                fromID = base.resolvePosition(now.state, action[1])
 8188                if fromID is None:
 8189                    raise InvalidActionError(
 8190                        f"{aType!r} action at step {step} has position"
 8191                        f" {action[1]!r} which cannot be resolved to a"
 8192                        f" decision."
 8193                    )
 8194                transition, outcomes = action[2]
 8195                if (
 8196                    action[0] == "explore"
 8197                and isinstance(action[3], base.DecisionID)
 8198                ):
 8199                    destID = action[3]
 8200                else:
 8201                    destID = graph.getDestination(fromID, transition)
 8202                return (fromID, transition, destID)
 8203            else:
 8204                raise InvalidActionError(
 8205                    f"Malformed {aType!r} action:\n{repr(action)}"
 8206                )
 8207        elif aType == 'warp':
 8208            if len(action) != 3:
 8209                raise InvalidActionError(
 8210                    f"Malformed 'warp' action:\n{repr(action)}"
 8211                )
 8212            dest = action[2]
 8213            assert isinstance(dest, base.DecisionID)
 8214            if action[1] in get_args(base.ContextSpecifier):
 8215                # Unspecified starting point; find active decisions in
 8216                # same domain if primary is None
 8217                if primary is not None:
 8218                    return (primary, None, dest)
 8219                else:
 8220                    toDomain = now.graph.domainFor(dest)
 8221                    # TODO: Could check destination focalization here...
 8222                    active = self.getActiveDecisions(step)
 8223                    sameDomain = set(
 8224                        dID
 8225                        for dID in active
 8226                        if now.graph.domainFor(dID) == toDomain
 8227                    )
 8228                    if len(sameDomain) == 1:
 8229                        return (
 8230                            list(sameDomain)[0],
 8231                            None,
 8232                            dest
 8233                        )
 8234                    else:
 8235                        return (
 8236                            sameDomain,
 8237                            None,
 8238                            dest
 8239                        )
 8240            else:
 8241                if (
 8242                    not isinstance(action[1], tuple)
 8243                or not len(action[1]) == 3
 8244                or not action[1][0] in get_args(base.ContextSpecifier)
 8245                or not isinstance(action[1][1], base.Domain)
 8246                or not isinstance(action[1][2], base.FocalPointName)
 8247                ):
 8248                    raise InvalidActionError(
 8249                        f"Malformed 'warp' action:\n{repr(action)}"
 8250                    )
 8251                return (
 8252                    base.resolvePosition(now.state, action[1]),
 8253                    None,
 8254                    dest
 8255                )
 8256        elif aType == 'revertTo':
 8257            assert len(action) == 3  # type, save slot, & aspects
 8258            if primary is not None:
 8259                cameFrom = primary
 8260            nextSituation = self.getSituation(step + 1)
 8261            wentTo = nextSituation.state['primaryDecision']
 8262            return (primary, None, wentTo)
 8263        else:
 8264            raise InvalidActionError(
 8265                f"Action taken had invalid action type {repr(aType)}:"
 8266                f"\n{repr(action)}"
 8267            )
 8268
 8269    def latestStepWithDecision(
 8270        self,
 8271        dID: base.DecisionID,
 8272        startFrom: int = -1
 8273    ) -> int:
 8274        """
 8275        Scans backwards through exploration steps until it finds a graph
 8276        that contains a decision with the specified ID, and returns the
 8277        step number of that step. Instead of starting from the last step,
 8278        you can tell it to start from a different step (either positive
 8279        or negative index) via `startFrom`. Raises a
 8280        `MissingDecisionError` if there is no such step.
 8281        """
 8282        if startFrom < 0:
 8283            startFrom = len(self) + startFrom
 8284        for step in range(startFrom, -1, -1):
 8285            graph = self.getSituation(step).graph
 8286            try:
 8287                return step
 8288            except MissingDecisionError:
 8289                continue
 8290        raise MissingDecisionError(
 8291            f"Decision {dID!r} does not exist at any step of the"
 8292            f" exploration."
 8293        )
 8294
 8295    def latestDecisionInfo(self, dID: base.DecisionID) -> DecisionInfo:
 8296        """
 8297        Looks up decision info for the given decision in the latest step
 8298        in which that decision exists (which will usually be the final
 8299        exploration step, unless the decision was merged or otherwise
 8300        removed along the way). This will raise a `MissingDecisionError`
 8301        only if there is no step at which the specified decision exists.
 8302        """
 8303        for step in range(len(self) - 1, -1, -1):
 8304            graph = self.getSituation(step).graph
 8305            try:
 8306                return graph.decisionInfo(dID)
 8307            except MissingDecisionError:
 8308                continue
 8309        raise MissingDecisionError(
 8310            f"Decision {dID!r} does not exist at any step of the"
 8311            f" exploration."
 8312        )
 8313
 8314    def latestTransitionProperties(
 8315        self,
 8316        dID: base.DecisionID,
 8317        transition: base.Transition
 8318    ) -> TransitionProperties:
 8319        """
 8320        Looks up transition properties for the transition with the given
 8321        name outgoing from the decision with the given ID, in the latest
 8322        step in which a transiiton with that name from that decision
 8323        exists (which will usually be the final exploration step, unless
 8324        transitions get removed/renamed along the way). Note that because
 8325        a transition can be deleted and later added back (unlike
 8326        decisions where an ID will not be re-used), it's possible there
 8327        are two or more different transitions that meet the
 8328        specifications at different points in time, and this will always
 8329        return the properties of the last of them. This will raise a
 8330        `MissingDecisionError` if there is no step at which the specified
 8331        decision exists, and a `MissingTransitionError` if the target
 8332        decision exists at some step but never has a transition with the
 8333        specified name.
 8334        """
 8335        sawDecision: Optional[int] = None
 8336        for step in range(len(self) - 1, -1, -1):
 8337            graph = self.getSituation(step).graph
 8338            try:
 8339                return graph.getTransitionProperties(dID, transition)
 8340            except (MissingDecisionError, MissingTransitionError) as e:
 8341                if (
 8342                    sawDecision is None
 8343                and isinstance(e, MissingTransitionError)
 8344                ):
 8345                    sawDecision = step
 8346                continue
 8347        if sawDecision is None:
 8348            raise MissingDecisionError(
 8349                f"Decision {dID!r} does not exist at any step of the"
 8350                f" exploration."
 8351            )
 8352        else:
 8353            raise MissingTransitionError(
 8354                f"Decision {dID!r} does exist (last seen at step"
 8355                f" {sawDecision}) but it never has an outgoing"
 8356                f" transition named {transition!r}."
 8357            )
 8358
 8359    def tagStep(
 8360        self,
 8361        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
 8362        tagValue: Union[
 8363            base.TagValue,
 8364            type[base.NoTagValue]
 8365        ] = base.NoTagValue,
 8366        step: int = -1
 8367    ) -> None:
 8368        """
 8369        Adds a tag (or multiple tags) to the current step, or to a
 8370        specific step if `n` is given as an integer rather than the
 8371        default `None`. A tag value should be supplied when a tag is
 8372        given (unless you want to use the default of `1`), but it's a
 8373        `ValueError` to supply a tag value when a dictionary of tags to
 8374        update is provided.
 8375        """
 8376        if isinstance(tagOrTags, base.Tag):
 8377            if tagValue is base.NoTagValue:
 8378                tagValue = 1
 8379
 8380            # Not sure why this is necessary...
 8381            tagValue = cast(base.TagValue, tagValue)
 8382
 8383            self.getSituation(step).tags.update({tagOrTags: tagValue})
 8384        else:
 8385            self.getSituation(step).tags.update(tagOrTags)
 8386
 8387    def annotateStep(
 8388        self,
 8389        annotationOrAnnotations: Union[
 8390            base.Annotation,
 8391            Sequence[base.Annotation]
 8392        ],
 8393        step: Optional[int] = None
 8394    ) -> None:
 8395        """
 8396        Adds an annotation to the current exploration step, or to a
 8397        specific step if `n` is given as an integer rather than the
 8398        default `None`.
 8399        """
 8400        if step is None:
 8401            step = -1
 8402        if isinstance(annotationOrAnnotations, base.Annotation):
 8403            self.getSituation(step).annotations.append(
 8404                annotationOrAnnotations
 8405            )
 8406        else:
 8407            self.getSituation(step).annotations.extend(
 8408                annotationOrAnnotations
 8409            )
 8410
 8411    def hasCapability(
 8412        self,
 8413        capability: base.Capability,
 8414        step: Optional[int] = None,
 8415        inCommon: Union[bool, Literal['both']] = "both"
 8416    ) -> bool:
 8417        """
 8418        Returns True if the player currently had the specified
 8419        capability, at the specified exploration step, and False
 8420        otherwise. Checks the current state if no step is given. Does
 8421        NOT return true if the game state means that the player has an
 8422        equivalent for that capability (see
 8423        `hasCapabilityOrEquivalent`).
 8424
 8425        Normally, `inCommon` is set to 'both' by default and so if
 8426        either the common `FocalContext` or the active one has the
 8427        capability, this will return `True`. `inCommon` may instead be
 8428        set to `True` or `False` to ask about just the common (or
 8429        active) focal context.
 8430        """
 8431        state = self.getSituation().state
 8432        commonCapabilities = state['common']['capabilities']\
 8433            ['capabilities']  # noqa
 8434        activeCapabilities = state['contexts'][state['activeContext']]\
 8435            ['capabilities']['capabilities']  # noqa
 8436
 8437        if inCommon == 'both':
 8438            return (
 8439                capability in commonCapabilities
 8440             or capability in activeCapabilities
 8441            )
 8442        elif inCommon is True:
 8443            return capability in commonCapabilities
 8444        elif inCommon is False:
 8445            return capability in activeCapabilities
 8446        else:
 8447            raise ValueError(
 8448                f"Invalid inCommon value (must be False, True, or"
 8449                f" 'both'; got {repr(inCommon)})."
 8450            )
 8451
 8452    def hasCapabilityOrEquivalent(
 8453        self,
 8454        capability: base.Capability,
 8455        step: Optional[int] = None,
 8456        location: Optional[Set[base.DecisionID]] = None
 8457    ) -> bool:
 8458        """
 8459        Works like `hasCapability`, but also returns `True` if the
 8460        player counts as having the specified capability via an equivalence
 8461        that's part of the current graph. As with `hasCapability`, the
 8462        optional `step` argument is used to specify which step to check,
 8463        with the current step being used as the default.
 8464
 8465        The `location` set can specify where to start looking for
 8466        mechanisms; if left unspecified active decisions for that step
 8467        will be used.
 8468        """
 8469        if step is None:
 8470            step = -1
 8471        if location is None:
 8472            location = self.getActiveDecisions(step)
 8473        situation = self.getSituation(step)
 8474        return base.hasCapabilityOrEquivalent(
 8475            capability,
 8476            base.RequirementContext(
 8477                state=situation.state,
 8478                graph=situation.graph,
 8479                searchFrom=location
 8480            )
 8481        )
 8482
 8483    def gainCapabilityNow(
 8484        self,
 8485        capability: base.Capability,
 8486        inCommon: bool = False
 8487    ) -> None:
 8488        """
 8489        Modifies the current game state to add the specified `Capability`
 8490        to the player's capabilities. No changes are made to the current
 8491        graph.
 8492
 8493        If `inCommon` is set to `True` (default is `False`) then the
 8494        capability will be added to the common `FocalContext` and will
 8495        therefore persist even when a focal context switch happens.
 8496        Normally, it will be added to the currently-active focal
 8497        context.
 8498        """
 8499        state = self.getSituation().state
 8500        if inCommon:
 8501            context = state['common']
 8502        else:
 8503            context = state['contexts'][state['activeContext']]
 8504        context['capabilities']['capabilities'].add(capability)
 8505
 8506    def loseCapabilityNow(
 8507        self,
 8508        capability: base.Capability,
 8509        inCommon: Union[bool, Literal['both']] = "both"
 8510    ) -> None:
 8511        """
 8512        Modifies the current game state to remove the specified `Capability`
 8513        from the player's capabilities. Does nothing if the player
 8514        doesn't already have that capability.
 8515
 8516        By default, this removes the capability from both the common
 8517        capabilities set and the active `FocalContext`'s capabilities
 8518        set, so that afterwards the player will definitely not have that
 8519        capability. However, if you set `inCommon` to either `True` or
 8520        `False`, it will remove the capability from just the common
 8521        capabilities set (if `True`) or just the active capabilities set
 8522        (if `False`). In these cases, removing the capability from just
 8523        one capability set will not actually remove it in terms of the
 8524        `hasCapability` result if it had been present in the other set.
 8525        Set `inCommon` to "both" to use the default behavior explicitly.
 8526        """
 8527        now = self.getSituation()
 8528        if inCommon in ("both", True):
 8529            context = now.state['common']
 8530            try:
 8531                context['capabilities']['capabilities'].remove(capability)
 8532            except KeyError:
 8533                pass
 8534        elif inCommon in ("both", False):
 8535            context = now.state['contexts'][now.state['activeContext']]
 8536            try:
 8537                context['capabilities']['capabilities'].remove(capability)
 8538            except KeyError:
 8539                pass
 8540        else:
 8541            raise ValueError(
 8542                f"Invalid inCommon value (must be False, True, or"
 8543                f" 'both'; got {repr(inCommon)})."
 8544            )
 8545
 8546    def tokenCountNow(self, tokenType: base.Token) -> Optional[int]:
 8547        """
 8548        Returns the number of tokens the player currently has of a given
 8549        type. Returns `None` if the player has never acquired or lost
 8550        tokens of that type.
 8551
 8552        This method adds together tokens from the common and active
 8553        focal contexts.
 8554        """
 8555        state = self.getSituation().state
 8556        commonContext = state['common']
 8557        activeContext = state['contexts'][state['activeContext']]
 8558        base = commonContext['capabilities']['tokens'].get(tokenType)
 8559        if base is None:
 8560            return activeContext['capabilities']['tokens'].get(tokenType)
 8561        else:
 8562            return base + activeContext['capabilities']['tokens'].get(
 8563                tokenType,
 8564                0
 8565            )
 8566
 8567    def adjustTokensNow(
 8568        self,
 8569        tokenType: base.Token,
 8570        amount: int,
 8571        inCommon: bool = False
 8572    ) -> None:
 8573        """
 8574        Modifies the current game state to add the specified number of
 8575        `Token`s of the given type to the player's tokens. No changes are
 8576        made to the current graph. Reduce the number of tokens by
 8577        supplying a negative amount; note that negative token amounts
 8578        are possible.
 8579
 8580        By default, the number of tokens for the current active
 8581        `FocalContext` will be adjusted. However, if `inCommon` is set
 8582        to `True`, then the number of tokens for the common context will
 8583        be adjusted instead.
 8584        """
 8585        # TODO: Custom token caps!
 8586        state = self.getSituation().state
 8587        if inCommon:
 8588            context = state['common']
 8589        else:
 8590            context = state['contexts'][state['activeContext']]
 8591        tokens = context['capabilities']['tokens']
 8592        tokens[tokenType] = tokens.get(tokenType, 0) + amount
 8593
 8594    def setTokensNow(
 8595        self,
 8596        tokenType: base.Token,
 8597        amount: int,
 8598        inCommon: bool = False
 8599    ) -> None:
 8600        """
 8601        Modifies the current game state to set number of `Token`s of the
 8602        given type to a specific amount, regardless of the old value. No
 8603        changes are made to the current graph.
 8604
 8605        By default this sets the number of tokens for the active
 8606        `FocalContext`. But if you set `inCommon` to `True`, it will
 8607        set the number of tokens in the common context instead.
 8608        """
 8609        # TODO: Custom token caps!
 8610        state = self.getSituation().state
 8611        if inCommon:
 8612            context = state['common']
 8613        else:
 8614            context = state['contexts'][state['activeContext']]
 8615        context['capabilities']['tokens'][tokenType] = amount
 8616
 8617    def lookupMechanism(
 8618        self,
 8619        mechanism: base.MechanismName,
 8620        step: Optional[int] = None,
 8621        where: Union[
 8622            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]],
 8623            Collection[base.AnyDecisionSpecifier],
 8624            None
 8625        ] = None
 8626    ) -> base.MechanismID:
 8627        """
 8628        Looks up a mechanism ID by name, in the graph for the specified
 8629        step. The `where` argument specifies where to start looking,
 8630        which helps disambiguate. It can be a tuple with a decision
 8631        specifier and `None` to start from a single decision, or with a
 8632        decision specifier and a transition name to start from either
 8633        end of that transition. It can also be `None` to look at global
 8634        mechanisms and then all decisions directly, although this
 8635        increases the chance of a `AmbiguousMechanismError`. Finally, it
 8636        can be some other non-tuple collection of decision specifiers to
 8637        start from that set.
 8638
 8639        If no step is specified, uses the current step.
 8640        """
 8641        if step is None:
 8642            step = -1
 8643        situation = self.getSituation(step)
 8644        graph = situation.graph
 8645        searchFrom: Collection[base.AnyDecisionSpecifier]
 8646        if where is None:
 8647            searchFrom = set()
 8648        elif isinstance(where, tuple):
 8649            if len(where) != 2:
 8650                raise ValueError(
 8651                    f"Mechanism lookup location was a tuple with an"
 8652                    f" invalid length (must be length-2 if it's a"
 8653                    f" tuple):\n  {repr(where)}"
 8654                )
 8655            where = cast(
 8656                Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]],
 8657                where
 8658            )
 8659            if where[1] is None:
 8660                searchFrom = {graph.resolveDecision(where[0])}
 8661            else:
 8662                searchFrom = graph.bothEnds(where[0], where[1])
 8663        else:  # must be a collection of specifiers
 8664            searchFrom = cast(Collection[base.AnyDecisionSpecifier], where)
 8665        return graph.lookupMechanism(searchFrom, mechanism)
 8666
 8667    def mechanismState(
 8668        self,
 8669        mechanism: base.AnyMechanismSpecifier,
 8670        where: Optional[Set[base.DecisionID]] = None,
 8671        step: int = -1
 8672    ) -> Optional[base.MechanismState]:
 8673        """
 8674        Returns the current state for the specified mechanism (or the
 8675        state at the specified step if a step index is given). `where`
 8676        may be provided as a set of decision IDs to indicate where to
 8677        search for the named mechanism, or a mechanism ID may be provided
 8678        in the first place. Mechanism states are properties of a `State`
 8679        but are not associated with focal contexts.
 8680        """
 8681        situation = self.getSituation(step)
 8682        mID = situation.graph.resolveMechanism(mechanism, startFrom=where)
 8683        return situation.state['mechanisms'].get(
 8684            mID,
 8685            base.DEFAULT_MECHANISM_STATE
 8686        )
 8687
 8688    def setMechanismStateNow(
 8689        self,
 8690        mechanism: base.AnyMechanismSpecifier,
 8691        toState: base.MechanismState,
 8692        where: Optional[Set[base.DecisionID]] = None
 8693    ) -> None:
 8694        """
 8695        Sets the state of the specified mechanism to the specified
 8696        state. Mechanisms can only be in one state at once, so this
 8697        removes any previous states for that mechanism (note that via
 8698        equivalences multiple mechanism states can count as active).
 8699
 8700        The mechanism can be any kind of mechanism specifier (see
 8701        `base.AnyMechanismSpecifier`). If it's not a mechanism ID and
 8702        doesn't have its own position information, the 'where' argument
 8703        can be used to hint where to search for the mechanism.
 8704        """
 8705        now = self.getSituation()
 8706        mID = now.graph.resolveMechanism(mechanism, startFrom=where)
 8707        now.state['mechanisms'][mID] = toState
 8708
 8709    def skillLevel(
 8710        self,
 8711        skill: base.Skill,
 8712        step: Optional[int] = None
 8713    ) -> Optional[base.Level]:
 8714        """
 8715        Returns the skill level the player had in a given skill at a
 8716        given step, or for the current step if no step is specified.
 8717        Returns `None` if the player had never acquired or lost levels
 8718        in that skill before the specified step (skill level would count
 8719        as 0 in that case).
 8720
 8721        This method adds together levels from the common and active
 8722        focal contexts.
 8723        """
 8724        if step is None:
 8725            step = -1
 8726        state = self.getSituation(step).state
 8727        commonContext = state['common']
 8728        activeContext = state['contexts'][state['activeContext']]
 8729        base = commonContext['capabilities']['skills'].get(skill)
 8730        if base is None:
 8731            return activeContext['capabilities']['skills'].get(skill)
 8732        else:
 8733            return base + activeContext['capabilities']['skills'].get(
 8734                skill,
 8735                0
 8736            )
 8737
 8738    def adjustSkillLevelNow(
 8739        self,
 8740        skill: base.Skill,
 8741        levels: base.Level,
 8742        inCommon: bool = False
 8743    ) -> None:
 8744        """
 8745        Modifies the current game state to add the specified number of
 8746        `Level`s of the given skill. No changes are made to the current
 8747        graph. Reduce the skill level by supplying negative levels; note
 8748        that negative skill levels are possible.
 8749
 8750        By default, the skill level for the current active
 8751        `FocalContext` will be adjusted. However, if `inCommon` is set
 8752        to `True`, then the skill level for the common context will be
 8753        adjusted instead.
 8754        """
 8755        # TODO: Custom level caps?
 8756        state = self.getSituation().state
 8757        if inCommon:
 8758            context = state['common']
 8759        else:
 8760            context = state['contexts'][state['activeContext']]
 8761        skills = context['capabilities']['skills']
 8762        skills[skill] = skills.get(skill, 0) + levels
 8763
 8764    def setSkillLevelNow(
 8765        self,
 8766        skill: base.Skill,
 8767        level: base.Level,
 8768        inCommon: bool = False
 8769    ) -> None:
 8770        """
 8771        Modifies the current game state to set `Skill` `Level` for the
 8772        given skill, regardless of the old value. No changes are made to
 8773        the current graph.
 8774
 8775        By default this sets the skill level for the active
 8776        `FocalContext`. But if you set `inCommon` to `True`, it will set
 8777        the skill level in the common context instead.
 8778        """
 8779        # TODO: Custom level caps?
 8780        state = self.getSituation().state
 8781        if inCommon:
 8782            context = state['common']
 8783        else:
 8784            context = state['contexts'][state['activeContext']]
 8785        skills = context['capabilities']['skills']
 8786        skills[skill] = level
 8787
 8788    def updateRequirementNow(
 8789        self,
 8790        decision: base.AnyDecisionSpecifier,
 8791        transition: base.Transition,
 8792        requirement: Optional[base.Requirement]
 8793    ) -> None:
 8794        """
 8795        Updates the requirement for a specific transition in a specific
 8796        decision. Use `None` to remove the requirement for that edge.
 8797        """
 8798        if requirement is None:
 8799            requirement = base.ReqNothing()
 8800        self.getSituation().graph.setTransitionRequirement(
 8801            decision,
 8802            transition,
 8803            requirement
 8804        )
 8805
 8806    def isTraversable(
 8807        self,
 8808        decision: base.AnyDecisionSpecifier,
 8809        transition: base.Transition,
 8810        step: int = -1
 8811    ) -> bool:
 8812        """
 8813        Returns True if the specified transition from the specified
 8814        decision had its requirement satisfied by the game state at the
 8815        specified step (or at the current step if no step is specified).
 8816        Raises an `IndexError` if the specified step doesn't exist, and
 8817        a `KeyError` if the decision or transition specified does not
 8818        exist in the `DecisionGraph` at that step.
 8819        """
 8820        situation = self.getSituation(step)
 8821        req = situation.graph.getTransitionRequirement(decision, transition)
 8822        ctx = base.contextForTransition(situation, decision, transition)
 8823        fromID = situation.graph.resolveDecision(decision)
 8824        return (
 8825            req.satisfied(ctx)
 8826        and (fromID, transition) not in situation.state['deactivated']
 8827        )
 8828
 8829    def applyTransitionEffect(
 8830        self,
 8831        whichEffect: base.EffectSpecifier,
 8832        moveWhich: Optional[base.FocalPointName] = None
 8833    ) -> Optional[base.DecisionID]:
 8834        """
 8835        Applies an effect attached to a transition, taking charges and
 8836        delay into account based on the current `Situation`.
 8837        Modifies the effect's trigger count (but may not actually
 8838        trigger the effect if the charges and/or delay values indicate
 8839        not to; see `base.doTriggerEffect`).
 8840
 8841        If a specific focal point in a plural-focalized domain is
 8842        triggering the effect, the focal point name should be specified
 8843        via `moveWhich` so that goto `Effect`s can know which focal
 8844        point to move when it's not explicitly specified in the effect.
 8845        TODO: Test this!
 8846
 8847        Returns None most of the time, but if a 'goto', 'bounce', or
 8848        'follow' effect was applied, it returns the decision ID for that
 8849        effect's destination, which would override a transition's normal
 8850        destination. If it returns a destination ID, then the exploration
 8851        state will already have been updated to set the position there,
 8852        and further position updates are not needed.
 8853
 8854        Note that transition effects which update active decisions will
 8855        also update the exploration status of those decisions to
 8856        'exploring' if they had been in an unvisited status (see
 8857        `updatePosition` and `hasBeenVisited`).
 8858
 8859        Note: callers should immediately update situation-based variables
 8860        that might have been changes by a 'revert' effect.
 8861        """
 8862        now = self.getSituation()
 8863        effect, triggerCount = base.doTriggerEffect(
 8864            now.state,
 8865            now.graph,
 8866            whichEffect
 8867        )
 8868        if triggerCount is not None:
 8869            return self.applyExtraneousEffect(
 8870                effect,
 8871                where=whichEffect[:2],
 8872                moveWhich=moveWhich
 8873            )
 8874        else:
 8875            return None
 8876
 8877    def applyExtraneousEffect(
 8878        self,
 8879        effect: base.Effect,
 8880        where: Optional[
 8881            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]]
 8882        ] = None,
 8883        moveWhich: Optional[base.FocalPointName] = None,
 8884        challengePolicy: base.ChallengePolicy = "specified"
 8885    ) -> Optional[base.DecisionID]:
 8886        """
 8887        Applies a single extraneous effect to the state & graph,
 8888        *without* accounting for charges or delay values, since the
 8889        effect is not part of the graph (use `applyTransitionEffect` to
 8890        apply effects that are attached to transitions, which is almost
 8891        always the function you should be using). An associated
 8892        transition for the extraneous effect can be supplied using the
 8893        `where` argument, and effects like 'deactivate' and 'edit' will
 8894        affect it (but the effect's charges and delay values will still
 8895        be ignored).
 8896
 8897        If the effect would change the destination of a transition, the
 8898        altered destination ID is returned: 'bounce' effects return the
 8899        provided decision part of `where`, 'goto' effects return their
 8900        target, and 'follow' effects return the destination followed to
 8901        (possibly via chained follows in the extreme case). In all other
 8902        cases, `None` is returned indicating no change to a normal
 8903        destination.
 8904
 8905        If a specific focal point in a plural-focalized domain is
 8906        triggering the effect, the focal point name should be specified
 8907        via `moveWhich` so that goto `Effect`s can know which focal
 8908        point to move when it's not explicitly specified in the effect.
 8909        TODO: Test this!
 8910
 8911        Note that transition effects which update active decisions will
 8912        also update the exploration status of those decisions to
 8913        'exploring' if they had been in an unvisited status and will
 8914        remove any 'unconfirmed' tag they might still have (see
 8915        `updatePosition` and `hasBeenVisited`).
 8916
 8917        The given `challengePolicy` is applied when traversing further
 8918        transitions due to 'follow' effects.
 8919
 8920        Note: Anyone calling `applyExtraneousEffect` should update any
 8921        situation-based variables immediately after the call, as a
 8922        'revert' effect may have changed the current graph and/or state.
 8923        """
 8924        typ = effect['type']
 8925        value = effect['value']
 8926        applyTo = effect['applyTo']
 8927        inCommon = applyTo == 'common'
 8928
 8929        now = self.getSituation()
 8930
 8931        if where is not None:
 8932            if where[1] is not None:
 8933                searchFrom = now.graph.bothEnds(where[0], where[1])
 8934            else:
 8935                searchFrom = {now.graph.resolveDecision(where[0])}
 8936        else:
 8937            searchFrom = None
 8938
 8939        # Note: Delay and charges are ignored!
 8940
 8941        # If it's a simple effect, we can use
 8942        # `base.applySimpleEffectToState` to apply it:
 8943        if base.isSimple(effect):
 8944            # TODO: NOT THIS, since it applies only simple effects of
 8945            # followed transitions!!!
 8946            return base.applySimpleEffectToState(
 8947                now.state,
 8948                now.graph,
 8949                effect,
 8950                where,
 8951                moveWhich,
 8952                challengePolicy
 8953            )
 8954        else:
 8955            # TODO: HERE
 8956            if typ == "edit":
 8957                value = cast(List[List[commands.Command]], value)
 8958                # If there are no blocks, do nothing
 8959                if len(value) > 0:
 8960                    # Apply the first block of commands and then rotate the list
 8961                    scope: commands.Scope = {}
 8962                    if where is not None:
 8963                        here: base.DecisionID = now.graph.resolveDecision(
 8964                            where[0]
 8965                        )
 8966                        outwards: Optional[base.Transition] = where[1]
 8967                        scope['@'] = here
 8968                        scope['@t'] = outwards
 8969                        if outwards is not None:
 8970                            reciprocal = now.graph.getReciprocal(
 8971                                here,
 8972                                outwards
 8973                            )
 8974                            destination = now.graph.getDestination(
 8975                                here,
 8976                                outwards
 8977                            )
 8978                        else:
 8979                            reciprocal = None
 8980                            destination = None
 8981                        scope['@r'] = reciprocal
 8982                        scope['@d'] = destination
 8983                    self.runCommandBlock(value[0], scope)
 8984                    value.append(value.pop(0))
 8985
 8986            elif typ == "follow":
 8987                # TODO: Maybe this should remain a non-complex effect?
 8988                if applyTo == "both":
 8989                    raise ValueError(
 8990                        "Can't follow a transition in both common & active"
 8991                        " focal contexts."
 8992                    )
 8993
 8994                if where is None:
 8995                    raise ValueError(
 8996                        f"Can't follow transition {value!r} because there"
 8997                        f" is no position information when applying the"
 8998                        f" effect."
 8999                    )
 9000
 9001                if where[1] is not None:
 9002                    followFrom = now.graph.getDestination(where[0], where[1])
 9003                    if followFrom is None:
 9004                        raise ValueError(
 9005                            f"Can't follow transition {value!r} because the"
 9006                            f" position information specifies transition"
 9007                            f" {where[1]!r} from decision"
 9008                            f" {now.graph.identityOf(where[0])} but that"
 9009                            f" transition does not exist."
 9010                        )
 9011
 9012                else:
 9013                    followFrom = now.graph.resolveDecision(where[0])
 9014
 9015                following = cast(base.Transition, value)
 9016
 9017                followTo = now.graph.getDestination(followFrom, following)
 9018
 9019                if followTo is None:
 9020                    raise ValueError(
 9021                        f"Can't follow transition {following!r} because"
 9022                        f" that transition doesn't exist at the specified"
 9023                        f" destination {now.graph.identityOf(followFrom)}."
 9024                    )
 9025
 9026                if self.isTraversable(followFrom, following):  # skip if not
 9027                    # Perform initial position update before following new
 9028                    # transition:
 9029                    base.updatePosition(
 9030                        now.state,
 9031                        now.graph,
 9032                        followFrom,
 9033                        applyTo,
 9034                        moveWhich
 9035                    )
 9036
 9037                    # Apply consequences of followed transition
 9038                    fullFollowTo = self.applyTransitionConsequence(
 9039                        followFrom,
 9040                        following,
 9041                        moveWhich,
 9042                        challengePolicy
 9043                    )
 9044
 9045                    # Now update to end of followed transition
 9046                    if fullFollowTo is None:
 9047                        base.updatePosition(
 9048                            now.state,
 9049                            now.graph,
 9050                            followTo,
 9051                            applyTo,
 9052                            moveWhich
 9053                        )
 9054                        fullFollowTo = followTo
 9055
 9056                    # Skip the normal update: we've taken care of that
 9057                    # plus more
 9058                    return fullFollowTo
 9059                else:
 9060                    # Normal position updates still applies since follow
 9061                    # transition wasn't possible
 9062                    return None
 9063
 9064            elif typ == "save":
 9065                assert isinstance(value, base.SaveSlot)
 9066                now.saves[value] = copy.deepcopy((now.graph, now.state))
 9067
 9068            else:
 9069                raise ValueError(f"Invalid effect type {typ!r}.")
 9070
 9071        return None  # default return value if we didn't return above
 9072
 9073    def applyExtraneousConsequence(
 9074        self,
 9075        consequence: base.Consequence,
 9076        where: Optional[
 9077            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]]
 9078        ] = None,
 9079        moveWhich: Optional[base.FocalPointName] = None
 9080    ) -> Optional[base.DecisionID]:
 9081        """
 9082        Applies an extraneous consequence not associated with a
 9083        transition. Unlike `applyTransitionConsequence`, the provided
 9084        `base.Consequence` must already have observed outcomes (see
 9085        `base.observeChallengeOutcomes`). Returns the decision ID for a
 9086        decision implied by a goto, follow, or bounce effect, or `None`
 9087        if no effect implies a destination.
 9088
 9089        The `where` and `moveWhich` optional arguments specify which
 9090        decision and/or transition to use as the application position,
 9091        and/or which focal point to move. This affects mechanism lookup
 9092        as well as the end position when 'follow' effects are used.
 9093        Specifically:
 9094
 9095        - A 'follow' trigger will search for transitions to follow from
 9096            the destination of the specified transition, or if only a
 9097            decision was supplied, from that decision.
 9098        - Mechanism lookups will start with both ends of the specified
 9099            transition as their search field (or with just the specified
 9100            decision if no transition is included).
 9101
 9102        'bounce' effects will cause an error unless position information
 9103        is provided, and will set the position to the base decision
 9104        provided in `where`.
 9105
 9106        Note: callers should update any situation-based variables
 9107        immediately after calling this as a 'revert' effect could change
 9108        the current graph and/or state and other changes could get lost
 9109        if they get applied to a stale graph/state.
 9110
 9111        # TODO: Examples for goto and follow effects.
 9112        """
 9113        now = self.getSituation()
 9114        searchFrom = set()
 9115        if where is not None:
 9116            if where[1] is not None:
 9117                searchFrom = now.graph.bothEnds(where[0], where[1])
 9118            else:
 9119                searchFrom = {now.graph.resolveDecision(where[0])}
 9120
 9121        context = base.RequirementContext(
 9122            state=now.state,
 9123            graph=now.graph,
 9124            searchFrom=searchFrom
 9125        )
 9126
 9127        effectIndices = base.observedEffects(context, consequence)
 9128        destID = None
 9129        for index in effectIndices:
 9130            effect = base.consequencePart(consequence, index)
 9131            if not isinstance(effect, dict) or 'value' not in effect:
 9132                raise RuntimeError(
 9133                    f"Invalid effect index {index}: Consequence part at"
 9134                    f" that index is not an Effect. Got:\n{effect}"
 9135                )
 9136            effect = cast(base.Effect, effect)
 9137            destID = self.applyExtraneousEffect(
 9138                effect,
 9139                where,
 9140                moveWhich
 9141            )
 9142            # technically this variable is not used later in this
 9143            # function, but the `applyExtraneousEffect` call means it
 9144            # needs an update, so we're doing that in case someone later
 9145            # adds code to this function that uses 'now' after this
 9146            # point.
 9147            now = self.getSituation()
 9148
 9149        return destID
 9150
 9151    def applyTransitionConsequence(
 9152        self,
 9153        decision: base.AnyDecisionSpecifier,
 9154        transition: base.AnyTransition,
 9155        moveWhich: Optional[base.FocalPointName] = None,
 9156        policy: base.ChallengePolicy = "specified",
 9157        fromIndex: Optional[int] = None,
 9158        toIndex: Optional[int] = None
 9159    ) -> Optional[base.DecisionID]:
 9160        """
 9161        Applies the effects of the specified transition to the current
 9162        graph and state, possibly overriding observed outcomes using
 9163        outcomes specified as part of a `base.TransitionWithOutcomes`.
 9164
 9165        The `where` and `moveWhich` function serve the same purpose as
 9166        for `applyExtraneousEffect`. If `where` is `None`, then the
 9167        effects will be applied as extraneous effects, meaning that
 9168        their delay and charges values will be ignored and their trigger
 9169        count will not be tracked. If `where` is supplied
 9170
 9171        Returns either None to indicate that the position update for the
 9172        transition should apply as usual, or a decision ID indicating
 9173        another destination which has already been applied by a
 9174        transition effect.
 9175
 9176        If `fromIndex` and/or `toIndex` are specified, then only effects
 9177        which have indices between those two (inclusive) will be
 9178        applied, and other effects will neither apply nor be updated in
 9179        any way. Note that `onlyPart` does not override the challenge
 9180        policy: if the effects in the specified part are not applied due
 9181        to a challenge outcome, they still won't happen, including
 9182        challenge outcomes outside of that part. Also, outcomes for
 9183        challenges of the entire consequence are re-observed if the
 9184        challenge policy implies it.
 9185
 9186        Note: Anyone calling this should update any situation-based
 9187        variables immediately after the call, as a 'revert' effect may
 9188        have changed the current graph and/or state.
 9189        """
 9190        now = self.getSituation()
 9191        dID = now.graph.resolveDecision(decision)
 9192
 9193        transitionName, outcomes = base.nameAndOutcomes(transition)
 9194
 9195        searchFrom = set()
 9196        searchFrom = now.graph.bothEnds(dID, transitionName)
 9197
 9198        context = base.RequirementContext(
 9199            state=now.state,
 9200            graph=now.graph,
 9201            searchFrom=searchFrom
 9202        )
 9203
 9204        consequence = now.graph.getConsequence(dID, transitionName)
 9205
 9206        # Make sure that challenge outcomes are known
 9207        if policy != "specified":
 9208            base.resetChallengeOutcomes(consequence)
 9209        useUp = outcomes[:]
 9210        base.observeChallengeOutcomes(
 9211            context,
 9212            consequence,
 9213            location=searchFrom,
 9214            policy=policy,
 9215            knownOutcomes=useUp
 9216        )
 9217        if len(useUp) > 0:
 9218            raise ValueError(
 9219                f"More outcomes specified than challenges observed in"
 9220                f" consequence:\n{consequence}"
 9221                f"\nRemaining outcomes:\n{useUp}"
 9222            )
 9223
 9224        # Figure out which effects apply, and apply each of them
 9225        effectIndices = base.observedEffects(context, consequence)
 9226        if fromIndex is None:
 9227            fromIndex = 0
 9228
 9229        altDest = None
 9230        for index in effectIndices:
 9231            if (
 9232                index >= fromIndex
 9233            and (toIndex is None or index <= toIndex)
 9234            ):
 9235                thisDest = self.applyTransitionEffect(
 9236                    (dID, transitionName, index),
 9237                    moveWhich
 9238                )
 9239                if thisDest is not None:
 9240                    altDest = thisDest
 9241                # TODO: What if this updates state with 'revert' to a
 9242                # graph that doesn't contain the same effects?
 9243                # TODO: Update 'now' and 'context'?!
 9244        return altDest
 9245
 9246    def allDecisions(self) -> List[base.DecisionID]:
 9247        """
 9248        Returns the list of all decisions which existed at any point
 9249        within the exploration. Example:
 9250
 9251        >>> ex = DiscreteExploration()
 9252        >>> ex.start('A')
 9253        0
 9254        >>> ex.observe('A', 'right')
 9255        1
 9256        >>> ex.explore('right', 'B', 'left')
 9257        1
 9258        >>> ex.observe('B', 'right')
 9259        2
 9260        >>> ex.allDecisions()  # 'A', 'B', and the unnamed 'right of B'
 9261        [0, 1, 2]
 9262        """
 9263        seen = set()
 9264        result = []
 9265        for situation in self:
 9266            for decision in situation.graph:
 9267                if decision not in seen:
 9268                    result.append(decision)
 9269                    seen.add(decision)
 9270
 9271        return result
 9272
 9273    def allExploredDecisions(self) -> List[base.DecisionID]:
 9274        """
 9275        Returns the list of all decisions which existed at any point
 9276        within the exploration, excluding decisions whose highest
 9277        exploration status was `noticed` or lower. May still include
 9278        decisions which don't exist in the final situation's graph due to
 9279        things like decision merging. Example:
 9280
 9281        >>> ex = DiscreteExploration()
 9282        >>> idA = ex.start('A')
 9283        >>> idB = ex.observe('A', 'right')
 9284        >>> ex.explore('right', 'B', 'left') == idB
 9285        True
 9286        >>> idU = ex.observe('B', 'right')
 9287        >>> graph = ex.getSituation().graph
 9288        >>> idC = graph.addDecision('C')  # add isolated decision;
 9289        >>>                               # doesn't set status
 9290        >>> ex.hasBeenVisited('C')
 9291        False
 9292        >>> ex.allExploredDecisions() == [idA, idB]
 9293        True
 9294        >>> ex.setExplorationStatus('C', 'exploring')
 9295        >>> ex.allExploredDecisions() == [idA, idB, idC]
 9296        True
 9297        >>> ex.setExplorationStatus('A', 'explored')
 9298        >>> ex.allExploredDecisions() == [idA, idB, idC]
 9299        True
 9300        >>> ex.setExplorationStatus('A', 'unknown')
 9301        >>> # remains visisted in an earlier step
 9302        >>> ex.allExploredDecisions() == [idA, idB, idC]
 9303        True
 9304        >>> ex.setExplorationStatus('C', 'unknown')  # not explored earlier
 9305        >>> ex.allExploredDecisions() == [idA, idB]
 9306        True
 9307        """
 9308        seen = set()
 9309        result = []
 9310        for situation in self:
 9311            graph = situation.graph
 9312            for decision in graph:
 9313                if (
 9314                    decision not in seen
 9315                and base.hasBeenVisited(situation.state, decision)
 9316                ):
 9317                    result.append(decision)
 9318                    seen.add(decision)
 9319
 9320        return result
 9321
 9322    def allVisitedDecisions(self) -> List[base.DecisionID]:
 9323        """
 9324        Returns the list of all decisions which existed at any point
 9325        within the exploration and which were visited at least once.
 9326        Orders them in the same order they were visited in.
 9327
 9328        Usually all of these decisions will be present in the final
 9329        situation's graph, but sometimes merging or other factors means
 9330        there might be some that won't be. Being present on the game
 9331        state's 'active' list in a step for its domain is what counts as
 9332        "being visited," which means that nodes which were passed through
 9333        directly via a 'follow' effect won't be counted, for example.
 9334
 9335        This should usually correspond with the absence of the
 9336        'unconfirmed' tag.
 9337
 9338        Example:
 9339
 9340        >>> ex = DiscreteExploration()
 9341        >>> ex.start('A')
 9342        0
 9343        >>> ex.observe('A', 'right')
 9344        1
 9345        >>> ex.explore('right', 'B', 'left')
 9346        1
 9347        >>> ex.observe('B', 'right')
 9348        2
 9349        >>> ex.getSituation().graph.addDecision('C')  # add isolated decision
 9350        3
 9351        >>> av = ex.allVisitedDecisions()
 9352        >>> av
 9353        [0, 1]
 9354        >>> all(  # no decisions in the 'visited' list are tagged
 9355        ...     'unconfirmed' not in ex.getSituation().graph.decisionTags(d)
 9356        ...     for d in av
 9357        ... )
 9358        True
 9359        >>> graph = ex.getSituation().graph
 9360        >>> 'unconfirmed' in graph.decisionTags(0)
 9361        False
 9362        >>> 'unconfirmed' in graph.decisionTags(1)
 9363        False
 9364        >>> 'unconfirmed' in graph.decisionTags(2)
 9365        True
 9366        >>> 'unconfirmed' in graph.decisionTags(3)  # not tagged; not explored
 9367        False
 9368        """
 9369        seen = set()
 9370        result = []
 9371        for step in range(len(self)):
 9372            active = self.getActiveDecisions(step)
 9373            for dID in active:
 9374                if dID not in seen:
 9375                    result.append(dID)
 9376                    seen.add(dID)
 9377
 9378        return result
 9379
 9380    def allTransitions(self) -> List[
 9381        Tuple[base.DecisionID, base.Transition, base.DecisionID]
 9382    ]:
 9383        """
 9384        Returns the list of all transitions which existed at any point
 9385        within the exploration, as 3-tuples with source decision ID,
 9386        transition name, and destination decision ID. Note that since
 9387        transitions can be deleted or re-targeted, and a transition name
 9388        can be re-used after being deleted, things can get messy in the
 9389        edges cases (see `allFinalTransitions`). When the same transition
 9390        name is used in different steps with different decision targets,
 9391        we end up including each possible source-transition-destination
 9392        triple. Example:
 9393
 9394        >>> ex = DiscreteExploration()
 9395        >>> ex.start('A')
 9396        0
 9397        >>> ex.observe('A', 'right', None, 'return')
 9398        1
 9399        >>> ex.explore('right', 'B', 'left')
 9400        1
 9401        >>> ex.observe('B', 'right')
 9402        2
 9403        >>> ex.wait()  # leave behind a step where 'B' has a 'right'
 9404        >>> ex.primaryDecision(0)
 9405        >>> ex.primaryDecision(1)
 9406        0
 9407        >>> ex.primaryDecision(2)
 9408        1
 9409        >>> ex.primaryDecision(3)
 9410        1
 9411        >>> len(ex)
 9412        4
 9413        >>> ex[3].graph.removeDecision(2)  # delete 'right of B'
 9414        >>> ex.observe('B', 'down')
 9415        3
 9416        >>> # Decisions are: 'A', 'B', and the unnamed 'right of B'
 9417        >>> # (now-deleted), and the unnamed 'down from B'
 9418        >>> ex.allDecisions()
 9419        [0, 1, 2, 3]
 9420        >>> for tr in ex.allTransitions():
 9421        ...     print(tr)
 9422        ...
 9423        (0, 'right', 1)
 9424        (1, 'return', 0)
 9425        (1, 'left', 0)
 9426        (1, 'right', 2)
 9427        (1, 'down', 3)
 9428        >>> # Note transitions from now-deleted nodes, and 'return'
 9429        >>> # transitions for unexplored nodes before they get explored
 9430        """
 9431        seen = set()
 9432        result = []
 9433        for situation in self:
 9434            graph = situation.graph
 9435            for (src, dst, transition) in graph.allEdges():  # type:ignore
 9436                trans = (src, transition, dst)
 9437                if trans not in seen:
 9438                    result.append(trans)
 9439                    seen.add(trans)
 9440
 9441        return result
 9442
 9443    def allFinalTransitions(self) -> List[
 9444        Tuple[base.DecisionID, base.Transition]
 9445    ]:
 9446        """
 9447        Returns the list of all transitions which exist in the final
 9448        situation's graph, as 2-tuples of source decision ID and
 9449        transition name. Compare `allTransitions` which tracks all
 9450        transitions that existed at any point in the exploration.
 9451        
 9452        Example:
 9453
 9454        >>> ex = DiscreteExploration()
 9455        >>> ex.start('A')
 9456        0
 9457        >>> ex.observe('A', 'right', None, 'return')
 9458        1
 9459        >>> ex.explore('right', 'B', 'left')
 9460        1
 9461        >>> ex.observe('B', 'right')
 9462        2
 9463        >>> ex.wait()  # leave behind a step where 'B' has a 'right'
 9464        >>> ex.primaryDecision(0)
 9465        >>> ex.primaryDecision(1)
 9466        0
 9467        >>> ex.primaryDecision(2)
 9468        1
 9469        >>> ex.primaryDecision(3)
 9470        1
 9471        >>> len(ex)
 9472        4
 9473        >>> ex[3].graph.removeDecision(2)  # delete 'right of B'
 9474        >>> ex.observe('B', 'down')
 9475        3
 9476        >>> # Decisions are: 'A', 'B', and the unnamed 'right of B'
 9477        >>> # (now-deleted), and the unnamed 'down from B'
 9478        >>> ex.allDecisions()
 9479        [0, 1, 2, 3]
 9480        >>> for tr in ex.allFinalTransitions():
 9481        ...     print(tr)
 9482        ...
 9483        (0, 'right')
 9484        (1, 'left')
 9485        (1, 'down')
 9486        >>> # Note only transitions present in final graph
 9487        """
 9488        if len(self) == 0:
 9489            return []
 9490        graph = self[-1].graph;
 9491        result = []
 9492        seen = set()
 9493        for (src, dst, transition) in graph.allEdges():  # type:ignore
 9494            trans = (src, transition)
 9495            if trans not in seen:
 9496                result.append(trans)
 9497                seen.add(trans)
 9498
 9499        return result
 9500
 9501    def start(
 9502        self,
 9503        decision: base.AnyDecisionSpecifier,
 9504        startCapabilities: Optional[base.CapabilitySet] = None,
 9505        setMechanismStates: Optional[
 9506            Dict[base.MechanismID, base.MechanismState]
 9507        ] = None,
 9508        setCustomState: Optional[dict] = None,
 9509        decisionType: base.DecisionType = "imposed"
 9510    ) -> base.DecisionID:
 9511        """
 9512        Sets the initial position information for a newly-relevant
 9513        domain for the current focal context. Creates a new decision
 9514        if the decision is specified by name or `DecisionSpecifier` and
 9515        that decision doesn't already exist. Returns the decision ID for
 9516        the newly-placed decision (or for the specified decision if it
 9517        already existed).
 9518
 9519        Raises a `BadStart` error if the current focal context already
 9520        has position information for the specified domain.
 9521
 9522        - The given `startCapabilities` replaces any existing
 9523            capabilities for the current focal context, although you can
 9524            leave it as the default `None` to avoid that and retain any
 9525            capabilities that have been set up already.
 9526        - The given `setMechanismStates` and `setCustomState`
 9527            dictionaries override all previous mechanism states & custom
 9528            states in the new situation. Leave these as the default
 9529            `None` to maintain those states.
 9530        - If created, the decision will be placed in the DEFAULT_DOMAIN
 9531            domain unless it's specified as a `base.DecisionSpecifier`
 9532            with a domain part, in which case that domain is used.
 9533        - If specified as a `base.DecisionSpecifier` with a zone part
 9534            and a new decision needs to be created, the decision will be
 9535            added to that zone, creating it at level 0 if necessary,
 9536            although otherwise no zone information will be changed.
 9537        - Resets the decision type to "pending" and the action taken to
 9538            `None`. Sets the decision type of the previous situation to
 9539            'imposed' (or the specified `decisionType`) and sets an
 9540            appropriate 'start' action for that situation.
 9541        - Tags the step with 'start'.
 9542        - Even in a plural- or spreading-focalized domain, you still need
 9543            to pick one decision to start at.
 9544        """
 9545        now = self.getSituation()
 9546
 9547        startID = now.graph.getDecision(decision)
 9548        zone = None
 9549        domain = base.DEFAULT_DOMAIN
 9550        if startID is None:
 9551            if isinstance(decision, base.DecisionID):
 9552                raise MissingDecisionError(
 9553                    f"Cannot start at decision {decision} because no"
 9554                    f" decision with that ID exists. Supply a name or"
 9555                    f" DecisionSpecifier if you need the start decision"
 9556                    f" to be created automatically."
 9557                )
 9558            elif isinstance(decision, base.DecisionName):
 9559                decision = base.DecisionSpecifier(
 9560                    domain=None,
 9561                    zone=None,
 9562                    name=decision
 9563                )
 9564            startID = now.graph.addDecision(
 9565                decision.name,
 9566                domain=decision.domain
 9567            )
 9568            zone = decision.zone
 9569            if decision.domain is not None:
 9570                domain = decision.domain
 9571
 9572        if zone is not None:
 9573            if now.graph.getZoneInfo(zone) is None:
 9574                now.graph.createZone(zone, 0)
 9575            now.graph.addDecisionToZone(startID, zone)
 9576
 9577        action: base.ExplorationAction = (
 9578            'start',
 9579            startID,
 9580            startID,
 9581            domain,
 9582            startCapabilities,
 9583            setMechanismStates,
 9584            setCustomState
 9585        )
 9586
 9587        self.advanceSituation(action, decisionType)
 9588
 9589        return startID
 9590
 9591    def hasBeenVisited(
 9592        self,
 9593        decision: base.AnyDecisionSpecifier,
 9594        step: int = -1
 9595    ):
 9596        """
 9597        Returns whether or not the specified decision has been visited in
 9598        or prior to the specified step (default current step).
 9599        """
 9600        situation = self.getSituation(step)
 9601        return base.hasBeenVisited(
 9602            situation.state,
 9603            situation.graph.resolveDecision(decision)
 9604        )
 9605
 9606    def setExplorationStatus(
 9607        self,
 9608        decision: base.AnyDecisionSpecifier,
 9609        status: base.ExplorationStatus,
 9610        upgradeOnly: bool = False
 9611    ):
 9612        """
 9613        Updates the current exploration status of a specific decision in
 9614        the current situation. If `upgradeOnly` is true (default is
 9615        `False` then the update will only apply if the new exploration
 9616        status counts as 'more-explored' than the old one (see
 9617        `base.moreExplored`).
 9618        """
 9619        now = self.getSituation()
 9620        base.setExplorationStatus(
 9621            now.state,
 9622            now.graph.resolveDecision(decision),
 9623            status,
 9624            upgradeOnly
 9625        )
 9626
 9627    def getExplorationStatus(
 9628        self,
 9629        decision: base.AnyDecisionSpecifier,
 9630        step: int = -1
 9631    ):
 9632        """
 9633        Returns the exploration status of the specified decision at the
 9634        specified step (default is last step). Decisions whose
 9635        exploration status has never been set will have a default status
 9636        of 'unknown'.
 9637        """
 9638        situation = self.getSituation(step)
 9639        dID = situation.graph.resolveDecision(decision)
 9640        return base.explorationStatusOf(
 9641            situation.state,
 9642            dID,
 9643            default='unknown'
 9644        )
 9645
 9646    def deduceTransitionDetailsAtStep(
 9647        self,
 9648        step: int,
 9649        transition: base.Transition,
 9650        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
 9651        whichFocus: Optional[base.FocalPointSpecifier] = None,
 9652        inCommon: Union[bool, Literal["auto"]] = "auto"
 9653    ) -> Tuple[
 9654        base.ContextSpecifier,
 9655        base.DecisionID,
 9656        base.DecisionID,
 9657        Optional[base.FocalPointSpecifier]
 9658    ]:
 9659        """
 9660        Given just a transition name which the player intends to take in
 9661        a specific step, deduces the `ContextSpecifier` for which
 9662        context should be updated, the source and destination
 9663        `DecisionID`s for the transition, and if the destination
 9664        decision's domain is plural-focalized, the `FocalPointName`
 9665        specifying which focal point should be moved.
 9666
 9667        Because many of those things are ambiguous, you may get an
 9668        `AmbiguousTransitionError` when things are underspecified, and
 9669        there are options for specifying some of the extra information
 9670        directly:
 9671
 9672        - `fromDecision` may be used to specify the source decision.
 9673        - `whichFocus` may be used to specify the focal point (within a
 9674            particular context/domain) being updated. When focal point
 9675            ambiguity remains and this is unspecified, the
 9676            alphabetically-earliest relevant focal point will be used
 9677            (either among all focal points which activate the source
 9678            decision, if there are any, or among all focal points for
 9679            the entire domain of the destination decision).
 9680        - `inCommon` (a `ContextSpecifier`) may be used to specify which
 9681            context to update. The default of "auto" will cause the
 9682            active context to be selected unless it does not activate
 9683            the source decision, in which case the common context will
 9684            be selected.
 9685
 9686        A `MissingDecisionError` will be raised if there are no current
 9687        active decisions (e.g., before `start` has been called), and a
 9688        `MissingTransitionError` will be raised if the listed transition
 9689        does not exist from any active decision (or from the specified
 9690        decision if `fromDecision` is used).
 9691        """
 9692        now = self.getSituation(step)
 9693        active = self.getActiveDecisions(step)
 9694        if len(active) == 0:
 9695            raise MissingDecisionError(
 9696                f"There are no active decisions from which transition"
 9697                f" {repr(transition)} could be taken at step {step}."
 9698            )
 9699
 9700        # All source/destination decision pairs for transitions with the
 9701        # given transition name.
 9702        allDecisionPairs: Dict[base.DecisionID, base.DecisionID] = {}
 9703
 9704        # TODO: When should we be trimming the active decisions to match
 9705        # any alterations to the graph?
 9706        for dID in active:
 9707            outgoing = now.graph.destinationsFrom(dID)
 9708            if transition in outgoing:
 9709                allDecisionPairs[dID] = outgoing[transition]
 9710
 9711        if len(allDecisionPairs) == 0:
 9712            raise MissingTransitionError(
 9713                f"No transitions named {repr(transition)} are outgoing"
 9714                f" from active decisions at step {step}."
 9715                f"\nActive decisions are:"
 9716                f"\n{now.graph.namesListing(active)}"
 9717            )
 9718
 9719        if (
 9720            fromDecision is not None
 9721        and fromDecision not in allDecisionPairs
 9722        ):
 9723            raise MissingTransitionError(
 9724                f"{fromDecision} was specified as the source decision"
 9725                f" for traversing transition {repr(transition)} but"
 9726                f" there is no transition of that name from that"
 9727                f" decision at step {step}."
 9728                f"\nValid source decisions are:"
 9729                f"\n{now.graph.namesListing(allDecisionPairs)}"
 9730            )
 9731        elif fromDecision is not None:
 9732            fromID = now.graph.resolveDecision(fromDecision)
 9733            destID = allDecisionPairs[fromID]
 9734            fromDomain = now.graph.domainFor(fromID)
 9735        elif len(allDecisionPairs) == 1:
 9736            fromID, destID = list(allDecisionPairs.items())[0]
 9737            fromDomain = now.graph.domainFor(fromID)
 9738        else:
 9739            fromID = None
 9740            destID = None
 9741            fromDomain = None
 9742            # Still ambiguous; resolve this below
 9743
 9744        # Use whichFocus if provided
 9745        if whichFocus is not None:
 9746            # Type/value check for whichFocus
 9747            if (
 9748                not isinstance(whichFocus, tuple)
 9749             or len(whichFocus) != 3
 9750             or whichFocus[0] not in ("active", "common")
 9751             or not isinstance(whichFocus[1], base.Domain)
 9752             or not isinstance(whichFocus[2], base.FocalPointName)
 9753            ):
 9754                raise ValueError(
 9755                    f"Invalid whichFocus value {repr(whichFocus)}."
 9756                    f"\nMust be a length-3 tuple with 'active' or 'common'"
 9757                    f" as the first element, a Domain as the second"
 9758                    f" element, and a FocalPointName as the third"
 9759                    f" element."
 9760                )
 9761
 9762            # Resolve focal point specified
 9763            fromID = base.resolvePosition(
 9764                now.state,
 9765                whichFocus
 9766            )
 9767            if fromID is None:
 9768                raise MissingTransitionError(
 9769                    f"Focal point {repr(whichFocus)} was specified as"
 9770                    f" the transition source, but that focal point does"
 9771                    f" not have a position."
 9772                )
 9773            else:
 9774                destID = now.graph.destination(fromID, transition)
 9775                fromDomain = now.graph.domainFor(fromID)
 9776
 9777        elif fromID is None:  # whichFocus is None, so it can't disambiguate
 9778            raise AmbiguousTransitionError(
 9779                f"Transition {repr(transition)} was selected for"
 9780                f" disambiguation, but there are multiple transitions"
 9781                f" with that name from currently-active decisions, and"
 9782                f" neither fromDecision nor whichFocus adequately"
 9783                f" disambiguates the specific transition taken."
 9784                f"\nValid source decisions at step {step} are:"
 9785                f"\n{now.graph.namesListing(allDecisionPairs)}"
 9786            )
 9787
 9788        # At this point, fromID, destID, and fromDomain have
 9789        # been resolved.
 9790        if fromID is None or destID is None or fromDomain is None:
 9791            raise RuntimeError(
 9792                f"One of fromID, destID, or fromDomain was None after"
 9793                f" disambiguation was finished:"
 9794                f"\nfromID: {fromID}, destID: {destID}, fromDomain:"
 9795                f" {repr(fromDomain)}"
 9796            )
 9797
 9798        # Now figure out which context activated the source so we know
 9799        # which focal point we're moving:
 9800        context = self.getActiveContext()
 9801        active = base.activeDecisionSet(context)
 9802        using: base.ContextSpecifier = "active"
 9803        if fromID not in active:
 9804            context = self.getCommonContext(step)
 9805            using = "common"
 9806
 9807        destDomain = now.graph.domainFor(destID)
 9808        if (
 9809            whichFocus is None
 9810        and base.getDomainFocalization(context, destDomain) == 'plural'
 9811        ):
 9812            # Need to figure out which focal point is moving; use the
 9813            # alphabetically earliest one that's positioned at the
 9814            # fromID, or just the earliest one overall if none of them
 9815            # are there.
 9816            contextFocalPoints: Dict[
 9817                base.FocalPointName,
 9818                Optional[base.DecisionID]
 9819            ] = cast(
 9820                Dict[base.FocalPointName, Optional[base.DecisionID]],
 9821                context['activeDecisions'][destDomain]
 9822            )
 9823            if not isinstance(contextFocalPoints, dict):
 9824                raise RuntimeError(
 9825                    f"Active decisions specifier for domain"
 9826                    f" {repr(destDomain)} with plural focalization has"
 9827                    f" a non-dictionary value."
 9828                )
 9829
 9830            if fromDomain == destDomain:
 9831                focalCandidates = [
 9832                    fp
 9833                    for fp, pos in contextFocalPoints.items()
 9834                    if pos == fromID
 9835                ]
 9836            else:
 9837                focalCandidates = list(contextFocalPoints)
 9838
 9839            whichFocus = (using, destDomain, min(focalCandidates))
 9840
 9841        # Now whichFocus has been set if it wasn't already specified;
 9842        # might still be None if it's not relevant.
 9843        return (using, fromID, destID, whichFocus)
 9844
 9845    def advanceSituation(
 9846        self,
 9847        action: base.ExplorationAction,
 9848        decisionType: base.DecisionType = "active",
 9849        challengePolicy: base.ChallengePolicy = "specified"
 9850    ) -> Tuple[base.Situation, Set[base.DecisionID]]:
 9851        """
 9852        Given an `ExplorationAction`, sets that as the action taken in
 9853        the current situation, and adds a new situation with the results
 9854        of that action. A `DoubleActionError` will be raised if the
 9855        current situation already has an action specified, and/or has a
 9856        decision type other than 'pending'. By default the type of the
 9857        decision will be 'active' but another `DecisionType` can be
 9858        specified via the `decisionType` parameter.
 9859
 9860        If the action specified is `('noAction',)`, then the new
 9861        situation will be a copy of the old one; this represents waiting
 9862        or being at an ending (a decision type other than 'pending'
 9863        should be used).
 9864
 9865        Although `None` can appear as the action entry in situations
 9866        with pending decisions, you cannot call `advanceSituation` with
 9867        `None` as the action.
 9868
 9869        If the action includes taking a transition whose requirements
 9870        are not satisfied, the transition will still be taken (and any
 9871        consequences applied) but a `TransitionBlockedWarning` will be
 9872        issued.
 9873
 9874        A `ChallengePolicy` may be specified, the default is 'specified'
 9875        which requires that outcomes are pre-specified. If any other
 9876        policy is set, the challenge outcomes will be reset before
 9877        re-resolving them according to the provided policy.
 9878
 9879        The new situation will have decision type 'pending' and `None`
 9880        as the action.
 9881
 9882        The new situation created as a result of the action is returned,
 9883        along with the set of destination decision IDs, including
 9884        possibly a modified destination via 'bounce', 'goto', and/or
 9885        'follow' effects. For actions that don't have a destination, the
 9886        second part of the returned tuple will be an empty set. Multiple
 9887        IDs may be in the set when using a start action in a plural- or
 9888        spreading-focalized domain, for example.
 9889
 9890        If the action updates active decisions (including via transition
 9891        effects) this will also update the exploration status of those
 9892        decisions to 'exploring' if they had been in an unvisited
 9893        status (see `updatePosition` and `hasBeenVisited`). This
 9894        includes decisions traveled through but not ultimately arrived
 9895        at via 'follow' effects. These will also lose any 'unconfirmed'
 9896        tags they might have had.
 9897
 9898        If any decisions are active in the `ENDINGS_DOMAIN`, attempting
 9899        to 'warp', 'explore', 'take', or 'start' will raise an
 9900        `InvalidActionError`.
 9901        """
 9902        now = self.getSituation()
 9903        if now.type != 'pending' or now.action is not None:
 9904            raise DoubleActionError(
 9905                f"Attempted to take action {repr(action)} at step"
 9906                f" {len(self) - 1}, but an action and/or decision type"
 9907                f" had already been specified:"
 9908                f"\nAction: {repr(now.action)}"
 9909                f"\nType: {repr(now.type)}"
 9910            )
 9911
 9912        # Update the now situation to add in the decision type and
 9913        # action taken:
 9914        revised = base.Situation(
 9915            now.graph,
 9916            now.state,
 9917            decisionType,
 9918            action,
 9919            now.saves,
 9920            now.tags,
 9921            now.annotations
 9922        )
 9923        self.situations[-1] = revised
 9924
 9925        # Separate update process when reverting (this branch returns)
 9926        if (
 9927            action is not None
 9928        and isinstance(action, tuple)
 9929        and len(action) == 3
 9930        and action[0] == 'revertTo'
 9931        and isinstance(action[1], base.SaveSlot)
 9932        and isinstance(action[2], set)
 9933        and all(isinstance(x, str) for x in action[2])
 9934        ):
 9935            _, slot, aspects = action
 9936            if slot not in now.saves:
 9937                raise KeyError(
 9938                    f"Cannot load save slot {slot!r} because no save"
 9939                    f" data has been established for that slot."
 9940                )
 9941            load = now.saves[slot]
 9942            rGraph, rState = base.revertedState(
 9943                (now.graph, now.state),
 9944                load,
 9945                aspects
 9946            )
 9947            reverted = base.Situation(
 9948                graph=rGraph,
 9949                state=rState,
 9950                type='pending',
 9951                action=None,
 9952                saves=copy.deepcopy(now.saves),
 9953                tags={},
 9954                annotations=[]
 9955            )
 9956            self.situations.append(reverted)
 9957            # Apply any active triggers (edits reverted)
 9958            self.applyActiveTriggers()
 9959            # Figure out destinations set to return
 9960            newDestinations = set()
 9961            newPr = rState['primaryDecision']
 9962            if newPr is not None:
 9963                newDestinations.add(newPr)
 9964            return (reverted, newDestinations)
 9965
 9966        # TODO: These deep copies are expensive time-wise. Can we avoid
 9967        # them? Probably not.
 9968        newGraph = copy.deepcopy(now.graph)
 9969        newState = copy.deepcopy(now.state)
 9970        newSaves = copy.copy(now.saves)  # a shallow copy
 9971        newTags: Dict[base.Tag, base.TagValue] = {}
 9972        newAnnotations: List[base.Annotation] = []
 9973        updated = base.Situation(
 9974            graph=newGraph,
 9975            state=newState,
 9976            type='pending',
 9977            action=None,
 9978            saves=newSaves,
 9979            tags=newTags,
 9980            annotations=newAnnotations
 9981        )
 9982
 9983        targetContext: base.FocalContext
 9984
 9985        # Now that action effects have been imprinted into the updated
 9986        # situation, append it to our situations list
 9987        self.situations.append(updated)
 9988
 9989        # Figure out effects of the action:
 9990        if action is None:
 9991            raise InvalidActionError(
 9992                "None cannot be used as an action when advancing the"
 9993                " situation."
 9994            )
 9995
 9996        aLen = len(action)
 9997
 9998        destIDs = set()
 9999
10000        if (
10001            action[0] in ('start', 'take', 'explore', 'warp')
10002        and any(
10003                newGraph.domainFor(d) == ENDINGS_DOMAIN
10004                for d in self.getActiveDecisions()
10005            )
10006        ):
10007            activeEndings = [
10008                d
10009                for d in self.getActiveDecisions()
10010                if newGraph.domainFor(d) == ENDINGS_DOMAIN
10011            ]
10012            raise InvalidActionError(
10013                f"Attempted to {action[0]!r} while an ending was"
10014                f" active. Active endings are:"
10015                f"\n{newGraph.namesListing(activeEndings)}"
10016            )
10017
10018        if action == ('noAction',):
10019            # No updates needed
10020            pass
10021
10022        elif (
10023            not isinstance(action, tuple)
10024         or (action[0] not in get_args(base.ExplorationActionType))
10025         or not (2 <= aLen <= 7)
10026        ):
10027            raise InvalidActionError(
10028                f"Invalid ExplorationAction tuple (must be a tuple that"
10029                f" starts with an ExplorationActionType and has 2-6"
10030                f" entries if it's not ('noAction',)):"
10031                f"\n{repr(action)}"
10032            )
10033
10034        elif action[0] == 'start':
10035            (
10036                _,
10037                positionSpecifier,
10038                primary,
10039                domain,
10040                capabilities,
10041                mechanismStates,
10042                customState
10043            ) = cast(
10044                Tuple[
10045                    Literal['start'],
10046                    Union[
10047                        base.DecisionID,
10048                        Dict[base.FocalPointName, base.DecisionID],
10049                        Set[base.DecisionID]
10050                    ],
10051                    Optional[base.DecisionID],
10052                    base.Domain,
10053                    Optional[base.CapabilitySet],
10054                    Optional[Dict[base.MechanismID, base.MechanismState]],
10055                    Optional[dict]
10056                ],
10057                action
10058            )
10059            targetContext = newState['contexts'][
10060                newState['activeContext']
10061            ]
10062
10063            targetFocalization = base.getDomainFocalization(
10064                targetContext,
10065                domain
10066            )  # sets up 'singular' as default if
10067
10068            # Check if there are any already-active decisions.
10069            if targetContext['activeDecisions'][domain] is not None:
10070                raise BadStart(
10071                    f"Cannot start in domain {repr(domain)} because"
10072                    f" that domain already has a position. 'start' may"
10073                    f" only be used with domains that don't yet have"
10074                    f" any position information."
10075                )
10076
10077            # Make the domain active
10078            if domain not in targetContext['activeDomains']:
10079                targetContext['activeDomains'].add(domain)
10080
10081            # Check position info matches focalization type and update
10082            # exploration statuses
10083            if isinstance(positionSpecifier, base.DecisionID):
10084                if targetFocalization != 'singular':
10085                    raise BadStart(
10086                        f"Invalid position specifier"
10087                        f" {repr(positionSpecifier)} (type"
10088                        f" {type(positionSpecifier)}). Domain"
10089                        f" {repr(domain)} has {targetFocalization}"
10090                        f" focalization."
10091                    )
10092                base.setExplorationStatus(
10093                    updated.state,
10094                    updated.graph.resolveDecision(positionSpecifier),
10095                    'exploring',
10096                    upgradeOnly=True
10097                )
10098                destIDs.add(positionSpecifier)
10099            elif isinstance(positionSpecifier, dict):
10100                if targetFocalization != 'plural':
10101                    raise BadStart(
10102                        f"Invalid position specifier"
10103                        f" {repr(positionSpecifier)} (type"
10104                        f" {type(positionSpecifier)}). Domain"
10105                        f" {repr(domain)} has {targetFocalization}"
10106                        f" focalization."
10107                    )
10108                destIDs |= set(positionSpecifier.values())
10109            elif isinstance(positionSpecifier, set):
10110                if targetFocalization != 'spreading':
10111                    raise BadStart(
10112                        f"Invalid position specifier"
10113                        f" {repr(positionSpecifier)} (type"
10114                        f" {type(positionSpecifier)}). Domain"
10115                        f" {repr(domain)} has {targetFocalization}"
10116                        f" focalization."
10117                    )
10118                destIDs |= positionSpecifier
10119            else:
10120                raise TypeError(
10121                    f"Invalid position specifier"
10122                    f" {repr(positionSpecifier)} (type"
10123                    f" {type(positionSpecifier)}). It must be a"
10124                    f" DecisionID, a dictionary from FocalPointNames to"
10125                    f" DecisionIDs, or a set of DecisionIDs, according"
10126                    f" to the focalization of the relevant domain."
10127                )
10128
10129            # Put specified position(s) in place
10130            # TODO: This cast is really silly...
10131            targetContext['activeDecisions'][domain] = cast(
10132                Union[
10133                    None,
10134                    base.DecisionID,
10135                    Dict[base.FocalPointName, Optional[base.DecisionID]],
10136                    Set[base.DecisionID]
10137                ],
10138                positionSpecifier
10139            )
10140
10141            # Set primary decision
10142            newState['primaryDecision'] = primary
10143
10144            # Set capabilities
10145            if capabilities is not None:
10146                targetContext['capabilities'] = capabilities
10147
10148            # Set mechanism states
10149            if mechanismStates is not None:
10150                newState['mechanisms'] = mechanismStates
10151
10152            # Set custom state
10153            if customState is not None:
10154                newState['custom'] = customState
10155
10156        elif action[0] in ('explore', 'take', 'warp'):  # similar handling
10157            assert (
10158                len(action) == 3
10159             or len(action) == 4
10160             or len(action) == 6
10161             or len(action) == 7
10162            )
10163            # Set up necessary variables
10164            cSpec: base.ContextSpecifier = "active"
10165            fromID: Optional[base.DecisionID] = None
10166            takeTransition: Optional[base.Transition] = None
10167            outcomes: List[bool] = []
10168            destID: base.DecisionID  # No starting value as it's not optional
10169            moveInDomain: Optional[base.Domain] = None
10170            moveWhich: Optional[base.FocalPointName] = None
10171
10172            # Figure out target context
10173            if isinstance(action[1], str):
10174                if action[1] not in get_args(base.ContextSpecifier):
10175                    raise InvalidActionError(
10176                        f"Action specifies {repr(action[1])} context,"
10177                        f" but that's not a valid context specifier."
10178                        f" The valid options are:"
10179                        f"\n{repr(get_args(base.ContextSpecifier))}"
10180                    )
10181                else:
10182                    cSpec = cast(base.ContextSpecifier, action[1])
10183            else:  # Must be a `FocalPointSpecifier`
10184                cSpec, moveInDomain, moveWhich = cast(
10185                    base.FocalPointSpecifier,
10186                    action[1]
10187                )
10188                assert moveInDomain is not None
10189
10190            # Grab target context to work in
10191            if cSpec == 'common':
10192                targetContext = newState['common']
10193            else:
10194                targetContext = newState['contexts'][
10195                    newState['activeContext']
10196                ]
10197
10198            # Check focalization of the target domain
10199            if moveInDomain is not None:
10200                fType = base.getDomainFocalization(
10201                    targetContext,
10202                    moveInDomain
10203                )
10204                if (
10205                    (
10206                        isinstance(action[1], str)
10207                    and fType == 'plural'
10208                    ) or (
10209                        not isinstance(action[1], str)
10210                    and fType != 'plural'
10211                    )
10212                ):
10213                    raise ImpossibleActionError(
10214                        f"Invalid ExplorationAction (moves in"
10215                        f" plural-focalized domains must include a"
10216                        f" FocalPointSpecifier, while moves in"
10217                        f" non-plural-focalized domains must not."
10218                        f" Domain {repr(moveInDomain)} is"
10219                        f" {fType}-focalized):"
10220                        f"\n{repr(action)}"
10221                    )
10222
10223            if action[0] == "warp":
10224                # It's a warp, so destination is specified directly
10225                if not isinstance(action[2], base.DecisionID):
10226                    raise TypeError(
10227                        f"Invalid ExplorationAction tuple (third part"
10228                        f" must be a decision ID for 'warp' actions):"
10229                        f"\n{repr(action)}"
10230                    )
10231                else:
10232                    destID = cast(base.DecisionID, action[2])
10233
10234            elif aLen == 4 or aLen == 7:
10235                # direct 'take' or 'explore'
10236                fromID = cast(base.DecisionID, action[2])
10237                takeTransition, outcomes = cast(
10238                    base.TransitionWithOutcomes,
10239                    action[3]  # type: ignore [misc]
10240                )
10241                if (
10242                    not isinstance(fromID, base.DecisionID)
10243                 or not isinstance(takeTransition, base.Transition)
10244                ):
10245                    raise InvalidActionError(
10246                        f"Invalid ExplorationAction tuple (for 'take' or"
10247                        f" 'explore', if the length is 4/7, parts 2-4"
10248                        f" must be a context specifier, a decision ID, and a"
10249                        f" transition name. Got:"
10250                        f"\n{repr(action)}"
10251                    )
10252
10253                try:
10254                    destID = newGraph.destination(fromID, takeTransition)
10255                except MissingDecisionError:
10256                    raise ImpossibleActionError(
10257                        f"Invalid ExplorationAction: move from decision"
10258                        f" {fromID} is invalid because there is no"
10259                        f" decision with that ID in the current"
10260                        f" graph."
10261                        f"\nValid decisions are:"
10262                        f"\n{newGraph.namesListing(newGraph)}"
10263                    )
10264                except MissingTransitionError:
10265                    valid = newGraph.destinationsFrom(fromID)
10266                    listing = newGraph.destinationsListing(valid)
10267                    raise ImpossibleActionError(
10268                        f"Invalid ExplorationAction: move from decision"
10269                        f" {newGraph.identityOf(fromID)}"
10270                        f" along transition {repr(takeTransition)} is"
10271                        f" invalid because there is no such transition"
10272                        f" at that decision."
10273                        f"\nValid transitions there are:"
10274                        f"\n{listing}"
10275                    )
10276                targetActive = targetContext['activeDecisions']
10277                if moveInDomain is not None:
10278                    activeInDomain = targetActive[moveInDomain]
10279                    if (
10280                        (
10281                            isinstance(activeInDomain, base.DecisionID)
10282                        and fromID != activeInDomain
10283                        )
10284                     or (
10285                            isinstance(activeInDomain, set)
10286                        and fromID not in activeInDomain
10287                        )
10288                     or (
10289                            isinstance(activeInDomain, dict)
10290                        and fromID not in activeInDomain.values()
10291                        )
10292                    ):
10293                        raise ImpossibleActionError(
10294                            f"Invalid ExplorationAction: move from"
10295                            f" decision {fromID} is invalid because"
10296                            f" that decision is not active in domain"
10297                            f" {repr(moveInDomain)} in the current"
10298                            f" graph."
10299                            f"\nValid decisions are:"
10300                            f"\n{newGraph.namesListing(newGraph)}"
10301                        )
10302
10303            elif aLen == 3 or aLen == 6:
10304                # 'take' or 'explore' focal point
10305                # We know that moveInDomain is not None here.
10306                assert moveInDomain is not None
10307                if not isinstance(action[2], base.Transition):
10308                    raise InvalidActionError(
10309                        f"Invalid ExplorationAction tuple (for 'take'"
10310                        f" actions if the second part is a"
10311                        f" FocalPointSpecifier the third part must be a"
10312                        f" transition name):"
10313                        f"\n{repr(action)}"
10314                    )
10315
10316                takeTransition, outcomes = cast(
10317                    base.TransitionWithOutcomes,
10318                    action[2]
10319                )
10320                targetActive = targetContext['activeDecisions']
10321                activeInDomain = cast(
10322                    Dict[base.FocalPointName, Optional[base.DecisionID]],
10323                    targetActive[moveInDomain]
10324                )
10325                if (
10326                    moveInDomain is not None
10327                and (
10328                        not isinstance(activeInDomain, dict)
10329                     or moveWhich not in activeInDomain
10330                    )
10331                ):
10332                    raise ImpossibleActionError(
10333                        f"Invalid ExplorationAction: move of focal"
10334                        f" point {repr(moveWhich)} in domain"
10335                        f" {repr(moveInDomain)} is invalid because"
10336                        f" that domain does not have a focal point"
10337                        f" with that name."
10338                    )
10339                fromID = activeInDomain[moveWhich]
10340                if fromID is None:
10341                    raise ImpossibleActionError(
10342                        f"Invalid ExplorationAction: move of focal"
10343                        f" point {repr(moveWhich)} in domain"
10344                        f" {repr(moveInDomain)} is invalid because"
10345                        f" that focal point does not have a position"
10346                        f" at this step."
10347                    )
10348                try:
10349                    destID = newGraph.destination(fromID, takeTransition)
10350                except MissingDecisionError:
10351                    raise ImpossibleActionError(
10352                        f"Invalid exploration state: focal point"
10353                        f" {repr(moveWhich)} in domain"
10354                        f" {repr(moveInDomain)} specifies decision"
10355                        f" {fromID} as the current position, but"
10356                        f" that decision does not exist!"
10357                    )
10358                except MissingTransitionError:
10359                    valid = newGraph.destinationsFrom(fromID)
10360                    listing = newGraph.destinationsListing(valid)
10361                    raise ImpossibleActionError(
10362                        f"Invalid ExplorationAction: move of focal"
10363                        f" point {repr(moveWhich)} in domain"
10364                        f" {repr(moveInDomain)} along transition"
10365                        f" {repr(takeTransition)} is invalid because"
10366                        f" that focal point is at decision"
10367                        f" {newGraph.identityOf(fromID)} and that"
10368                        f" decision does not have an outgoing"
10369                        f" transition with that name.\nValid"
10370                        f" transitions from that decision are:"
10371                        f"\n{listing}"
10372                    )
10373
10374            else:
10375                raise InvalidActionError(
10376                    f"Invalid ExplorationAction: unrecognized"
10377                    f" 'explore', 'take' or 'warp' format:"
10378                    f"\n{action}"
10379                )
10380
10381            # If we're exploring, update information for the destination
10382            if action[0] == 'explore':
10383                zone = cast(Optional[base.Zone], action[-1])
10384                recipName = cast(Optional[base.Transition], action[-2])
10385                destOrName = cast(
10386                    Union[base.DecisionName, base.DecisionID, None],
10387                    action[-3]
10388                )
10389                if isinstance(destOrName, base.DecisionID):
10390                    destID = destOrName
10391
10392                if fromID is None or takeTransition is None:
10393                    raise ImpossibleActionError(
10394                        f"Invalid ExplorationAction: exploration"
10395                        f" has unclear origin decision or transition."
10396                        f" Got:\n{action}"
10397                    )
10398
10399                currentDest = newGraph.destination(fromID, takeTransition)
10400                if not newGraph.isConfirmed(currentDest):
10401                    newGraph.replaceUnconfirmed(
10402                        fromID,
10403                        takeTransition,
10404                        destOrName,
10405                        recipName,
10406                        placeInZone=zone,
10407                        forceNew=not isinstance(destOrName, base.DecisionID)
10408                    )
10409                else:
10410                    # Otherwise, since the destination already existed
10411                    # and was hooked up at the right decision, no graph
10412                    # edits need to be made, unless we need to rename
10413                    # the reciprocal.
10414                    # TODO: Do we care about zones here?
10415                    if recipName is not None:
10416                        oldReciprocal = newGraph.getReciprocal(
10417                            fromID,
10418                            takeTransition
10419                        )
10420                        if (
10421                            oldReciprocal is not None
10422                        and oldReciprocal != recipName
10423                        ):
10424                            newGraph.addTransition(
10425                                destID,
10426                                recipName,
10427                                fromID,
10428                                None
10429                            )
10430                            newGraph.setReciprocal(
10431                                destID,
10432                                recipName,
10433                                takeTransition,
10434                                setBoth=True
10435                            )
10436                            newGraph.mergeTransitions(
10437                                destID,
10438                                oldReciprocal,
10439                                recipName
10440                            )
10441
10442            # If we are moving along a transition, check requirements
10443            # and apply transition effects *before* updating our
10444            # position, and check that they don't cancel the normal
10445            # position update
10446            finalDest = None
10447            if takeTransition is not None:
10448                assert fromID is not None  # both or neither
10449                if not self.isTraversable(fromID, takeTransition):
10450                    if (fromID, takeTransition) in now.state['deactivated']:
10451                        warnings.warn(
10452                            (
10453                                f"The transition {takeTransition!r}"
10454                                f" from decision"
10455                                f" {now.graph.identityOf(fromID)} was"
10456                                f" already deactivated before step"
10457                                f" {len(self) - 1}."
10458                            ),
10459                            TransitionBlockedWarning
10460                        )
10461                    else:
10462                        req = now.graph.getTransitionRequirement(
10463                            fromID,
10464                            takeTransition
10465                        )
10466                        warnings.warn(
10467                            (
10468                                f"The requirements for transition"
10469                                f" {takeTransition!r} from decision"
10470                                f" {now.graph.identityOf(fromID)} are"
10471                                f" not met at step {len(self) - 1}:"
10472                                f"\n{req}"
10473                            ),
10474                            TransitionBlockedWarning
10475                        )
10476
10477                # Apply transition consequences to our new state and
10478                # figure out if we need to skip our normal update or not
10479                finalDest = self.applyTransitionConsequence(
10480                    fromID,
10481                    (takeTransition, outcomes),
10482                    moveWhich,
10483                    challengePolicy
10484                )
10485
10486            # Check moveInDomain
10487            destDomain = newGraph.domainFor(destID)
10488            if moveInDomain is not None and moveInDomain != destDomain:
10489                raise ImpossibleActionError(
10490                    f"Invalid ExplorationAction: move specified"
10491                    f" domain {repr(moveInDomain)} as the domain of"
10492                    f" the focal point to move, but the destination"
10493                    f" of the move is {now.graph.identityOf(destID)}"
10494                    f" which is in domain {repr(destDomain)}, so focal"
10495                    f" point {repr(moveWhich)} cannot be moved there."
10496                )
10497
10498            # Now that we know where we're going, update position
10499            # information (assuming it wasn't already set):
10500            if finalDest is None:
10501                finalDest = destID
10502                base.updatePosition(
10503                    updated.state,
10504                    updated.graph,
10505                    destID,
10506                    cSpec,
10507                    moveWhich
10508                )
10509
10510            destIDs.add(finalDest)
10511
10512        elif action[0] == "focus":
10513            # Figure out target context
10514            action = cast(
10515                Tuple[
10516                    Literal['focus'],
10517                    base.ContextSpecifier,
10518                    Set[base.Domain],
10519                    Set[base.Domain]
10520                ],
10521                action
10522            )
10523            contextSpecifier: base.ContextSpecifier = action[1]
10524            if contextSpecifier == 'common':
10525                targetContext = newState['common']
10526            else:
10527                targetContext = newState['contexts'][
10528                    newState['activeContext']
10529                ]
10530
10531            # Just need to swap out active domains
10532            goingOut, comingIn = cast(
10533                Tuple[Set[base.Domain], Set[base.Domain]],
10534                action[2:]
10535            )
10536            if (
10537                not isinstance(goingOut, set)
10538             or not isinstance(comingIn, set)
10539             or not all(isinstance(d, base.Domain) for d in goingOut)
10540             or not all(isinstance(d, base.Domain) for d in comingIn)
10541            ):
10542                raise InvalidActionError(
10543                    f"Invalid ExplorationAction tuple (must have 4"
10544                    f" parts if the first part is 'focus' and"
10545                    f" the third and fourth parts must be sets of"
10546                    f" domains):"
10547                    f"\n{repr(action)}"
10548                )
10549            activeSet = targetContext['activeDomains']
10550            for dom in goingOut:
10551                try:
10552                    activeSet.remove(dom)
10553                except KeyError:
10554                    warnings.warn(
10555                        (
10556                            f"Domain {repr(dom)} was deactivated at"
10557                            f" step {len(self)} but it was already"
10558                            f" inactive at that point."
10559                        ),
10560                        InactiveDomainWarning
10561                    )
10562            # TODO: Also warn for doubly-activated domains?
10563            activeSet |= comingIn
10564
10565            # destIDs remains empty in this case
10566
10567        elif action[0] == 'swap':  # update which `FocalContext` is active
10568            newContext = cast(base.FocalContextName, action[1])
10569            if newContext not in newState['contexts']:
10570                raise MissingFocalContextError(
10571                    f"'swap' action with target {repr(newContext)} is"
10572                    f" invalid because no context with that name"
10573                    f" exists."
10574                )
10575            newState['activeContext'] = newContext
10576
10577            # destIDs remains empty in this case
10578
10579        elif action[0] == 'focalize':  # create new `FocalContext`
10580            newContext = cast(base.FocalContextName, action[1])
10581            if newContext in newState['contexts']:
10582                raise FocalContextCollisionError(
10583                    f"'focalize' action with target {repr(newContext)}"
10584                    f" is invalid because a context with that name"
10585                    f" already exists."
10586                )
10587            newState['contexts'][newContext] = base.emptyFocalContext()
10588            newState['activeContext'] = newContext
10589
10590            # destIDs remains empty in this case
10591
10592        # revertTo is handled above
10593        else:
10594            raise InvalidActionError(
10595                f"Invalid ExplorationAction tuple (first item must be"
10596                f" an ExplorationActionType, and tuple must be length-1"
10597                f" if the action type is 'noAction'):"
10598                f"\n{repr(action)}"
10599            )
10600
10601        # Apply any active triggers
10602        followTo = self.applyActiveTriggers()
10603        if followTo is not None:
10604            destIDs.add(followTo)
10605            # TODO: Re-work to work with multiple position updates in
10606            # different focal contexts, domains, and/or for different
10607            # focal points in plural-focalized domains.
10608
10609        return (updated, destIDs)
10610
10611    def applyActiveTriggers(self) -> Optional[base.DecisionID]:
10612        """
10613        Finds all actions with the 'trigger' tag attached to currently
10614        active decisions, and applies their effects if their requirements
10615        are met (ordered by decision-ID with ties broken alphabetically
10616        by action name).
10617
10618        'bounce', 'goto' and 'follow' effects may apply. However, any
10619        new triggers that would be activated because of decisions
10620        reached by such effects will not apply. Note that 'bounce'
10621        effects update position to the decision where the action was
10622        attached, which is usually a no-op. This function returns the
10623        decision ID of the decision reached by the last decision-moving
10624        effect applied, or `None` if no such effects triggered.
10625
10626        TODO: What about situations where positions are updated in
10627        multiple domains or multiple foal points in a plural domain are
10628        independently updated?
10629
10630        TODO: Tests for this!
10631        """
10632        active = self.getActiveDecisions()
10633        now = self.getSituation()
10634        graph = now.graph
10635        finalFollow = None
10636        for decision in sorted(active):
10637            for action in sorted(graph.decisionActions(decision)):
10638                if (
10639                    'trigger' in graph.transitionTags(decision, action)
10640                and self.isTraversable(decision, action)
10641                ):
10642                    followTo = self.applyTransitionConsequence(
10643                        decision,
10644                        action
10645                    )
10646                    if followTo is not None:
10647                        # TODO: How will triggers interact with
10648                        # plural-focalized domains? Probably need to fix
10649                        # this to detect moveWhich based on which focal
10650                        # points are at the decision where the transition
10651                        # is, and then apply this to each of them?
10652                        base.updatePosition(now.state, now.graph, followTo)
10653                        finalFollow = followTo
10654
10655        return finalFollow
10656
10657    def explore(
10658        self,
10659        transition: base.AnyTransition,
10660        destination: Union[base.DecisionName, base.DecisionID, None],
10661        reciprocal: Optional[base.Transition] = None,
10662        zone: Optional[base.Zone] = base.DefaultZone,
10663        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
10664        whichFocus: Optional[base.FocalPointSpecifier] = None,
10665        inCommon: Union[bool, Literal["auto"]] = "auto",
10666        decisionType: base.DecisionType = "active",
10667        challengePolicy: base.ChallengePolicy = "specified"
10668    ) -> base.DecisionID:
10669        """
10670        Adds a new situation to the exploration representing the
10671        traversal of the specified transition (possibly with outcomes
10672        specified for challenges among that transitions consequences).
10673        Uses `deduceTransitionDetailsAtStep` to figure out from the
10674        transition name which specific transition is taken (and which
10675        focal point is updated if necessary). This uses the
10676        `fromDecision`, `whichFocus`, and `inCommon` optional
10677        parameters, and also determines whether to update the common or
10678        the active `FocalContext`. Sets the exploration status of the
10679        decision explored to 'exploring'. Returns the decision ID for
10680        the destination reached, accounting for goto/bounce/follow
10681        effects that might have triggered.
10682
10683        If multiple decisions are reached (e.g., in multiple domains,
10684        like you arrive at the destination but also die) it returns the
10685        decision with the highest decision ID (i.e., discovered latest)
10686        among decisions in the same domain as the natural endpoint of the
10687        transition taken, or if there are no such decisions, it returns
10688        the decision with the highest ID out of all newly-arrived-at
10689        decisions.
10690
10691        The `destination` will be used to name the newly-explored
10692        decision, except when it's a `DecisionID`, in which case that
10693        decision must be unvisited, and we'll connect the specified
10694        transition to that decision.
10695
10696        The focalization of the destination domain in the context to be
10697        updated determines how active decisions are changed:
10698
10699        - If the destination domain is focalized as 'single', then in
10700            the subsequent `Situation`, the destination decision will
10701            become the single active decision in that domain.
10702        - If it's focalized as 'plural', then one of the
10703            `FocalPointName`s for that domain will be moved to activate
10704            that decision; which one can be specified using `whichFocus`
10705            or if left unspecified, will be deduced: if the starting
10706            decision is in the same domain, then the
10707            alphabetically-earliest focal point which is at the starting
10708            decision will be moved. If the starting position is in a
10709            different domain, then the alphabetically earliest focal
10710            point among all focal points in the destination domain will
10711            be moved.
10712        - If it's focalized as 'spreading', then the destination
10713            decision will be added to the set of active decisions in
10714            that domain, without removing any.
10715
10716        The transition named must have been pointing to an unvisited
10717        decision (see `hasBeenVisited`), and the name of that decision
10718        will be updated if a `destination` value is given (a
10719        `DecisionCollisionWarning` will be issued if the destination
10720        name is a duplicate of another name in the graph, although this
10721        is not an error). Additionally:
10722
10723        - If a `reciprocal` name is specified, the reciprocal transition
10724            will be renamed using that name, or created with that name if
10725            it didn't already exist. If reciprocal is left as `None` (the
10726            default) then no change will be made to the reciprocal
10727            transition, and it will not be created if it doesn't exist.
10728        - If a `zone` is specified, the newly-explored decision will be
10729            added to that zone (and that zone will be created at level 0
10730            if it didn't already exist). If `zone` is set to `None` then
10731            it will not be added to any new zones. If `zone` is left as
10732            the default (the `base.DefaultZone` value) then the explored
10733            decision will be added to each zone that the decision it was
10734            explored from is a part of. If a zone needs to be created,
10735            that zone will be added as a sub-zone of each zone which is a
10736            parent of a zone that directly contains the origin decision.
10737        - An `ExplorationStatusError` will be raised if the specified
10738            transition leads to a decision whose `ExplorationStatus` is
10739            'exploring' or higher (i.e., `hasBeenVisited`). (Use
10740            `returnTo` instead to adjust things when a transition to an
10741            unknown destination turns out to lead to an already-known
10742            destination.)
10743        - A `TransitionBlockedWarning` will be issued if the specified
10744            transition is not traversable given the current game state
10745            (but in that last case the step will still be taken).
10746        - By default, the decision type for the new step will be
10747            'active', but a `decisionType` value can be specified to
10748            override that.
10749        - By default, the 'mostLikely' `ChallengePolicy` will be used to
10750            resolve challenges in the consequence of the transition
10751            taken, but an alternate policy can be supplied using the
10752            `challengePolicy` argument.
10753        """
10754        now = self.getSituation()
10755
10756        transitionName, outcomes = base.nameAndOutcomes(transition)
10757
10758        # Deduce transition details from the name + optional specifiers
10759        (
10760            using,
10761            fromID,
10762            destID,
10763            whichFocus
10764        ) = self.deduceTransitionDetailsAtStep(
10765            -1,
10766            transitionName,
10767            fromDecision,
10768            whichFocus,
10769            inCommon
10770        )
10771
10772        # Issue a warning if the destination name is already in use
10773        if destination is not None:
10774            if isinstance(destination, base.DecisionName):
10775                try:
10776                    existingID = now.graph.resolveDecision(destination)
10777                    collision = existingID != destID
10778                except MissingDecisionError:
10779                    collision = False
10780                except AmbiguousDecisionSpecifierError:
10781                    collision = True
10782
10783                if collision and WARN_OF_NAME_COLLISIONS:
10784                    warnings.warn(
10785                        (
10786                            f"The destination name {repr(destination)} is"
10787                            f" already in use when exploring transition"
10788                            f" {repr(transition)} from decision"
10789                            f" {now.graph.identityOf(fromID)} at step"
10790                            f" {len(self) - 1}."
10791                        ),
10792                        DecisionCollisionWarning
10793                    )
10794
10795        # TODO: Different terminology for "exploration state above
10796        # noticed" vs. "DG thinks it's been visited"...
10797        if (
10798            self.hasBeenVisited(destID)
10799        ):
10800            frStr = ''
10801            if fromDecision is not None:
10802                frStr = f"from decision {now.graph.identityOf(fromDecision)} "
10803            raise ExplorationStatusError(
10804                f"Cannot explore {frStr}to decision"
10805                f" {now.graph.identityOf(destID)} because it has"
10806                f" already been visited. Use returnTo instead of"
10807                f" explore when discovering a connection back to a"
10808                f" previously-explored decision."
10809            )
10810
10811        if (
10812            isinstance(destination, base.DecisionID)
10813        and self.hasBeenVisited(destination)
10814        ):
10815            frStr = ''
10816            if fromDecision is not None:
10817                frStr = f"from decision {now.graph.identityOf(fromDecision)} "
10818            raise ExplorationStatusError(
10819                f"Cannot explore {frStr}to decision"
10820                f" {now.graph.identityOf(destination)} because it has"
10821                f" already been visited. Use returnTo instead of"
10822                f" explore when discovering a connection back to a"
10823                f" previously-explored decision."
10824            )
10825
10826        actionTaken: base.ExplorationAction = (
10827            'explore',
10828            using,
10829            fromID,
10830            (transitionName, outcomes),
10831            destination,
10832            reciprocal,
10833            zone
10834        )
10835        if whichFocus is not None:
10836            # A move-from-specific-focal-point action
10837            actionTaken = (
10838                'explore',
10839                whichFocus,
10840                (transitionName, outcomes),
10841                destination,
10842                reciprocal,
10843                zone
10844            )
10845
10846        # Advance the situation, applying transition effects and
10847        # updating the destination decision.
10848        _, finalDests = self.advanceSituation(
10849            actionTaken,
10850            decisionType,
10851            challengePolicy
10852        )
10853
10854        return self.mostApplicableDestination(
10855            now.graph,
10856            fromID,
10857            destID,
10858            finalDests
10859        )
10860
10861    def mostApplicableDestination(
10862        self,
10863        graph: DecisionGraph,
10864        fromID: base.DecisionID,
10865        destID: base.DecisionID,
10866        destinationSet: Set[base.DecisionID]
10867    ) -> base.DecisionID:
10868        """
10869        Returns the single decision ID that's "most applicable" as the
10870        destination of an action that moved from the given `fromID`
10871        decision to the given `destID` decision (naively) on the given
10872        `graph` with the given `destinationSet` as the set of newly-active
10873        decisions from an `advanceSituation` call.
10874
10875        `advanceSituation` can return multiple or zero active decisions
10876        (e.g., if you take a transition but then die as a consequence,
10877        you'll be at the destination plus at the death ending in the
10878        endings domain, or if you take a transition with 'follow'
10879        consequences in a spreading-focalized domain).
10880
10881        When multiple decisions are present in the destination set, this
10882        function returns the decision with the highest ID (i.e.,
10883        discovered most recently) that's in the same domain as the
10884        destination decision, or if there are none in that domain, the
10885        one with the highest decision ID overall.
10886
10887        If the destination set is empty, it returns the `fromID`.
10888        """
10889        if len(destinationSet) == 0:
10890            return fromID
10891        elif len(destinationSet) > 1:
10892            # Figure out which destination(s) are in the same domain as
10893            # the natural destination, and return the one with the
10894            # highest ID among those, or the one with the highest ID
10895            # overall if there are none.
10896            destDomain = graph.domainFor(destID)
10897            inSame = [
10898                x
10899                for x in destinationSet
10900                if graph.domainFor(x) == destDomain
10901            ]
10902            if len(inSame) == 0:
10903                return max(destinationSet)
10904            else:
10905                return max(inSame)
10906        else:
10907            return next(x for x in destinationSet)
10908
10909    def returnTo(
10910        self,
10911        transition: base.AnyTransition,
10912        destination: base.AnyDecisionSpecifier,
10913        reciprocal: Optional[base.Transition] = None,
10914        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
10915        whichFocus: Optional[base.FocalPointSpecifier] = None,
10916        inCommon: Union[bool, Literal["auto"]] = "auto",
10917        decisionType: base.DecisionType = "active",
10918        challengePolicy: base.ChallengePolicy = "specified"
10919    ) -> base.DecisionID:
10920        """
10921        Adds a new graph to the exploration that replaces the given
10922        transition at the current position (which must lead to an unknown
10923        node, or a `MissingDecisionError` will result). The new
10924        transition will connect back to the specified destination, which
10925        must already exist (or a different `ValueError` will be raised).
10926        Returns the decision ID for the destination reached.
10927
10928        Deduces transition details using the optional `fromDecision`,
10929        `whichFocus`, and `inCommon` arguments in addition to the
10930        `transition` value; see `deduceTransitionDetailsAtStep`.
10931
10932        If a `reciprocal` transition is specified, that transition must
10933        either not already exist in the destination decision or lead to
10934        an unknown region; it will be replaced (or added) as an edge
10935        leading back to the current position.
10936
10937        The `decisionType` and `challengePolicy` optional arguments are
10938        used for `advanceSituation`.
10939
10940        A `TransitionBlockedWarning` will be issued if the requirements
10941        for the transition are not met, but the step will still be taken.
10942        Raises a `MissingDecisionError` if there is no current
10943        transition.
10944        """
10945        now = self.getSituation()
10946
10947        transitionName, outcomes = base.nameAndOutcomes(transition)
10948
10949        # Deduce transition details from the name + optional specifiers
10950        (
10951            using,
10952            fromID,
10953            destID,
10954            whichFocus
10955        ) = self.deduceTransitionDetailsAtStep(
10956            -1,
10957            transitionName,
10958            fromDecision,
10959            whichFocus,
10960            inCommon
10961        )
10962
10963        # Replace with connection to existing destination
10964        destID = now.graph.resolveDecision(destination)
10965        if not self.hasBeenVisited(destID):
10966            raise ExplorationStatusError(
10967                f"Cannot return to decision"
10968                f" {now.graph.identityOf(destID)} because it has NOT"
10969                f" already been at least partially explored. Use"
10970                f" explore instead of returnTo when discovering a"
10971                f" connection to a previously-unexplored decision."
10972            )
10973
10974        now.graph.replaceUnconfirmed(
10975            fromID,
10976            transitionName,
10977            destID,
10978            reciprocal
10979        )
10980
10981        # A move-from-decision action
10982        actionTaken: base.ExplorationAction = (
10983            'take',
10984            using,
10985            fromID,
10986            (transitionName, outcomes)
10987        )
10988        if whichFocus is not None:
10989            # A move-from-specific-focal-point action
10990            actionTaken = ('take', whichFocus, (transitionName, outcomes))
10991
10992        # Next, advance the situation, applying transition effects
10993        _, finalDests = self.advanceSituation(
10994            actionTaken,
10995            decisionType,
10996            challengePolicy
10997        )
10998
10999        return self.mostApplicableDestination(
11000            now.graph,
11001            fromID,
11002            destID,
11003            finalDests
11004        )
11005
11006    def takeAction(
11007        self,
11008        action: base.AnyTransition,
11009        requires: Optional[base.Requirement] = None,
11010        consequence: Optional[base.Consequence] = None,
11011        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
11012        whichFocus: Optional[base.FocalPointSpecifier] = None,
11013        inCommon: Union[bool, Literal["auto"]] = "auto",
11014        decisionType: base.DecisionType = "active",
11015        challengePolicy: base.ChallengePolicy = "specified"
11016    ) -> base.DecisionID:
11017        """
11018        Adds a new graph to the exploration based on taking the given
11019        action, which must be a self-transition in the graph. If the
11020        action does not already exist in the graph, it will be created.
11021        Either way if requirements and/or a consequence are supplied,
11022        the requirements and consequence of the action will be updated
11023        to match them, and those are the requirements/consequence that
11024        will count.
11025
11026        Returns the decision ID for the decision reached, which normally
11027        is the same action you were just at, but which might be altered
11028        by goto, bounce, and/or follow effects.
11029
11030        Issues a `TransitionBlockedWarning` if the current game state
11031        doesn't satisfy the requirements for the action.
11032
11033        The `fromDecision`, `whichFocus`, and `inCommon` arguments are
11034        used for `deduceTransitionDetailsAtStep`, while `decisionType`
11035        and `challengePolicy` are used for `advanceSituation`.
11036
11037        When an action is being created, `fromDecision` (or
11038        `whichFocus`) must be specified, since the source decision won't
11039        be deducible from the transition name. Note that if a transition
11040        with the given name exists from *any* active decision, it will
11041        be used instead of creating a new action (possibly resulting in
11042        an error if it's not a self-loop transition). Also, you may get
11043        an `AmbiguousTransitionError` if several transitions with that
11044        name exist; in that case use `fromDecision` and/or `whichFocus`
11045        to disambiguate.
11046        """
11047        now = self.getSituation()
11048        graph = now.graph
11049
11050        actionName, outcomes = base.nameAndOutcomes(action)
11051
11052        try:
11053            (
11054                using,
11055                fromID,
11056                destID,
11057                whichFocus
11058            ) = self.deduceTransitionDetailsAtStep(
11059                -1,
11060                actionName,
11061                fromDecision,
11062                whichFocus,
11063                inCommon
11064            )
11065
11066            if destID != fromID:
11067                raise ValueError(
11068                    f"Cannot take action {repr(action)} because it's a"
11069                    f" transition to another decision, not an action"
11070                    f" (use explore, returnTo, and/or retrace instead)."
11071                )
11072
11073        except MissingTransitionError:
11074            using = 'active'
11075            if inCommon is True:
11076                using = 'common'
11077
11078            if fromDecision is not None:
11079                fromID = graph.resolveDecision(fromDecision)
11080            elif whichFocus is not None:
11081                maybeFromID = base.resolvePosition(now.state, whichFocus)
11082                if maybeFromID is None:
11083                    raise MissingDecisionError(
11084                        f"Focal point {repr(whichFocus)} was specified"
11085                        f" in takeAction but that focal point doesn't"
11086                        f" have a position."
11087                    )
11088                else:
11089                    fromID = maybeFromID
11090            else:
11091                raise AmbiguousTransitionError(
11092                    f"Taking action {repr(action)} is ambiguous because"
11093                    f" the source decision has not been specified via"
11094                    f" either fromDecision or whichFocus, and we"
11095                    f" couldn't find an existing action with that name."
11096                )
11097
11098            destID = fromID
11099
11100            # Since the action doesn't exist, add it:
11101            graph.addAction(fromID, actionName, requires, consequence)
11102
11103        # Update the transition requirement/consequence if requested
11104        # (before the action is taken)
11105        if requires is not None:
11106            graph.setTransitionRequirement(fromID, actionName, requires)
11107        if consequence is not None:
11108            graph.setConsequence(fromID, actionName, consequence)
11109
11110        # A move-from-decision action
11111        actionTaken: base.ExplorationAction = (
11112            'take',
11113            using,
11114            fromID,
11115            (actionName, outcomes)
11116        )
11117        if whichFocus is not None:
11118            # A move-from-specific-focal-point action
11119            actionTaken = ('take', whichFocus, (actionName, outcomes))
11120
11121        _, finalDests = self.advanceSituation(
11122            actionTaken,
11123            decisionType,
11124            challengePolicy
11125        )
11126
11127        return self.mostApplicableDestination(
11128            graph,
11129            fromID,
11130            destID,
11131            finalDests
11132        )
11133
11134    def retrace(
11135        self,
11136        transition: base.AnyTransition,
11137        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
11138        whichFocus: Optional[base.FocalPointSpecifier] = None,
11139        inCommon: Union[bool, Literal["auto"]] = "auto",
11140        decisionType: base.DecisionType = "active",
11141        challengePolicy: base.ChallengePolicy = "specified"
11142    ) -> base.DecisionID:
11143        """
11144        Adds a new graph to the exploration based on taking the given
11145        transition, which must already exist and which must not lead to
11146        an unknown region. Returns the ID of the destination decision,
11147        accounting for goto, bounce, and/or follow effects.
11148
11149        Issues a `TransitionBlockedWarning` if the current game state
11150        doesn't satisfy the requirements for the transition.
11151
11152        The `fromDecision`, `whichFocus`, and `inCommon` arguments are
11153        used for `deduceTransitionDetailsAtStep`, while `decisionType`
11154        and `challengePolicy` are used for `advanceSituation`.
11155        """
11156        now = self.getSituation()
11157
11158        transitionName, outcomes = base.nameAndOutcomes(transition)
11159
11160        (
11161            using,
11162            fromID,
11163            destID,
11164            whichFocus
11165        ) = self.deduceTransitionDetailsAtStep(
11166            -1,
11167            transitionName,
11168            fromDecision,
11169            whichFocus,
11170            inCommon
11171        )
11172
11173        visited = self.hasBeenVisited(destID)
11174        confirmed = now.graph.isConfirmed(destID)
11175        if not confirmed:
11176            raise ExplorationStatusError(
11177                f"Cannot retrace transition {transition!r} from"
11178                f" decision {now.graph.identityOf(fromID)} because it"
11179                f" leads to an unconfirmed decision.\nUse"
11180                f" `DiscreteExploration.explore` and provide"
11181                f" destination decision details instead."
11182            )
11183        if not visited:
11184            raise ExplorationStatusError(
11185                f"Cannot retrace transition {transition!r} from"
11186                f" decision {now.graph.identityOf(fromID)} because it"
11187                f" leads to an unvisited decision.\nUse"
11188                f" `DiscreteExploration.explore` and provide"
11189                f" destination decision details instead."
11190            )
11191
11192        # A move-from-decision action
11193        actionTaken: base.ExplorationAction = (
11194            'take',
11195            using,
11196            fromID,
11197            (transitionName, outcomes)
11198        )
11199        if whichFocus is not None:
11200            # A move-from-specific-focal-point action
11201            actionTaken = ('take', whichFocus, (transitionName, outcomes))
11202
11203        _, finalDests = self.advanceSituation(
11204            actionTaken,
11205            decisionType,
11206            challengePolicy
11207        )
11208
11209        return self.mostApplicableDestination(
11210            now.graph,
11211            fromID,
11212            destID,
11213            finalDests
11214        )
11215
11216    def warp(
11217        self,
11218        destination: base.AnyDecisionSpecifier,
11219        consequence: Optional[base.Consequence] = None,
11220        domain: Optional[base.Domain] = None,
11221        zone: Optional[base.Zone] = base.DefaultZone,
11222        whichFocus: Optional[base.FocalPointSpecifier] = None,
11223        inCommon: Union[bool] = False,
11224        decisionType: base.DecisionType = "active",
11225        challengePolicy: base.ChallengePolicy = "specified",
11226        allowNew: bool = False
11227    ) -> base.DecisionID:
11228        """
11229        Adds a new graph to the exploration that's a copy of the current
11230        graph, with the position updated to be at the destination without
11231        actually creating a transition from the old position to the new
11232        one. Returns the ID of the decision warped to (accounting for
11233        any goto or follow effects triggered).
11234
11235        Any provided consequences are applied, but are not associated
11236        with any transition (so any delays and charges are ignored, and
11237        'bounce' effects don't actually cancel the warp). 'goto' or
11238        'follow' effects might change the warp destination; 'follow'
11239        effects take the original destination as their starting point.
11240        Any mechanisms mentioned in extra consequences will be found
11241        based on the destination. Outcomes in supplied challenges should
11242        be pre-specified, or else they will be resolved with the
11243        `challengePolicy`.
11244
11245        `whichFocus` may be specified when the destination domain's
11246        focalization is 'plural' but for 'singular' or 'spreading'
11247        destination domains it is not allowed. `inCommon` determines
11248        whether the common or the active focal context is updated
11249        (default is to update the active context). The `decisionType`
11250        and `challengePolicy` are used for `advanceSituation`.
11251
11252        - If the destination did not already exist, it will be created if
11253            `allowNew` is `True` (default is `False`). If `allowNew` is
11254            `False` and the destination did not already exist, a
11255            `MissingDecisionError` will be raised. Initially, any
11256            newly-created decision will be disconnected from all other
11257            decisions. In this case, the `domain` value can be used to
11258            put it in a non-default domain.
11259        - The position is set to the specified destination, and if a
11260            `consequence` is specified it is applied. Note that
11261            'deactivate' effects are NOT allowed, and 'edit' effects
11262            must establish their own transition target because there is
11263            no transition that the effects are being applied to.
11264        - If the destination had been unexplored, its exploration status
11265            will be set to 'exploring'.
11266        - If a `zone` is specified, the destination will be added to that
11267            zone (even if the destination already existed) and that zone
11268            will be created (as a level-0 zone) if need be. If `zone` is
11269            set to `None`, then no zone will be applied. If `zone` is
11270            left as the default (`base.DefaultZone`) and the
11271            focalization of the destination domain is 'singular' or
11272            'plural' and the destination is newly created and there is
11273            an origin and the origin is in the same domain as the
11274            destination, then the destination will be added to all zones
11275            that the origin was a part of if the destination is newly
11276            created, but otherwise the destination will not be added to
11277            any zones. If the specified zone has to be created and
11278            there's an origin decision, it will be added as a sub-zone
11279            to all parents of zones directly containing the origin, as
11280            long as the origin is in the same domain as the destination.
11281        """
11282        now = self.getSituation()
11283        graph = now.graph
11284
11285        fromID: Optional[base.DecisionID]
11286
11287        new = False
11288        try:
11289            destID = graph.resolveDecision(destination)
11290        except MissingDecisionError:
11291            if not allowNew:
11292                raise
11293
11294            if isinstance(destination, tuple):
11295                # just the name; ignore zone/domain
11296                destination = destination[-1]
11297
11298            if not isinstance(destination, base.DecisionName):
11299                raise TypeError(
11300                    f"Warp destination {repr(destination)} does not"
11301                    f" exist, and cannot be created as it is not a"
11302                    f" decision name."
11303                )
11304            destID = graph.addDecision(destination, domain)
11305            graph.tagDecision(destID, 'unconfirmed')
11306            self.setExplorationStatus(destID, 'unknown')
11307            new = True
11308
11309        using: base.ContextSpecifier
11310        if inCommon:
11311            targetContext = self.getCommonContext()
11312            using = "common"
11313        else:
11314            targetContext = self.getActiveContext()
11315            using = "active"
11316
11317        destDomain = graph.domainFor(destID)
11318        targetFocalization = base.getDomainFocalization(
11319            targetContext,
11320            destDomain
11321        )
11322        if targetFocalization == 'singular':
11323            targetActive = targetContext['activeDecisions']
11324            if destDomain in targetActive:
11325                fromID = cast(
11326                    base.DecisionID,
11327                    targetContext['activeDecisions'][destDomain]
11328                )
11329            else:
11330                fromID = None
11331        elif targetFocalization == 'plural':
11332            if whichFocus is None:
11333                raise AmbiguousTransitionError(
11334                    f"Warping to {repr(destination)} is ambiguous"
11335                    f" becuase domain {repr(destDomain)} has plural"
11336                    f" focalization, and no whichFocus value was"
11337                    f" specified."
11338                )
11339
11340            fromID = base.resolvePosition(
11341                self.getSituation().state,
11342                whichFocus
11343            )
11344        else:
11345            fromID = None
11346
11347        # Handle zones
11348        if zone == base.DefaultZone:
11349            if (
11350                new
11351            and fromID is not None
11352            and graph.domainFor(fromID) == destDomain
11353            ):
11354                for prevZone in graph.zoneParents(fromID):
11355                    graph.addDecisionToZone(destination, prevZone)
11356            # Otherwise don't update zones
11357        elif zone is not None:
11358            # Newness is ignored when a zone is specified
11359            zone = cast(base.Zone, zone)
11360            # Create the zone at level 0 if it didn't already exist
11361            if graph.getZoneInfo(zone) is None:
11362                graph.createZone(zone, 0)
11363                # Add the newly created zone to each 2nd-level parent of
11364                # the previous decision if there is one and it's in the
11365                # same domain
11366                if (
11367                    fromID is not None
11368                and graph.domainFor(fromID) == destDomain
11369                ):
11370                    for prevZone in graph.zoneParents(fromID):
11371                        for prevUpper in graph.zoneParents(prevZone):
11372                            graph.addZoneToZone(zone, prevUpper)
11373            # Finally add the destination to the (maybe new) zone
11374            graph.addDecisionToZone(destID, zone)
11375        # else don't touch zones
11376
11377        # Encode the action taken
11378        actionTaken: base.ExplorationAction
11379        if whichFocus is None:
11380            actionTaken = (
11381                'warp',
11382                using,
11383                destID
11384            )
11385        else:
11386            actionTaken = (
11387                'warp',
11388                whichFocus,
11389                destID
11390            )
11391
11392        # Advance the situation
11393        _, finalDests = self.advanceSituation(
11394            actionTaken,
11395            decisionType,
11396            challengePolicy
11397        )
11398        now = self.getSituation()  # updating just in case
11399
11400        baseID = fromID
11401        if baseID is None:
11402            baseID = destID
11403
11404        finalDest = self.mostApplicableDestination(
11405            now.graph,
11406            baseID,
11407            destID,
11408            finalDests
11409        )
11410
11411        # Apply additional consequences:
11412        if consequence is not None:
11413            altDest = self.applyExtraneousConsequence(
11414                consequence,
11415                where=(destID, None),
11416                # TODO: Mechanism search from both ends?
11417                moveWhich=(
11418                    whichFocus[-1]
11419                    if whichFocus is not None
11420                    else None
11421                )
11422            )
11423            if altDest is not None:
11424                finalDest = altDest
11425            now = self.getSituation()  # updating just in case
11426
11427        return finalDest
11428
11429    def wait(
11430        self,
11431        consequence: Optional[base.Consequence] = None,
11432        decisionType: base.DecisionType = "active",
11433        challengePolicy: base.ChallengePolicy = "specified"
11434    ) -> Optional[base.DecisionID]:
11435        """
11436        Adds a wait step. If a consequence is specified, it is applied,
11437        although it will not have any position/transition information
11438        available during resolution/application.
11439
11440        A decision type other than "active" and/or a challenge policy
11441        other than "specified" can be included (see `advanceSituation`).
11442
11443        The "pending" decision type may not be used, a `ValueError` will
11444        result. This allows None as the action for waiting while
11445        preserving the pending/None type/action combination for
11446        unresolved situations.
11447
11448        If a goto or follow effect in the applied consequence implies a
11449        position update, this will return the new destination ID;
11450        otherwise it will return `None`. Triggering a 'bounce' effect
11451        will be an error, because there is no position information for
11452        the effect.
11453        """
11454        if decisionType == "pending":
11455            raise ValueError(
11456                "The 'pending' decision type may not be used for"
11457                " wait actions."
11458            )
11459        self.advanceSituation(('noAction',), decisionType, challengePolicy)
11460        now = self.getSituation()
11461        if consequence is not None:
11462            if challengePolicy != "specified":
11463                base.resetChallengeOutcomes(consequence)
11464            observed = base.observeChallengeOutcomes(
11465                base.RequirementContext(
11466                    state=now.state,
11467                    graph=now.graph,
11468                    searchFrom=set()
11469                ),
11470                consequence,
11471                location=None,  # No position info
11472                policy=challengePolicy,
11473                knownOutcomes=None  # bake outcomes into the consequence
11474            )
11475            # No location information since we might have multiple
11476            # active decisions and there's no indication of which one
11477            # we're "waiting at."
11478            finalDest = self.applyExtraneousConsequence(observed)
11479            now = self.getSituation()  # updating just in case
11480
11481            return finalDest
11482        else:
11483            return None
11484
11485    def revert(
11486        self,
11487        slot: base.SaveSlot = base.DEFAULT_SAVE_SLOT,
11488        aspects: Optional[Set[str]] = None,
11489        decisionType: base.DecisionType = "active"
11490    ) -> None:
11491        """
11492        Reverts the game state to a previously-saved game state (saved
11493        via a 'save' effect). The save slot name and set of aspects to
11494        revert are required. By default, all aspects except the graph
11495        are reverted.
11496        """
11497        if aspects is None:
11498            aspects = set()
11499
11500        action: base.ExplorationAction = ("revertTo", slot, aspects)
11501
11502        self.advanceSituation(action, decisionType)
11503
11504    def observeAll(
11505        self,
11506        where: base.AnyDecisionSpecifier,
11507        *transitions: Union[
11508            base.Transition,
11509            Tuple[base.Transition, base.AnyDecisionSpecifier],
11510            Tuple[
11511                base.Transition,
11512                base.AnyDecisionSpecifier,
11513                base.Transition
11514            ]
11515        ]
11516    ) -> List[base.DecisionID]:
11517        """
11518        Observes one or more new transitions, applying changes to the
11519        current graph. The transitions can be specified in one of three
11520        ways:
11521
11522        1. A transition name. The transition will be created and will
11523            point to a new unexplored node.
11524        2. A pair containing a transition name and a destination
11525            specifier. If the destination does not exist it will be
11526            created as an unexplored node, although in that case the
11527            decision specifier may not be an ID.
11528        3. A triple containing a transition name, a destination
11529            specifier, and a reciprocal name. Works the same as the pair
11530            case but also specifies the name for the reciprocal
11531            transition.
11532
11533        The new transitions are outgoing from specified decision.
11534
11535        Yields the ID of each decision connected to, whether those are
11536        new or existing decisions.
11537        """
11538        now = self.getSituation()
11539        fromID = now.graph.resolveDecision(where)
11540        result = []
11541        for entry in transitions:
11542            if isinstance(entry, base.Transition):
11543                result.append(self.observe(fromID, entry))
11544            else:
11545                result.append(self.observe(fromID, *entry))
11546        return result
11547
11548    def observe(
11549        self,
11550        where: base.AnyDecisionSpecifier,
11551        transition: base.Transition,
11552        destination: Optional[base.AnyDecisionSpecifier] = None,
11553        reciprocal: Optional[base.Transition] = None
11554    ) -> base.DecisionID:
11555        """
11556        Observes a single new outgoing transition from the specified
11557        decision. If specified the transition connects to a specific
11558        destination and/or has a specific reciprocal. The specified
11559        destination will be created if it doesn't exist, or where no
11560        destination is specified, a new unexplored decision will be
11561        added. The ID of the decision connected to is returned.
11562
11563        Sets the exploration status of the observed destination to
11564        "noticed" if a destination is specified and needs to be created
11565        (but not when no destination is specified).
11566
11567        For example:
11568
11569        >>> e = DiscreteExploration()
11570        >>> e.start('start')
11571        0
11572        >>> e.observe('start', 'up')
11573        1
11574        >>> g = e.getSituation().graph
11575        >>> g.destinationsFrom('start')
11576        {'up': 1}
11577        >>> e.getExplorationStatus(1)  # not given a name: assumed unknown
11578        'unknown'
11579        >>> e.observe('start', 'left', 'A')
11580        2
11581        >>> g.destinationsFrom('start')
11582        {'up': 1, 'left': 2}
11583        >>> g.nameFor(2)
11584        'A'
11585        >>> e.getExplorationStatus(2)  # given a name: noticed
11586        'noticed'
11587        >>> e.observe('start', 'up2', 1)
11588        1
11589        >>> g.destinationsFrom('start')
11590        {'up': 1, 'left': 2, 'up2': 1}
11591        >>> e.getExplorationStatus(1)  # existing decision: status unchanged
11592        'unknown'
11593        >>> e.observe('start', 'right', 'B', 'left')
11594        3
11595        >>> g.destinationsFrom('start')
11596        {'up': 1, 'left': 2, 'up2': 1, 'right': 3}
11597        >>> g.nameFor(3)
11598        'B'
11599        >>> e.getExplorationStatus(3)  # new + name -> noticed
11600        'noticed'
11601        >>> e.observe('start', 'right')  # repeat transition name
11602        Traceback (most recent call last):
11603        ...
11604        exploration.core.TransitionCollisionError...
11605        >>> e.observe('start', 'right2', 'B', 'left')  # repeat reciprocal
11606        Traceback (most recent call last):
11607        ...
11608        exploration.core.TransitionCollisionError...
11609        >>> g = e.getSituation().graph
11610        >>> g.createZone('Z', 0)
11611        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
11612 annotations=[])
11613        >>> g.addDecisionToZone('start', 'Z')
11614        >>> e.observe('start', 'down', 'C', 'up')
11615        4
11616        >>> g.destinationsFrom('start')
11617        {'up': 1, 'left': 2, 'up2': 1, 'right': 3, 'down': 4}
11618        >>> g.identityOf('C')
11619        '4 (C)'
11620        >>> g.zoneParents(4)  # not in any zones, 'cause still unexplored
11621        set()
11622        >>> e.observe(
11623        ...     'C',
11624        ...     'right',
11625        ...     base.DecisionSpecifier('main', 'Z2', 'D'),
11626        ... )  # creates zone
11627        5
11628        >>> g.destinationsFrom('C')
11629        {'up': 0, 'right': 5}
11630        >>> g.destinationsFrom('D')  # no reciprocal if not specified
11631        {}
11632        >>> g.identityOf('D')
11633        '5 (Z2::D)'
11634        >>> g.zoneParents(5)
11635        {'Z2'}
11636        """
11637        now = self.getSituation()
11638        fromID = now.graph.resolveDecision(where)
11639
11640        kwargs: Dict[
11641            str,
11642            Union[base.Transition, base.DecisionName, None]
11643        ] = {}
11644        if reciprocal is not None:
11645            kwargs['reciprocal'] = reciprocal
11646
11647        if destination is not None:
11648            try:
11649                destID = now.graph.resolveDecision(destination)
11650                now.graph.addTransition(
11651                    fromID,
11652                    transition,
11653                    destID,
11654                    reciprocal
11655                )
11656                return destID
11657            except MissingDecisionError:
11658                if isinstance(destination, base.DecisionSpecifier):
11659                    kwargs['toDomain'] = destination.domain
11660                    kwargs['placeInZone'] = destination.zone
11661                    kwargs['destinationName'] = destination.name
11662                elif isinstance(destination, base.DecisionName):
11663                    kwargs['destinationName'] = destination
11664                else:
11665                    assert isinstance(destination, base.DecisionID)
11666                    # We got to except by failing to resolve, so it's an
11667                    # invalid ID
11668                    raise
11669
11670        result = now.graph.addUnexploredEdge(
11671            fromID,
11672            transition,
11673            **kwargs  # type: ignore [arg-type]
11674        )
11675        if 'destinationName' in kwargs:
11676            self.setExplorationStatus(result, 'noticed', upgradeOnly=True)
11677        return result
11678
11679    def observeMechanisms(
11680        self,
11681        where: Optional[base.AnyDecisionSpecifier],
11682        *mechanisms: Union[
11683            base.MechanismName,
11684            Tuple[base.MechanismName, base.MechanismState]
11685        ]
11686    ) -> List[base.MechanismID]:
11687        """
11688        Adds one or more mechanisms to the exploration's current graph,
11689        located at the specified decision. Global mechanisms can be
11690        added by using `None` for the location. Mechanisms are named, or
11691        a (name, state) tuple can be used to set them into a specific
11692        state. Mechanisms not set to a state will be in the
11693        `base.DEFAULT_MECHANISM_STATE`.
11694        """
11695        now = self.getSituation()
11696        result = []
11697        for mSpec in mechanisms:
11698            setState = None
11699            if isinstance(mSpec, base.MechanismName):
11700                result.append(now.graph.addMechanism(mSpec, where))
11701            elif (
11702                isinstance(mSpec, tuple)
11703            and len(mSpec) == 2
11704            and isinstance(mSpec[0], base.MechanismName)
11705            and isinstance(mSpec[1], base.MechanismState)
11706            ):
11707                result.append(now.graph.addMechanism(mSpec[0], where))
11708                setState = mSpec[1]
11709            else:
11710                raise TypeError(
11711                    f"Invalid mechanism: {repr(mSpec)} (must be a"
11712                    f" mechanism name or a (name, state) tuple."
11713                )
11714
11715            if setState:
11716                self.setMechanismStateNow(result[-1], setState)
11717
11718        return result
11719
11720    def reZone(
11721        self,
11722        zone: Optional[base.Zone],
11723        where: base.AnyDecisionSpecifier,
11724        replace: Union[base.Zone, int] = 0
11725    ) -> None:
11726        """
11727        Alters the current graph without adding a new exploration step.
11728
11729        When given an integer `replace` value, calls
11730        `DecisionGraph.replaceZonesInHierarchy` targeting the
11731        specified decision, replacing ALL zones at the specified
11732        hierarchy level.
11733
11734        If given a zone to replace instead, replaces just that zone by
11735        thoroughly removing the given decision from that zone and then
11736        adding it to the new target zone directly. Thorough removal may
11737        affect membership in other zones...
11738
11739        Use `None` as the zone name to instead remove the current
11740        decision from all zones at the specified hierarchy level, or
11741        from the specified single zone (this uses thorough removal so
11742        may affect membership in lower-level zones).
11743        """
11744        graph = self.getSituation().graph
11745        dID = graph.resolveDecision(where)
11746
11747        if isinstance(replace, int):
11748            # Replace/discard all zones at level
11749            if zone is None:
11750                # Remove from ALL zones at specified level
11751                for escape in graph.zoneAncestors(dID, atLevel=replace):
11752                    graph.removeDecisionFromZone(dID, escape, True)
11753            else:
11754                graph.replaceZonesInHierarchy(dID, zone, replace)
11755        else:
11756            # Replace specific zone
11757            graph.removeDecisionFromZone(dID, replace, True)
11758            if zone is not None:
11759                graph.addDecisionToZone(dID, zone)
11760
11761    def runCommand(
11762        self,
11763        command: commands.Command,
11764        scope: Optional[commands.Scope] = None,
11765        line: int = -1
11766    ) -> commands.CommandResult:
11767        """
11768        Runs a single `Command` applying effects to the exploration, its
11769        current graph, and the provided execution context, and returning
11770        a command result, which contains the modified scope plus
11771        optional skip and label values (see `CommandResult`). This
11772        function also directly modifies the scope you give it. Variable
11773        references in the command are resolved via entries in the
11774        provided scope. If no scope is given, an empty one is created.
11775
11776        A line number may be supplied for use in error messages; if left
11777        out line -1 will be used.
11778
11779        Raises an error if the command is invalid.
11780
11781        For commands that establish a value as the 'current value', that
11782        value will be stored in the '_' variable. When this happens, the
11783        old contents of '_' are stored in '__' first, and the old
11784        contents of '__' are discarded. Note that non-automatic
11785        assignment to '_' does not move the old value to '__'.
11786        """
11787        try:
11788            if scope is None:
11789                scope = {}
11790
11791            skip: Union[int, str, None] = None
11792            label: Optional[str] = None
11793
11794            if command.command == 'val':
11795                command = cast(commands.LiteralValue, command)
11796                result = commands.resolveValue(command.value, scope)
11797                commands.pushCurrentValue(scope, result)
11798
11799            elif command.command == 'empty':
11800                command = cast(commands.EstablishCollection, command)
11801                collection = commands.resolveVarName(command.collection, scope)
11802                commands.pushCurrentValue(
11803                    scope,
11804                    {
11805                        'list': [],
11806                        'tuple': (),
11807                        'set': set(),
11808                        'dict': {},
11809                    }[collection]
11810                )
11811
11812            elif command.command == 'append':
11813                command = cast(commands.AppendValue, command)
11814                target = scope['_']
11815                addIt = commands.resolveValue(command.value, scope)
11816                if isinstance(target, list):
11817                    target.append(addIt)
11818                elif isinstance(target, tuple):
11819                    scope['_'] = target + (addIt,)
11820                elif isinstance(target, set):
11821                    target.add(addIt)
11822                elif isinstance(target, dict):
11823                    raise TypeError(
11824                        "'append' command cannot be used with a"
11825                        " dictionary. Use 'set' instead."
11826                    )
11827                else:
11828                    raise TypeError(
11829                        f"Invalid current value for 'append' command."
11830                        f" The current value must be a list, tuple, or"
11831                        f" set, but it was a '{type(target).__name__}'."
11832                    )
11833
11834            elif command.command == 'set':
11835                command = cast(commands.SetValue, command)
11836                target = scope['_']
11837                where = commands.resolveValue(command.location, scope)
11838                what = commands.resolveValue(command.value, scope)
11839                if isinstance(target, list):
11840                    if not isinstance(where, int):
11841                        raise TypeError(
11842                            f"Cannot set item in list: index {where!r}"
11843                            f" is not an integer."
11844                        )
11845                    target[where] = what
11846                elif isinstance(target, tuple):
11847                    if not isinstance(where, int):
11848                        raise TypeError(
11849                            f"Cannot set item in tuple: index {where!r}"
11850                            f" is not an integer."
11851                        )
11852                    if not (
11853                        0 <= where < len(target)
11854                    or -1 >= where >= -len(target)
11855                    ):
11856                        raise IndexError(
11857                            f"Cannot set item in tuple at index"
11858                            f" {where}: Tuple has length {len(target)}."
11859                        )
11860                    scope['_'] = target[:where] + (what,) + target[where + 1:]
11861                elif isinstance(target, set):
11862                    if what:
11863                        target.add(where)
11864                    else:
11865                        try:
11866                            target.remove(where)
11867                        except KeyError:
11868                            pass
11869                elif isinstance(target, dict):
11870                    target[where] = what
11871
11872            elif command.command == 'pop':
11873                command = cast(commands.PopValue, command)
11874                target = scope['_']
11875                if isinstance(target, list):
11876                    result = target.pop()
11877                    commands.pushCurrentValue(scope, result)
11878                elif isinstance(target, tuple):
11879                    result = target[-1]
11880                    updated = target[:-1]
11881                    scope['__'] = updated
11882                    scope['_'] = result
11883                else:
11884                    raise TypeError(
11885                        f"Cannot 'pop' from a {type(target).__name__}"
11886                        f" (current value must be a list or tuple)."
11887                    )
11888
11889            elif command.command == 'get':
11890                command = cast(commands.GetValue, command)
11891                target = scope['_']
11892                where = commands.resolveValue(command.location, scope)
11893                if isinstance(target, list):
11894                    if not isinstance(where, int):
11895                        raise TypeError(
11896                            f"Cannot get item from list: index"
11897                            f" {where!r} is not an integer."
11898                        )
11899                elif isinstance(target, tuple):
11900                    if not isinstance(where, int):
11901                        raise TypeError(
11902                            f"Cannot get item from tuple: index"
11903                            f" {where!r} is not an integer."
11904                        )
11905                elif isinstance(target, set):
11906                    result = where in target
11907                    commands.pushCurrentValue(scope, result)
11908                elif isinstance(target, dict):
11909                    result = target[where]
11910                    commands.pushCurrentValue(scope, result)
11911                else:
11912                    result = getattr(target, where)
11913                    commands.pushCurrentValue(scope, result)
11914
11915            elif command.command == 'remove':
11916                command = cast(commands.RemoveValue, command)
11917                target = scope['_']
11918                where = commands.resolveValue(command.location, scope)
11919                if isinstance(target, (list, tuple)):
11920                    # this cast is not correct but suppresses warnings
11921                    # given insufficient narrowing by MyPy
11922                    target = cast(Tuple[Any, ...], target)
11923                    if not isinstance(where, int):
11924                        raise TypeError(
11925                            f"Cannot remove item from list or tuple:"
11926                            f" index {where!r} is not an integer."
11927                        )
11928                    scope['_'] = target[:where] + target[where + 1:]
11929                elif isinstance(target, set):
11930                    target.remove(where)
11931                elif isinstance(target, dict):
11932                    del target[where]
11933                else:
11934                    raise TypeError(
11935                        f"Cannot use 'remove' on a/an"
11936                        f" {type(target).__name__}."
11937                    )
11938
11939            elif command.command == 'op':
11940                command = cast(commands.ApplyOperator, command)
11941                left = commands.resolveValue(command.left, scope)
11942                right = commands.resolveValue(command.right, scope)
11943                op = command.op
11944                if op == '+':
11945                    result = left + right
11946                elif op == '-':
11947                    result = left - right
11948                elif op == '*':
11949                    result = left * right
11950                elif op == '/':
11951                    result = left / right
11952                elif op == '//':
11953                    result = left // right
11954                elif op == '**':
11955                    result = left ** right
11956                elif op == '%':
11957                    result = left % right
11958                elif op == '^':
11959                    result = left ^ right
11960                elif op == '|':
11961                    result = left | right
11962                elif op == '&':
11963                    result = left & right
11964                elif op == 'and':
11965                    result = left and right
11966                elif op == 'or':
11967                    result = left or right
11968                elif op == '<':
11969                    result = left < right
11970                elif op == '>':
11971                    result = left > right
11972                elif op == '<=':
11973                    result = left <= right
11974                elif op == '>=':
11975                    result = left >= right
11976                elif op == '==':
11977                    result = left == right
11978                elif op == 'is':
11979                    result = left is right
11980                else:
11981                    raise RuntimeError("Invalid operator '{op}'.")
11982
11983                commands.pushCurrentValue(scope, result)
11984
11985            elif command.command == 'unary':
11986                command = cast(commands.ApplyUnary, command)
11987                value = commands.resolveValue(command.value, scope)
11988                op = command.op
11989                if op == '-':
11990                    result = -value
11991                elif op == '~':
11992                    result = ~value
11993                elif op == 'not':
11994                    result = not value
11995
11996                commands.pushCurrentValue(scope, result)
11997
11998            elif command.command == 'assign':
11999                command = cast(commands.VariableAssignment, command)
12000                varname = commands.resolveVarName(command.varname, scope)
12001                value = commands.resolveValue(command.value, scope)
12002                scope[varname] = value
12003
12004            elif command.command == 'delete':
12005                command = cast(commands.VariableDeletion, command)
12006                varname = commands.resolveVarName(command.varname, scope)
12007                del scope[varname]
12008
12009            elif command.command == 'load':
12010                command = cast(commands.LoadVariable, command)
12011                varname = commands.resolveVarName(command.varname, scope)
12012                commands.pushCurrentValue(scope, scope[varname])
12013
12014            elif command.command == 'call':
12015                command = cast(commands.FunctionCall, command)
12016                function = command.function
12017                if function.startswith('$'):
12018                    function = commands.resolveValue(function, scope)
12019
12020                toCall: Callable
12021                args: Tuple[str, ...]
12022                kwargs: Dict[str, Any]
12023
12024                if command.target == 'builtin':
12025                    toCall = commands.COMMAND_BUILTINS[function]
12026                    args = (scope['_'],)
12027                    kwargs = {}
12028                    if toCall == round:
12029                        if 'ndigits' in scope:
12030                            kwargs['ndigits'] = scope['ndigits']
12031                    elif toCall == range and args[0] is None:
12032                        start = scope.get('start', 0)
12033                        stop = scope['stop']
12034                        step = scope.get('step', 1)
12035                        args = (start, stop, step)
12036
12037                else:
12038                    if command.target == 'stored':
12039                        toCall = function
12040                    elif command.target == 'graph':
12041                        toCall = getattr(self.getSituation().graph, function)
12042                    elif command.target == 'exploration':
12043                        toCall = getattr(self, function)
12044                    else:
12045                        raise TypeError(
12046                            f"Invalid call target '{command.target}'"
12047                            f" (must be one of 'builtin', 'stored',"
12048                            f" 'graph', or 'exploration'."
12049                        )
12050
12051                    # Fill in arguments via kwargs defined in scope
12052                    args = ()
12053                    kwargs = {}
12054                    signature = inspect.signature(toCall)
12055                    # TODO: Maybe try some type-checking here?
12056                    for argName, param in signature.parameters.items():
12057                        if param.kind == inspect.Parameter.VAR_POSITIONAL:
12058                            if argName in scope:
12059                                args = args + tuple(scope[argName])
12060                            # Else leave args as-is
12061                        elif param.kind == inspect.Parameter.KEYWORD_ONLY:
12062                            # These must have a default
12063                            if argName in scope:
12064                                kwargs[argName] = scope[argName]
12065                        elif param.kind == inspect.Parameter.VAR_KEYWORD:
12066                            # treat as a dictionary
12067                            if argName in scope:
12068                                argsToUse = scope[argName]
12069                                if not isinstance(argsToUse, dict):
12070                                    raise TypeError(
12071                                        f"Variable '{argName}' must"
12072                                        f" hold a dictionary when"
12073                                        f" calling function"
12074                                        f" '{toCall.__name__} which"
12075                                        f" uses that argument as a"
12076                                        f" keyword catchall."
12077                                    )
12078                                kwargs.update(scope[argName])
12079                        else:  # a normal parameter
12080                            if argName in scope:
12081                                args = args + (scope[argName],)
12082                            elif param.default == inspect.Parameter.empty:
12083                                raise TypeError(
12084                                    f"No variable named '{argName}' has"
12085                                    f" been defined to supply the"
12086                                    f" required parameter with that"
12087                                    f" name for function"
12088                                    f" '{toCall.__name__}'."
12089                                )
12090
12091                result = toCall(*args, **kwargs)
12092                commands.pushCurrentValue(scope, result)
12093
12094            elif command.command == 'skip':
12095                command = cast(commands.SkipCommands, command)
12096                doIt = commands.resolveValue(command.condition, scope)
12097                if doIt:
12098                    skip = commands.resolveValue(command.amount, scope)
12099                    if not isinstance(skip, (int, str)):
12100                        raise TypeError(
12101                            f"Skip amount must be an integer or a label"
12102                            f" name (got {skip!r})."
12103                        )
12104
12105            elif command.command == 'label':
12106                command = cast(commands.Label, command)
12107                label = commands.resolveValue(command.name, scope)
12108                if not isinstance(label, str):
12109                    raise TypeError(
12110                        f"Label name must be a string (got {label!r})."
12111                    )
12112
12113            else:
12114                raise ValueError(
12115                    f"Invalid command type: {command.command!r}"
12116                )
12117        except ValueError as e:
12118            raise commands.CommandValueError(command, line, e)
12119        except TypeError as e:
12120            raise commands.CommandTypeError(command, line, e)
12121        except IndexError as e:
12122            raise commands.CommandIndexError(command, line, e)
12123        except KeyError as e:
12124            raise commands.CommandKeyError(command, line, e)
12125        except Exception as e:
12126            raise commands.CommandOtherError(command, line, e)
12127
12128        return (scope, skip, label)
12129
12130    def runCommandBlock(
12131        self,
12132        block: List[commands.Command],
12133        scope: Optional[commands.Scope] = None
12134    ) -> commands.Scope:
12135        """
12136        Runs a list of commands, using the given scope (or creating a new
12137        empty scope if none was provided). Returns the scope after
12138        running all of the commands, which may also edit the exploration
12139        and/or the current graph of course.
12140
12141        Note that if a skip command would skip past the end of the
12142        block, execution will end. If a skip command would skip before
12143        the beginning of the block, execution will start from the first
12144        command.
12145
12146        Example:
12147
12148        >>> e = DiscreteExploration()
12149        >>> scope = e.runCommandBlock([
12150        ...    commands.command('assign', 'decision', "'START'"),
12151        ...    commands.command('call', 'exploration', 'start'),
12152        ...    commands.command('assign', 'where', '$decision'),
12153        ...    commands.command('assign', 'transition', "'left'"),
12154        ...    commands.command('call', 'exploration', 'observe'),
12155        ...    commands.command('assign', 'transition', "'right'"),
12156        ...    commands.command('call', 'exploration', 'observe'),
12157        ...    commands.command('call', 'graph', 'destinationsFrom'),
12158        ...    commands.command('call', 'builtin', 'print'),
12159        ...    commands.command('assign', 'transition', "'right'"),
12160        ...    commands.command('assign', 'destination', "'EastRoom'"),
12161        ...    commands.command('call', 'exploration', 'explore'),
12162        ... ])
12163        {'left': 1, 'right': 2}
12164        >>> scope['decision']
12165        'START'
12166        >>> scope['where']
12167        'START'
12168        >>> scope['_']  # result of 'explore' call is dest ID
12169        2
12170        >>> scope['transition']
12171        'right'
12172        >>> scope['destination']
12173        'EastRoom'
12174        >>> g = e.getSituation().graph
12175        >>> len(e)
12176        3
12177        >>> len(g)
12178        3
12179        >>> g.namesListing(g)
12180        '  0 (START)\\n  1 (_u.0)\\n  2 (EastRoom)\\n'
12181        """
12182        if scope is None:
12183            scope = {}
12184
12185        labelPositions: Dict[str, List[int]] = {}
12186
12187        # Keep going until we've exhausted the commands list
12188        index = 0
12189        while index < len(block):
12190
12191            # Execute the next command
12192            scope, skip, label = self.runCommand(
12193                block[index],
12194                scope,
12195                index + 1
12196            )
12197
12198            # Increment our index, or apply a skip
12199            if skip is None:
12200                index = index + 1
12201
12202            elif isinstance(skip, int):  # Integer skip value
12203                if skip < 0:
12204                    index += skip
12205                    if index < 0:  # can't skip before the start
12206                        index = 0
12207                else:
12208                    index += skip + 1  # may end loop if we skip too far
12209
12210            else:  # must be a label name
12211                if skip in labelPositions:  # an established label
12212                    # We jump to the last previous index, or if there
12213                    # are none, to the first future index.
12214                    prevIndices = [
12215                        x
12216                        for x in labelPositions[skip]
12217                        if x < index
12218                    ]
12219                    futureIndices = [
12220                        x
12221                        for x in labelPositions[skip]
12222                        if x >= index
12223                    ]
12224                    if len(prevIndices) > 0:
12225                        index = max(prevIndices)
12226                    else:
12227                        index = min(futureIndices)
12228                else:  # must be a forward-reference
12229                    for future in range(index + 1, len(block)):
12230                        inspect = block[future]
12231                        if inspect.command == 'label':
12232                            inspect = cast(commands.Label, inspect)
12233                            if inspect.name == skip:
12234                                index = future
12235                                break
12236                    else:
12237                        raise KeyError(
12238                            f"Skip command indicated a jump to label"
12239                            f" {skip!r} but that label had not already"
12240                            f" been defined and there is no future"
12241                            f" label with that name either (future"
12242                            f" labels based on variables cannot be"
12243                            f" skipped to from above as their names"
12244                            f" are not known yet)."
12245                        )
12246
12247            # If there's a label, record it
12248            if label is not None:
12249                labelPositions.setdefault(label, []).append(index)
12250
12251            # And now the while loop continues, or ends if we're at the
12252            # end of the commands list.
12253
12254        # Return the scope object.
12255        return scope
12256
12257    @staticmethod
12258    def example() -> 'DiscreteExploration':
12259        """
12260        Returns a little example exploration. Has a few decisions
12261        including one that's unexplored, and uses a few steps to explore
12262        them.
12263
12264        >>> e = DiscreteExploration.example()
12265        >>> len(e)
12266        7
12267        >>> def pg(n):
12268        ...     print(e[n].graph.namesListing(e[n].graph))
12269        >>> pg(0)
12270          0 (House)
12271        <BLANKLINE>
12272        >>> pg(1)
12273          0 (House)
12274          1 (_u.0)
12275          2 (_u.1)
12276          3 (_u.2)
12277        <BLANKLINE>
12278        >>> pg(2)
12279          0 (House)
12280          1 (_u.0)
12281          2 (_u.1)
12282          3 (Yard)
12283          4 (_u.3)
12284          5 (_u.4)
12285        <BLANKLINE>
12286        >>> pg(3)
12287          0 (House)
12288          1 (_u.0)
12289          2 (_u.1)
12290          3 (Yard)
12291          4 (_u.3)
12292          5 (_u.4)
12293        <BLANKLINE>
12294        >>> pg(4)
12295          0 (House)
12296          1 (_u.0)
12297          2 (Cellar)
12298          3 (Yard)
12299          5 (_u.4)
12300        <BLANKLINE>
12301        >>> pg(5)
12302          0 (House)
12303          1 (_u.0)
12304          2 (Cellar)
12305          3 (Yard)
12306          5 (_u.4)
12307        <BLANKLINE>
12308        >>> pg(6)
12309          0 (House)
12310          1 (_u.0)
12311          2 (Cellar)
12312          3 (Yard)
12313          5 (Lane)
12314        <BLANKLINE>
12315        """
12316        result = DiscreteExploration()
12317        result.start("House")
12318        result.observeAll("House", "ladder", "stairsDown", "frontDoor")
12319        result.explore("frontDoor", "Yard", "frontDoor")
12320        result.observe("Yard", "cellarDoors")
12321        result.observe("Yard", "frontGate")
12322        result.retrace("frontDoor")
12323        result.explore("stairsDown", "Cellar", "stairsUp")
12324        result.observe("Cellar", "stairsOut")
12325        result.returnTo("stairsOut", "Yard", "cellarDoors")
12326        result.explore("frontGate", "Lane", "redGate")
12327        return result

A list of Situations each of which contains a DecisionGraph representing exploration over time, with States containing FocalContext information for each step and 'taken' values for the transition selected (at a particular decision) in that step. Each decision graph represents a new state of the world (and/or new knowledge about a persisting state of the world), and the 'taken' transition in one situation transition indicates which option was selected, or what event happened to cause update(s). Depending on the resolution, it could represent a close record of every decision made or a more coarse set of snapshots from gameplay with more time in between.

The steps of the exploration can also be tagged and annotated (see tagStep and annotateStep).

It also holds a layouts field that includes zero or more base.Layouts by name.

When a new DiscreteExploration is created, it starts out with an empty Situation that contains an empty DecisionGraph. Use the start method to name the starting decision point and set things up for other methods.

Tracking of player goals and destinations is also planned (see the quest, progress, complete, destination, and arrive methods). TODO: That

situations: List[exploration.base.Situation]
layouts: Dict[str, Dict[int, Tuple[float, float]]]
@staticmethod
def fromGraph( graph: DecisionGraph, state: Optional[exploration.base.State] = None) -> DiscreteExploration:
7424    @staticmethod
7425    def fromGraph(
7426        graph: DecisionGraph,
7427        state: Optional[base.State] = None
7428    ) -> 'DiscreteExploration':
7429        """
7430        Creates an exploration which has just a single step whose graph
7431        is the entire specified graph, with the specified decision as
7432        the primary decision (if any). The graph is copied, so that
7433        changes to the exploration will not modify it. A starting state
7434        may also be specified if desired, although if not an empty state
7435        will be used (a provided starting state is NOT copied, but used
7436        directly).
7437
7438        Example:
7439
7440        >>> g = DecisionGraph()
7441        >>> g.addDecision('Room1')
7442        0
7443        >>> g.addDecision('Room2')
7444        1
7445        >>> g.addTransition('Room1', 'door', 'Room2', 'door')
7446        >>> e = DiscreteExploration.fromGraph(g)
7447        >>> len(e)
7448        1
7449        >>> e.getSituation().graph == g
7450        True
7451        >>> e.getActiveDecisions()
7452        set()
7453        >>> e.primaryDecision() is None
7454        True
7455        >>> e.observe('Room1', 'hatch')
7456        2
7457        >>> e.getSituation().graph == g
7458        False
7459        >>> e.getSituation().graph.destinationsFrom('Room1')
7460        {'door': 1, 'hatch': 2}
7461        >>> g.destinationsFrom('Room1')
7462        {'door': 1}
7463        """
7464        result = DiscreteExploration()
7465        result.situations[0] = base.Situation(
7466            graph=copy.deepcopy(graph),
7467            state=base.emptyState() if state is None else state,
7468            type='pending',
7469            action=None,
7470            saves={},
7471            tags={},
7472            annotations=[]
7473        )
7474        return result

Creates an exploration which has just a single step whose graph is the entire specified graph, with the specified decision as the primary decision (if any). The graph is copied, so that changes to the exploration will not modify it. A starting state may also be specified if desired, although if not an empty state will be used (a provided starting state is NOT copied, but used directly).

Example:

>>> g = DecisionGraph()
>>> g.addDecision('Room1')
0
>>> g.addDecision('Room2')
1
>>> g.addTransition('Room1', 'door', 'Room2', 'door')
>>> e = DiscreteExploration.fromGraph(g)
>>> len(e)
1
>>> e.getSituation().graph == g
True
>>> e.getActiveDecisions()
set()
>>> e.primaryDecision() is None
True
>>> e.observe('Room1', 'hatch')
2
>>> e.getSituation().graph == g
False
>>> e.getSituation().graph.destinationsFrom('Room1')
{'door': 1, 'hatch': 2}
>>> g.destinationsFrom('Room1')
{'door': 1}
def getSituation(self, step: int = -1) -> exploration.base.Situation:
7495    def getSituation(self, step: int = -1) -> base.Situation:
7496        """
7497        Returns a `base.Situation` named tuple detailing the state of
7498        the exploration at a given step (or at the current step if no
7499        argument is given). Note that this method works the same
7500        way as indexing the exploration: see `__getitem__`.
7501
7502        Raises an `IndexError` if asked for a step that's out-of-range.
7503        """
7504        return self[step]

Returns a base.Situation named tuple detailing the state of the exploration at a given step (or at the current step if no argument is given). Note that this method works the same way as indexing the exploration: see __getitem__.

Raises an IndexError if asked for a step that's out-of-range.

def primaryDecision(self, step: int = -1) -> Optional[int]:
7506    def primaryDecision(self, step: int = -1) -> Optional[base.DecisionID]:
7507        """
7508        Returns the current primary `base.DecisionID`, or the primary
7509        decision from a specific step if one is specified. This may be
7510        `None` for some steps, but mostly it's the destination of the
7511        transition taken in the previous step.
7512        """
7513        return self[step].state['primaryDecision']

Returns the current primary base.DecisionID, or the primary decision from a specific step if one is specified. This may be None for some steps, but mostly it's the destination of the transition taken in the previous step.

def effectiveCapabilities(self, step: int = -1) -> exploration.base.CapabilitySet:
7515    def effectiveCapabilities(
7516        self,
7517        step: int = -1
7518    ) -> base.CapabilitySet:
7519        """
7520        Returns the effective capability set for the specified step
7521        (default is the last/current step). See
7522        `base.effectiveCapabilities`.
7523        """
7524        return base.effectiveCapabilitySet(self.getSituation(step).state)

Returns the effective capability set for the specified step (default is the last/current step). See base.effectiveCapabilities.

def getCommonContext(self, step: Optional[int] = None) -> exploration.base.FocalContext:
7526    def getCommonContext(
7527        self,
7528        step: Optional[int] = None
7529    ) -> base.FocalContext:
7530        """
7531        Returns the common `FocalContext` at the specified step, or at
7532        the current step if no argument is given. Raises an `IndexError`
7533        if an invalid step is specified.
7534        """
7535        if step is None:
7536            step = -1
7537        state = self.getSituation(step).state
7538        return state['common']

Returns the common FocalContext at the specified step, or at the current step if no argument is given. Raises an IndexError if an invalid step is specified.

def getActiveContext(self, step: Optional[int] = None) -> exploration.base.FocalContext:
7540    def getActiveContext(
7541        self,
7542        step: Optional[int] = None
7543    ) -> base.FocalContext:
7544        """
7545        Returns the active `FocalContext` at the specified step, or at
7546        the current step if no argument is provided. Raises an
7547        `IndexError` if an invalid step is specified.
7548        """
7549        if step is None:
7550            step = -1
7551        state = self.getSituation(step).state
7552        return state['contexts'][state['activeContext']]

Returns the active FocalContext at the specified step, or at the current step if no argument is provided. Raises an IndexError if an invalid step is specified.

def addFocalContext(self, name: str) -> None:
7554    def addFocalContext(self, name: base.FocalContextName) -> None:
7555        """
7556        Adds a new empty focal context to our set of focal contexts (see
7557        `emptyFocalContext`). Use `setActiveContext` to swap to it.
7558        Raises a `FocalContextCollisionError` if the name is already in
7559        use.
7560        """
7561        contextMap = self.getSituation().state['contexts']
7562        if name in contextMap:
7563            raise FocalContextCollisionError(
7564                f"Cannot add focal context {name!r}: a focal context"
7565                f" with that name already exists."
7566            )
7567        contextMap[name] = base.emptyFocalContext()

Adds a new empty focal context to our set of focal contexts (see emptyFocalContext). Use setActiveContext to swap to it. Raises a FocalContextCollisionError if the name is already in use.

def setActiveContext(self, which: str) -> None:
7569    def setActiveContext(self, which: base.FocalContextName) -> None:
7570        """
7571        Sets the active context to the named focal context, creating it
7572        if it did not already exist (makes changes to the current
7573        situation only). Does not add an exploration step (use
7574        `advanceSituation` with a 'swap' action for that).
7575        """
7576        state = self.getSituation().state
7577        contextMap = state['contexts']
7578        if which not in contextMap:
7579            self.addFocalContext(which)
7580        state['activeContext'] = which

Sets the active context to the named focal context, creating it if it did not already exist (makes changes to the current situation only). Does not add an exploration step (use advanceSituation with a 'swap' action for that).

def createDomain( self, name: str, focalization: Literal['singular', 'plural', 'spreading'] = 'singular', makeActive: bool = False, inCommon: Union[bool, Literal['both']] = 'both') -> None:
7582    def createDomain(
7583        self,
7584        name: base.Domain,
7585        focalization: base.DomainFocalization = 'singular',
7586        makeActive: bool = False,
7587        inCommon: Union[bool, Literal["both"]] = "both"
7588    ) -> None:
7589        """
7590        Creates a new domain with the given focalization type, in either
7591        the common context (`inCommon` = `True`) the active context
7592        (`inCommon` = `False`) or both (the default; `inCommon` = 'both').
7593        The domain's focalization will be set to the given
7594        `focalization` value (default 'singular') and it will have no
7595        active decisions. Raises a `DomainCollisionError` if a domain
7596        with the specified name already exists.
7597
7598        Creates the domain in the current situation.
7599
7600        If `makeActive` is set to `True` (default is `False`) then the
7601        domain will be made active in whichever context(s) it's created
7602        in.
7603        """
7604        now = self.getSituation()
7605        state = now.state
7606        modify = []
7607        if inCommon in (True, "both"):
7608            modify.append(('common', state['common']))
7609        if inCommon in (False, "both"):
7610            acName = state['activeContext']
7611            modify.append(
7612                ('current ({repr(acName)})', state['contexts'][acName])
7613            )
7614
7615        for (fcType, fc) in modify:
7616            if name in fc['focalization']:
7617                raise DomainCollisionError(
7618                    f"Cannot create domain {repr(name)} because a"
7619                    f" domain with that name already exists in the"
7620                    f" {fcType} focal context."
7621                )
7622            fc['focalization'][name] = focalization
7623            if makeActive:
7624                fc['activeDomains'].add(name)
7625            if focalization == "spreading":
7626                fc['activeDecisions'][name] = set()
7627            elif focalization == "plural":
7628                fc['activeDecisions'][name] = {}
7629            else:
7630                fc['activeDecisions'][name] = None

Creates a new domain with the given focalization type, in either the common context (inCommon = True) the active context (inCommon = False) or both (the default; inCommon = 'both'). The domain's focalization will be set to the given focalization value (default 'singular') and it will have no active decisions. Raises a DomainCollisionError if a domain with the specified name already exists.

Creates the domain in the current situation.

If makeActive is set to True (default is False) then the domain will be made active in whichever context(s) it's created in.

def activateDomain( self, domain: str, activate: bool = True, inContext: Literal['common', 'active'] = 'active') -> None:
7632    def activateDomain(
7633        self,
7634        domain: base.Domain,
7635        activate: bool = True,
7636        inContext: base.ContextSpecifier = "active"
7637    ) -> None:
7638        """
7639        Sets the given domain as active (or inactive if 'activate' is
7640        given as `False`) in the specified context (default "active").
7641
7642        Modifies the current situation.
7643        """
7644        fc: base.FocalContext
7645        if inContext == "active":
7646            fc = self.getActiveContext()
7647        elif inContext == "common":
7648            fc = self.getCommonContext()
7649
7650        if activate:
7651            fc['activeDomains'].add(domain)
7652        else:
7653            try:
7654                fc['activeDomains'].remove(domain)
7655            except KeyError:
7656                pass

Sets the given domain as active (or inactive if 'activate' is given as False) in the specified context (default "active").

Modifies the current situation.

def createTriggerGroup(self, name: str) -> int:
7658    def createTriggerGroup(
7659        self,
7660        name: base.DecisionName
7661    ) -> base.DecisionID:
7662        """
7663        Creates a new trigger group with the given name, returning the
7664        decision ID for that trigger group. If this is the first trigger
7665        group being created, also creates the `TRIGGERS_DOMAIN` domain
7666        as a spreading-focalized domain that's active in the common
7667        context (but does NOT set the created trigger group as an active
7668        decision in that domain).
7669
7670        You can use 'goto' effects to activate trigger domains via
7671        consequences, and 'retreat' effects to deactivate them.
7672
7673        Creating a second trigger group with the same name as another
7674        results in a `ValueError`.
7675
7676        TODO: Retreat effects
7677        """
7678        ctx = self.getCommonContext()
7679        if TRIGGERS_DOMAIN not in ctx['focalization']:
7680            self.createDomain(
7681                TRIGGERS_DOMAIN,
7682                focalization='spreading',
7683                makeActive=True,
7684                inCommon=True
7685            )
7686
7687        graph = self.getSituation().graph
7688        if graph.getDecision(
7689            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
7690        ) is not None:
7691            raise ValueError(
7692                f"Cannot create trigger group {name!r}: a trigger group"
7693                f" with that name already exists."
7694            )
7695
7696        return self.getSituation().graph.triggerGroupID(name)

Creates a new trigger group with the given name, returning the decision ID for that trigger group. If this is the first trigger group being created, also creates the TRIGGERS_DOMAIN domain as a spreading-focalized domain that's active in the common context (but does NOT set the created trigger group as an active decision in that domain).

You can use 'goto' effects to activate trigger domains via consequences, and 'retreat' effects to deactivate them.

Creating a second trigger group with the same name as another results in a ValueError.

TODO: Retreat effects

def toggleTriggerGroup(self, name: str, setActive: Optional[bool] = None):
7698    def toggleTriggerGroup(
7699        self,
7700        name: base.DecisionName,
7701        setActive: Union[bool, None] = None
7702    ):
7703        """
7704        Toggles whether the specified trigger group (a decision in the
7705        `TRIGGERS_DOMAIN`) is active or not. Pass `True` or `False` as
7706        the `setActive` argument (instead of the default `None`) to set
7707        the state directly instead of toggling it.
7708
7709        Note that trigger groups are decisions in a spreading-focalized
7710        domain, so they can be activated or deactivated by the 'goto'
7711        and 'retreat' effects as well.
7712
7713        This does not affect whether the `TRIGGERS_DOMAIN` itself is
7714        active (normally it would always be active).
7715
7716        Raises a `MissingDecisionError` if the specified trigger group
7717        does not exist yet, including when the entire `TRIGGERS_DOMAIN`
7718        does not exist. Raises a `KeyError` if the target group exists
7719        but the `TRIGGERS_DOMAIN` has not been set up properly.
7720        """
7721        ctx = self.getCommonContext()
7722        tID = self.getSituation().graph.resolveDecision(
7723            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
7724        )
7725        activeGroups = ctx['activeDecisions'][TRIGGERS_DOMAIN]
7726        assert isinstance(activeGroups, set)
7727        if tID in activeGroups:
7728            if setActive is not True:
7729                activeGroups.remove(tID)
7730        else:
7731            if setActive is not False:
7732                activeGroups.add(tID)

Toggles whether the specified trigger group (a decision in the TRIGGERS_DOMAIN) is active or not. Pass True or False as the setActive argument (instead of the default None) to set the state directly instead of toggling it.

Note that trigger groups are decisions in a spreading-focalized domain, so they can be activated or deactivated by the 'goto' and 'retreat' effects as well.

This does not affect whether the TRIGGERS_DOMAIN itself is active (normally it would always be active).

Raises a MissingDecisionError if the specified trigger group does not exist yet, including when the entire TRIGGERS_DOMAIN does not exist. Raises a KeyError if the target group exists but the TRIGGERS_DOMAIN has not been set up properly.

def getActiveDecisions( self, step: Optional[int] = None, inCommon: Union[bool, Literal['both']] = 'both') -> Set[int]:
7734    def getActiveDecisions(
7735        self,
7736        step: Optional[int] = None,
7737        inCommon: Union[bool, Literal["both"]] = "both"
7738    ) -> Set[base.DecisionID]:
7739        """
7740        Returns the set of active decisions at the given step index, or
7741        at the current step if no step is specified. Raises an
7742        `IndexError` if the step index is out of bounds (see `__len__`).
7743        May return an empty set if no decisions are active.
7744
7745        If `inCommon` is set to "both" (the default) then decisions
7746        active in either the common or active context are returned. Set
7747        it to `True` or `False` to return only decisions active in the
7748        common (when `True`) or  active (when `False`) context.
7749        """
7750        if step is None:
7751            step = -1
7752        state = self.getSituation(step).state
7753        if inCommon == "both":
7754            return base.combinedDecisionSet(state)
7755        elif inCommon is True:
7756            return base.activeDecisionSet(state['common'])
7757        elif inCommon is False:
7758            return base.activeDecisionSet(
7759                state['contexts'][state['activeContext']]
7760            )
7761        else:
7762            raise ValueError(
7763                f"Invalid inCommon value {repr(inCommon)} (must be"
7764                f" 'both', True, or False)."
7765            )

Returns the set of active decisions at the given step index, or at the current step if no step is specified. Raises an IndexError if the step index is out of bounds (see __len__). May return an empty set if no decisions are active.

If inCommon is set to "both" (the default) then decisions active in either the common or active context are returned. Set it to True or False to return only decisions active in the common (when True) or active (when False) context.

def setActiveDecisionsAtStep( self, step: int, domain: str, activate: Union[int, Dict[str, Optional[int]], Set[int]], inCommon: bool = False) -> None:
7767    def setActiveDecisionsAtStep(
7768        self,
7769        step: int,
7770        domain: base.Domain,
7771        activate: Union[
7772            base.DecisionID,
7773            Dict[base.FocalPointName, Optional[base.DecisionID]],
7774            Set[base.DecisionID]
7775        ],
7776        inCommon: bool = False
7777    ) -> None:
7778        """
7779        Changes the activation status of decisions in the active
7780        `FocalContext` at the specified step, for the specified domain
7781        (see `currentActiveContext`). Does this without adding an
7782        exploration step, which is unusual: normally you should use
7783        another method like `warp` to update active decisions.
7784
7785        Note that this does not change which domains are active, and
7786        setting active decisions in inactive domains does not make those
7787        decisions active overall.
7788
7789        Which decisions to activate or deactivate are specified as
7790        either a single `DecisionID`, a list of them, or a set of them,
7791        depending on the `DomainFocalization` setting in the selected
7792        `FocalContext` for the specified domain. A `TypeError` will be
7793        raised if the wrong kind of decision information is provided. If
7794        the focalization context does not have any focalization value for
7795        the domain in question, it will be set based on the kind of
7796        active decision information specified.
7797
7798        A `MissingDecisionError` will be raised if a decision is
7799        included which is not part of the current `DecisionGraph`.
7800        The provided information will overwrite the previous active
7801        decision information.
7802
7803        If `inCommon` is set to `True`, then decisions are activated or
7804        deactivated in the common context, instead of in the active
7805        context.
7806
7807        Example:
7808
7809        >>> e = DiscreteExploration()
7810        >>> e.getActiveDecisions()
7811        set()
7812        >>> graph = e.getSituation().graph
7813        >>> graph.addDecision('A')
7814        0
7815        >>> graph.addDecision('B')
7816        1
7817        >>> graph.addDecision('C')
7818        2
7819        >>> e.setActiveDecisionsAtStep(0, 'main', 0)
7820        >>> e.getActiveDecisions()
7821        {0}
7822        >>> e.setActiveDecisionsAtStep(0, 'main', 1)
7823        >>> e.getActiveDecisions()
7824        {1}
7825        >>> graph = e.getSituation().graph
7826        >>> graph.addDecision('One', domain='numbers')
7827        3
7828        >>> graph.addDecision('Two', domain='numbers')
7829        4
7830        >>> graph.addDecision('Three', domain='numbers')
7831        5
7832        >>> graph.addDecision('Bear', domain='animals')
7833        6
7834        >>> graph.addDecision('Spider', domain='animals')
7835        7
7836        >>> graph.addDecision('Eel', domain='animals')
7837        8
7838        >>> ac = e.getActiveContext()
7839        >>> ac['focalization']['numbers'] = 'plural'
7840        >>> ac['focalization']['animals'] = 'spreading'
7841        >>> ac['activeDecisions']['numbers'] = {'a': None, 'b': None}
7842        >>> ac['activeDecisions']['animals'] = set()
7843        >>> cc = e.getCommonContext()
7844        >>> cc['focalization']['numbers'] = 'plural'
7845        >>> cc['focalization']['animals'] = 'spreading'
7846        >>> cc['activeDecisions']['numbers'] = {'z': None}
7847        >>> cc['activeDecisions']['animals'] = set()
7848        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 3, 'b': 3})
7849        >>> e.getActiveDecisions()
7850        {1}
7851        >>> e.activateDomain('numbers')
7852        >>> e.getActiveDecisions()
7853        {1, 3}
7854        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 4, 'b': None})
7855        >>> e.getActiveDecisions()
7856        {1, 4}
7857        >>> # Wrong domain for the decision ID:
7858        >>> e.setActiveDecisionsAtStep(0, 'main', 3)
7859        Traceback (most recent call last):
7860        ...
7861        ValueError...
7862        >>> # Wrong domain for one of the decision IDs:
7863        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 2, 'b': None})
7864        Traceback (most recent call last):
7865        ...
7866        ValueError...
7867        >>> # Wrong kind of decision information provided.
7868        >>> e.setActiveDecisionsAtStep(0, 'numbers', 3)
7869        Traceback (most recent call last):
7870        ...
7871        TypeError...
7872        >>> e.getActiveDecisions()
7873        {1, 4}
7874        >>> e.setActiveDecisionsAtStep(0, 'animals', {6, 7})
7875        >>> e.getActiveDecisions()
7876        {1, 4}
7877        >>> e.activateDomain('animals')
7878        >>> e.getActiveDecisions()
7879        {1, 4, 6, 7}
7880        >>> e.setActiveDecisionsAtStep(0, 'animals', {8})
7881        >>> e.getActiveDecisions()
7882        {8, 1, 4}
7883        >>> e.setActiveDecisionsAtStep(1, 'main', 2)  # invalid step
7884        Traceback (most recent call last):
7885        ...
7886        IndexError...
7887        >>> e.setActiveDecisionsAtStep(0, 'novel', 0)  # domain mismatch
7888        Traceback (most recent call last):
7889        ...
7890        ValueError...
7891
7892        Example of active/common contexts:
7893
7894        >>> e = DiscreteExploration()
7895        >>> graph = e.getSituation().graph
7896        >>> graph.addDecision('A')
7897        0
7898        >>> graph.addDecision('B')
7899        1
7900        >>> e.activateDomain('main', inContext="common")
7901        >>> e.setActiveDecisionsAtStep(0, 'main', 0, inCommon=True)
7902        >>> e.getActiveDecisions()
7903        {0}
7904        >>> e.setActiveDecisionsAtStep(0, 'main', None)
7905        >>> e.getActiveDecisions()
7906        {0}
7907        >>> # (Still active since it's active in the common context)
7908        >>> e.setActiveDecisionsAtStep(0, 'main', 1)
7909        >>> e.getActiveDecisions()
7910        {0, 1}
7911        >>> e.setActiveDecisionsAtStep(0, 'main', 1, inCommon=True)
7912        >>> e.getActiveDecisions()
7913        {1}
7914        >>> e.setActiveDecisionsAtStep(0, 'main', None, inCommon=True)
7915        >>> e.getActiveDecisions()
7916        {1}
7917        >>> # (Still active since it's active in the active context)
7918        >>> e.setActiveDecisionsAtStep(0, 'main', None)
7919        >>> e.getActiveDecisions()
7920        set()
7921        """
7922        now = self.getSituation(step)
7923        graph = now.graph
7924        if inCommon:
7925            context = self.getCommonContext(step)
7926        else:
7927            context = self.getActiveContext(step)
7928
7929        defaultFocalization: base.DomainFocalization = 'singular'
7930        if isinstance(activate, base.DecisionID):
7931            defaultFocalization = 'singular'
7932        elif isinstance(activate, dict):
7933            defaultFocalization = 'plural'
7934        elif isinstance(activate, set):
7935            defaultFocalization = 'spreading'
7936        elif domain not in context['focalization']:
7937            raise TypeError(
7938                f"Domain {domain!r} has no focalization in the"
7939                f" {'common' if inCommon else 'active'} context,"
7940                f" and the specified position doesn't imply one."
7941            )
7942
7943        focalization = base.getDomainFocalization(
7944            context,
7945            domain,
7946            defaultFocalization
7947        )
7948
7949        # Check domain & existence of decision(s) in question
7950        if activate is None:
7951            pass
7952        elif isinstance(activate, base.DecisionID):
7953            if activate not in graph:
7954                raise MissingDecisionError(
7955                    f"There is no decision {activate} at step {step}."
7956                )
7957            if graph.domainFor(activate) != domain:
7958                raise ValueError(
7959                    f"Can't set active decisions in domain {domain!r}"
7960                    f" to decision {graph.identityOf(activate)} because"
7961                    f" that decision is in actually in domain"
7962                    f" {graph.domainFor(activate)!r}."
7963                )
7964        elif isinstance(activate, dict):
7965            for fpName, pos in activate.items():
7966                if pos is None:
7967                    continue
7968                if pos not in graph:
7969                    raise MissingDecisionError(
7970                        f"There is no decision {pos} at step {step}."
7971                    )
7972                if graph.domainFor(pos) != domain:
7973                    raise ValueError(
7974                        f"Can't set active decision for focal point"
7975                        f" {fpName!r} in domain {domain!r}"
7976                        f" to decision {graph.identityOf(pos)} because"
7977                        f" that decision is in actually in domain"
7978                        f" {graph.domainFor(pos)!r}."
7979                    )
7980        elif isinstance(activate, set):
7981            for pos in activate:
7982                if pos not in graph:
7983                    raise MissingDecisionError(
7984                        f"There is no decision {pos} at step {step}."
7985                    )
7986                if graph.domainFor(pos) != domain:
7987                    raise ValueError(
7988                        f"Can't set {graph.identityOf(pos)} as an"
7989                        f" active decision in domain {domain!r} to"
7990                        f" decision because that decision is in"
7991                        f" actually in domain {graph.domainFor(pos)!r}."
7992                    )
7993        else:
7994            raise TypeError(
7995                f"Domain {domain!r} has no focalization in the"
7996                f" {'common' if inCommon else 'active'} context,"
7997                f" and the specified position doesn't imply one:"
7998                f"\n{activate!r}"
7999            )
8000
8001        if focalization == 'singular':
8002            if activate is None or isinstance(activate, base.DecisionID):
8003                if activate is not None:
8004                    targetDomain = graph.domainFor(activate)
8005                    if activate not in graph:
8006                        raise MissingDecisionError(
8007                            f"There is no decision {activate} in the"
8008                            f" graph at step {step}."
8009                        )
8010                    elif targetDomain != domain:
8011                        raise ValueError(
8012                            f"At step {step}, decision {activate} cannot"
8013                            f" be the active decision for domain"
8014                            f" {repr(domain)} because it is in a"
8015                            f" different domain ({repr(targetDomain)})."
8016                        )
8017                context['activeDecisions'][domain] = activate
8018            else:
8019                raise TypeError(
8020                    f"{'Common' if inCommon else 'Active'} focal"
8021                    f" context at step {step} has {repr(focalization)}"
8022                    f" focalization for domain {repr(domain)}, so the"
8023                    f" active decision must be a single decision or"
8024                    f" None.\n(You provided: {repr(activate)})"
8025                )
8026        elif focalization == 'plural':
8027            if (
8028                isinstance(activate, dict)
8029            and all(
8030                    isinstance(k, base.FocalPointName)
8031                    for k in activate.keys()
8032                )
8033            and all(
8034                    v is None or isinstance(v, base.DecisionID)
8035                    for v in activate.values()
8036                )
8037            ):
8038                for v in activate.values():
8039                    if v is not None:
8040                        targetDomain = graph.domainFor(v)
8041                        if v not in graph:
8042                            raise MissingDecisionError(
8043                                f"There is no decision {v} in the graph"
8044                                f" at step {step}."
8045                            )
8046                        elif targetDomain != domain:
8047                            raise ValueError(
8048                                f"At step {step}, decision {activate}"
8049                                f" cannot be an active decision for"
8050                                f" domain {repr(domain)} because it is"
8051                                f" in a different domain"
8052                                f" ({repr(targetDomain)})."
8053                            )
8054                context['activeDecisions'][domain] = activate
8055            else:
8056                raise TypeError(
8057                    f"{'Common' if inCommon else 'Active'} focal"
8058                    f" context at step {step} has {repr(focalization)}"
8059                    f" focalization for domain {repr(domain)}, so the"
8060                    f" active decision must be a dictionary mapping"
8061                    f" focal point names to decision IDs (or Nones)."
8062                    f"\n(You provided: {repr(activate)})"
8063                )
8064        elif focalization == 'spreading':
8065            if (
8066                isinstance(activate, set)
8067            and all(isinstance(x, base.DecisionID) for x in activate)
8068            ):
8069                for x in activate:
8070                    targetDomain = graph.domainFor(x)
8071                    if x not in graph:
8072                        raise MissingDecisionError(
8073                            f"There is no decision {x} in the graph"
8074                            f" at step {step}."
8075                        )
8076                    elif targetDomain != domain:
8077                        raise ValueError(
8078                            f"At step {step}, decision {activate}"
8079                            f" cannot be an active decision for"
8080                            f" domain {repr(domain)} because it is"
8081                            f" in a different domain"
8082                            f" ({repr(targetDomain)})."
8083                        )
8084                context['activeDecisions'][domain] = activate
8085            else:
8086                raise TypeError(
8087                    f"{'Common' if inCommon else 'Active'} focal"
8088                    f" context at step {step} has {repr(focalization)}"
8089                    f" focalization for domain {repr(domain)}, so the"
8090                    f" active decision must be a set of decision IDs"
8091                    f"\n(You provided: {repr(activate)})"
8092                )
8093        else:
8094            raise RuntimeError(
8095                f"Invalid focalization value {repr(focalization)} for"
8096                f" domain {repr(domain)} at step {step}."
8097            )

Changes the activation status of decisions in the active FocalContext at the specified step, for the specified domain (see currentActiveContext). Does this without adding an exploration step, which is unusual: normally you should use another method like warp to update active decisions.

Note that this does not change which domains are active, and setting active decisions in inactive domains does not make those decisions active overall.

Which decisions to activate or deactivate are specified as either a single DecisionID, a list of them, or a set of them, depending on the DomainFocalization setting in the selected FocalContext for the specified domain. A TypeError will be raised if the wrong kind of decision information is provided. If the focalization context does not have any focalization value for the domain in question, it will be set based on the kind of active decision information specified.

A MissingDecisionError will be raised if a decision is included which is not part of the current DecisionGraph. The provided information will overwrite the previous active decision information.

If inCommon is set to True, then decisions are activated or deactivated in the common context, instead of in the active context.

Example:

>>> e = DiscreteExploration()
>>> e.getActiveDecisions()
set()
>>> graph = e.getSituation().graph
>>> graph.addDecision('A')
0
>>> graph.addDecision('B')
1
>>> graph.addDecision('C')
2
>>> e.setActiveDecisionsAtStep(0, 'main', 0)
>>> e.getActiveDecisions()
{0}
>>> e.setActiveDecisionsAtStep(0, 'main', 1)
>>> e.getActiveDecisions()
{1}
>>> graph = e.getSituation().graph
>>> graph.addDecision('One', domain='numbers')
3
>>> graph.addDecision('Two', domain='numbers')
4
>>> graph.addDecision('Three', domain='numbers')
5
>>> graph.addDecision('Bear', domain='animals')
6
>>> graph.addDecision('Spider', domain='animals')
7
>>> graph.addDecision('Eel', domain='animals')
8
>>> ac = e.getActiveContext()
>>> ac['focalization']['numbers'] = 'plural'
>>> ac['focalization']['animals'] = 'spreading'
>>> ac['activeDecisions']['numbers'] = {'a': None, 'b': None}
>>> ac['activeDecisions']['animals'] = set()
>>> cc = e.getCommonContext()
>>> cc['focalization']['numbers'] = 'plural'
>>> cc['focalization']['animals'] = 'spreading'
>>> cc['activeDecisions']['numbers'] = {'z': None}
>>> cc['activeDecisions']['animals'] = set()
>>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 3, 'b': 3})
>>> e.getActiveDecisions()
{1}
>>> e.activateDomain('numbers')
>>> e.getActiveDecisions()
{1, 3}
>>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 4, 'b': None})
>>> e.getActiveDecisions()
{1, 4}
>>> # Wrong domain for the decision ID:
>>> e.setActiveDecisionsAtStep(0, 'main', 3)
Traceback (most recent call last):
...
ValueError...
>>> # Wrong domain for one of the decision IDs:
>>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 2, 'b': None})
Traceback (most recent call last):
...
ValueError...
>>> # Wrong kind of decision information provided.
>>> e.setActiveDecisionsAtStep(0, 'numbers', 3)
Traceback (most recent call last):
...
TypeError...
>>> e.getActiveDecisions()
{1, 4}
>>> e.setActiveDecisionsAtStep(0, 'animals', {6, 7})
>>> e.getActiveDecisions()
{1, 4}
>>> e.activateDomain('animals')
>>> e.getActiveDecisions()
{1, 4, 6, 7}
>>> e.setActiveDecisionsAtStep(0, 'animals', {8})
>>> e.getActiveDecisions()
{8, 1, 4}
>>> e.setActiveDecisionsAtStep(1, 'main', 2)  # invalid step
Traceback (most recent call last):
...
IndexError...
>>> e.setActiveDecisionsAtStep(0, 'novel', 0)  # domain mismatch
Traceback (most recent call last):
...
ValueError...

Example of active/common contexts:

>>> e = DiscreteExploration()
>>> graph = e.getSituation().graph
>>> graph.addDecision('A')
0
>>> graph.addDecision('B')
1
>>> e.activateDomain('main', inContext="common")
>>> e.setActiveDecisionsAtStep(0, 'main', 0, inCommon=True)
>>> e.getActiveDecisions()
{0}
>>> e.setActiveDecisionsAtStep(0, 'main', None)
>>> e.getActiveDecisions()
{0}
>>> # (Still active since it's active in the common context)
>>> e.setActiveDecisionsAtStep(0, 'main', 1)
>>> e.getActiveDecisions()
{0, 1}
>>> e.setActiveDecisionsAtStep(0, 'main', 1, inCommon=True)
>>> e.getActiveDecisions()
{1}
>>> e.setActiveDecisionsAtStep(0, 'main', None, inCommon=True)
>>> e.getActiveDecisions()
{1}
>>> # (Still active since it's active in the active context)
>>> e.setActiveDecisionsAtStep(0, 'main', None)
>>> e.getActiveDecisions()
set()
def movementAtStep( self, step: int = -1) -> Tuple[Union[int, Set[int], NoneType], Optional[str], Union[int, Set[int], NoneType]]:
8099    def movementAtStep(self, step: int = -1) -> Tuple[
8100        Union[base.DecisionID, Set[base.DecisionID], None],
8101        Optional[base.Transition],
8102        Union[base.DecisionID, Set[base.DecisionID], None]
8103    ]:
8104        """
8105        Given a step number, returns information about the starting
8106        decision, transition taken, and destination decision for that
8107        step. Not all steps have all of those, so some items may be
8108        `None`.
8109
8110        For steps where there is no action, where a decision is still
8111        pending, or where the action type is 'focus', 'swap', 'focalize',
8112        or 'revertTo', the result will be `(None, None, None)`, unless a
8113        primary decision is available in which case the first item in the
8114        tuple will be that decision. For 'start' actions, the starting
8115        position and transition will be `None` (again unless the step had
8116        a primary decision) but the destination will be the ID of the
8117        node started at. For 'revertTo' actions, the destination will be
8118        the primary decision of the state reverted to, if available.
8119
8120        Also, if the action taken has multiple potential or actual start
8121        or end points, these may be sets of decision IDs instead of
8122        single IDs.
8123
8124        Note that the primary decision of the starting state is usually
8125        used as the from-decision, but in some cases an action dictates
8126        taking a transition from a different decision, and this function
8127        will return that decision as the from-decision.
8128
8129        TODO: Examples!
8130
8131        TODO: Account for bounce/follow/goto effects!!!
8132        """
8133        now = self.getSituation(step)
8134        action = now.action
8135        graph = now.graph
8136        primary = now.state['primaryDecision']
8137
8138        if action is None:
8139            return (primary, None, None)
8140
8141        aType = action[0]
8142        fromID: Optional[base.DecisionID]
8143        destID: Optional[base.DecisionID]
8144        transition: base.Transition
8145        outcomes: List[bool]
8146
8147        if aType in ('noAction', 'focus', 'swap', 'focalize'):
8148            return (primary, None, None)
8149        elif aType == 'start':
8150            assert len(action) == 7
8151            where = cast(
8152                Union[
8153                    base.DecisionID,
8154                    Dict[base.FocalPointName, base.DecisionID],
8155                    Set[base.DecisionID]
8156                ],
8157                action[1]
8158            )
8159            if isinstance(where, dict):
8160                where = set(where.values())
8161            return (primary, None, where)
8162        elif aType in ('take', 'explore'):
8163            if (
8164                (len(action) == 4 or len(action) == 7)
8165            and isinstance(action[2], base.DecisionID)
8166            ):
8167                fromID = action[2]
8168                assert isinstance(action[3], tuple)
8169                transition, outcomes = action[3]
8170                if (
8171                    action[0] == "explore"
8172                and isinstance(action[4], base.DecisionID)
8173                ):
8174                    destID = action[4]
8175                else:
8176                    destID = graph.getDestination(fromID, transition)
8177                return (fromID, transition, destID)
8178            elif (
8179                (len(action) == 3 or len(action) == 6)
8180            and isinstance(action[1], tuple)
8181            and isinstance(action[2], base.Transition)
8182            and len(action[1]) == 3
8183            and action[1][0] in get_args(base.ContextSpecifier)
8184            and isinstance(action[1][1], base.Domain)
8185            and isinstance(action[1][2], base.FocalPointName)
8186            ):
8187                fromID = base.resolvePosition(now.state, action[1])
8188                if fromID is None:
8189                    raise InvalidActionError(
8190                        f"{aType!r} action at step {step} has position"
8191                        f" {action[1]!r} which cannot be resolved to a"
8192                        f" decision."
8193                    )
8194                transition, outcomes = action[2]
8195                if (
8196                    action[0] == "explore"
8197                and isinstance(action[3], base.DecisionID)
8198                ):
8199                    destID = action[3]
8200                else:
8201                    destID = graph.getDestination(fromID, transition)
8202                return (fromID, transition, destID)
8203            else:
8204                raise InvalidActionError(
8205                    f"Malformed {aType!r} action:\n{repr(action)}"
8206                )
8207        elif aType == 'warp':
8208            if len(action) != 3:
8209                raise InvalidActionError(
8210                    f"Malformed 'warp' action:\n{repr(action)}"
8211                )
8212            dest = action[2]
8213            assert isinstance(dest, base.DecisionID)
8214            if action[1] in get_args(base.ContextSpecifier):
8215                # Unspecified starting point; find active decisions in
8216                # same domain if primary is None
8217                if primary is not None:
8218                    return (primary, None, dest)
8219                else:
8220                    toDomain = now.graph.domainFor(dest)
8221                    # TODO: Could check destination focalization here...
8222                    active = self.getActiveDecisions(step)
8223                    sameDomain = set(
8224                        dID
8225                        for dID in active
8226                        if now.graph.domainFor(dID) == toDomain
8227                    )
8228                    if len(sameDomain) == 1:
8229                        return (
8230                            list(sameDomain)[0],
8231                            None,
8232                            dest
8233                        )
8234                    else:
8235                        return (
8236                            sameDomain,
8237                            None,
8238                            dest
8239                        )
8240            else:
8241                if (
8242                    not isinstance(action[1], tuple)
8243                or not len(action[1]) == 3
8244                or not action[1][0] in get_args(base.ContextSpecifier)
8245                or not isinstance(action[1][1], base.Domain)
8246                or not isinstance(action[1][2], base.FocalPointName)
8247                ):
8248                    raise InvalidActionError(
8249                        f"Malformed 'warp' action:\n{repr(action)}"
8250                    )
8251                return (
8252                    base.resolvePosition(now.state, action[1]),
8253                    None,
8254                    dest
8255                )
8256        elif aType == 'revertTo':
8257            assert len(action) == 3  # type, save slot, & aspects
8258            if primary is not None:
8259                cameFrom = primary
8260            nextSituation = self.getSituation(step + 1)
8261            wentTo = nextSituation.state['primaryDecision']
8262            return (primary, None, wentTo)
8263        else:
8264            raise InvalidActionError(
8265                f"Action taken had invalid action type {repr(aType)}:"
8266                f"\n{repr(action)}"
8267            )

Given a step number, returns information about the starting decision, transition taken, and destination decision for that step. Not all steps have all of those, so some items may be None.

For steps where there is no action, where a decision is still pending, or where the action type is 'focus', 'swap', 'focalize', or 'revertTo', the result will be (None, None, None), unless a primary decision is available in which case the first item in the tuple will be that decision. For 'start' actions, the starting position and transition will be None (again unless the step had a primary decision) but the destination will be the ID of the node started at. For 'revertTo' actions, the destination will be the primary decision of the state reverted to, if available.

Also, if the action taken has multiple potential or actual start or end points, these may be sets of decision IDs instead of single IDs.

Note that the primary decision of the starting state is usually used as the from-decision, but in some cases an action dictates taking a transition from a different decision, and this function will return that decision as the from-decision.

TODO: Examples!

TODO: Account for bounce/follow/goto effects!!!

def latestStepWithDecision(self, dID: int, startFrom: int = -1) -> int:
8269    def latestStepWithDecision(
8270        self,
8271        dID: base.DecisionID,
8272        startFrom: int = -1
8273    ) -> int:
8274        """
8275        Scans backwards through exploration steps until it finds a graph
8276        that contains a decision with the specified ID, and returns the
8277        step number of that step. Instead of starting from the last step,
8278        you can tell it to start from a different step (either positive
8279        or negative index) via `startFrom`. Raises a
8280        `MissingDecisionError` if there is no such step.
8281        """
8282        if startFrom < 0:
8283            startFrom = len(self) + startFrom
8284        for step in range(startFrom, -1, -1):
8285            graph = self.getSituation(step).graph
8286            try:
8287                return step
8288            except MissingDecisionError:
8289                continue
8290        raise MissingDecisionError(
8291            f"Decision {dID!r} does not exist at any step of the"
8292            f" exploration."
8293        )

Scans backwards through exploration steps until it finds a graph that contains a decision with the specified ID, and returns the step number of that step. Instead of starting from the last step, you can tell it to start from a different step (either positive or negative index) via startFrom. Raises a MissingDecisionError if there is no such step.

def latestDecisionInfo(self, dID: int) -> DecisionInfo:
8295    def latestDecisionInfo(self, dID: base.DecisionID) -> DecisionInfo:
8296        """
8297        Looks up decision info for the given decision in the latest step
8298        in which that decision exists (which will usually be the final
8299        exploration step, unless the decision was merged or otherwise
8300        removed along the way). This will raise a `MissingDecisionError`
8301        only if there is no step at which the specified decision exists.
8302        """
8303        for step in range(len(self) - 1, -1, -1):
8304            graph = self.getSituation(step).graph
8305            try:
8306                return graph.decisionInfo(dID)
8307            except MissingDecisionError:
8308                continue
8309        raise MissingDecisionError(
8310            f"Decision {dID!r} does not exist at any step of the"
8311            f" exploration."
8312        )

Looks up decision info for the given decision in the latest step in which that decision exists (which will usually be the final exploration step, unless the decision was merged or otherwise removed along the way). This will raise a MissingDecisionError only if there is no step at which the specified decision exists.

def latestTransitionProperties(self, dID: int, transition: str) -> TransitionProperties:
8314    def latestTransitionProperties(
8315        self,
8316        dID: base.DecisionID,
8317        transition: base.Transition
8318    ) -> TransitionProperties:
8319        """
8320        Looks up transition properties for the transition with the given
8321        name outgoing from the decision with the given ID, in the latest
8322        step in which a transiiton with that name from that decision
8323        exists (which will usually be the final exploration step, unless
8324        transitions get removed/renamed along the way). Note that because
8325        a transition can be deleted and later added back (unlike
8326        decisions where an ID will not be re-used), it's possible there
8327        are two or more different transitions that meet the
8328        specifications at different points in time, and this will always
8329        return the properties of the last of them. This will raise a
8330        `MissingDecisionError` if there is no step at which the specified
8331        decision exists, and a `MissingTransitionError` if the target
8332        decision exists at some step but never has a transition with the
8333        specified name.
8334        """
8335        sawDecision: Optional[int] = None
8336        for step in range(len(self) - 1, -1, -1):
8337            graph = self.getSituation(step).graph
8338            try:
8339                return graph.getTransitionProperties(dID, transition)
8340            except (MissingDecisionError, MissingTransitionError) as e:
8341                if (
8342                    sawDecision is None
8343                and isinstance(e, MissingTransitionError)
8344                ):
8345                    sawDecision = step
8346                continue
8347        if sawDecision is None:
8348            raise MissingDecisionError(
8349                f"Decision {dID!r} does not exist at any step of the"
8350                f" exploration."
8351            )
8352        else:
8353            raise MissingTransitionError(
8354                f"Decision {dID!r} does exist (last seen at step"
8355                f" {sawDecision}) but it never has an outgoing"
8356                f" transition named {transition!r}."
8357            )

Looks up transition properties for the transition with the given name outgoing from the decision with the given ID, in the latest step in which a transiiton with that name from that decision exists (which will usually be the final exploration step, unless transitions get removed/renamed along the way). Note that because a transition can be deleted and later added back (unlike decisions where an ID will not be re-used), it's possible there are two or more different transitions that meet the specifications at different points in time, and this will always return the properties of the last of them. This will raise a MissingDecisionError if there is no step at which the specified decision exists, and a MissingTransitionError if the target decision exists at some step but never has a transition with the specified name.

def tagStep( self, tagOrTags: Union[str, Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]], tagValue: Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]], type[exploration.base.NoTagValue]] = <class 'exploration.base.NoTagValue'>, step: int = -1) -> None:
8359    def tagStep(
8360        self,
8361        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
8362        tagValue: Union[
8363            base.TagValue,
8364            type[base.NoTagValue]
8365        ] = base.NoTagValue,
8366        step: int = -1
8367    ) -> None:
8368        """
8369        Adds a tag (or multiple tags) to the current step, or to a
8370        specific step if `n` is given as an integer rather than the
8371        default `None`. A tag value should be supplied when a tag is
8372        given (unless you want to use the default of `1`), but it's a
8373        `ValueError` to supply a tag value when a dictionary of tags to
8374        update is provided.
8375        """
8376        if isinstance(tagOrTags, base.Tag):
8377            if tagValue is base.NoTagValue:
8378                tagValue = 1
8379
8380            # Not sure why this is necessary...
8381            tagValue = cast(base.TagValue, tagValue)
8382
8383            self.getSituation(step).tags.update({tagOrTags: tagValue})
8384        else:
8385            self.getSituation(step).tags.update(tagOrTags)

Adds a tag (or multiple tags) to the current step, or to a specific step if n is given as an integer rather than the default None. A tag value should be supplied when a tag is given (unless you want to use the default of 1), but it's a ValueError to supply a tag value when a dictionary of tags to update is provided.

def annotateStep( self, annotationOrAnnotations: Union[str, Sequence[str]], step: Optional[int] = None) -> None:
8387    def annotateStep(
8388        self,
8389        annotationOrAnnotations: Union[
8390            base.Annotation,
8391            Sequence[base.Annotation]
8392        ],
8393        step: Optional[int] = None
8394    ) -> None:
8395        """
8396        Adds an annotation to the current exploration step, or to a
8397        specific step if `n` is given as an integer rather than the
8398        default `None`.
8399        """
8400        if step is None:
8401            step = -1
8402        if isinstance(annotationOrAnnotations, base.Annotation):
8403            self.getSituation(step).annotations.append(
8404                annotationOrAnnotations
8405            )
8406        else:
8407            self.getSituation(step).annotations.extend(
8408                annotationOrAnnotations
8409            )

Adds an annotation to the current exploration step, or to a specific step if n is given as an integer rather than the default None.

def hasCapability( self, capability: str, step: Optional[int] = None, inCommon: Union[bool, Literal['both']] = 'both') -> bool:
8411    def hasCapability(
8412        self,
8413        capability: base.Capability,
8414        step: Optional[int] = None,
8415        inCommon: Union[bool, Literal['both']] = "both"
8416    ) -> bool:
8417        """
8418        Returns True if the player currently had the specified
8419        capability, at the specified exploration step, and False
8420        otherwise. Checks the current state if no step is given. Does
8421        NOT return true if the game state means that the player has an
8422        equivalent for that capability (see
8423        `hasCapabilityOrEquivalent`).
8424
8425        Normally, `inCommon` is set to 'both' by default and so if
8426        either the common `FocalContext` or the active one has the
8427        capability, this will return `True`. `inCommon` may instead be
8428        set to `True` or `False` to ask about just the common (or
8429        active) focal context.
8430        """
8431        state = self.getSituation().state
8432        commonCapabilities = state['common']['capabilities']\
8433            ['capabilities']  # noqa
8434        activeCapabilities = state['contexts'][state['activeContext']]\
8435            ['capabilities']['capabilities']  # noqa
8436
8437        if inCommon == 'both':
8438            return (
8439                capability in commonCapabilities
8440             or capability in activeCapabilities
8441            )
8442        elif inCommon is True:
8443            return capability in commonCapabilities
8444        elif inCommon is False:
8445            return capability in activeCapabilities
8446        else:
8447            raise ValueError(
8448                f"Invalid inCommon value (must be False, True, or"
8449                f" 'both'; got {repr(inCommon)})."
8450            )

Returns True if the player currently had the specified capability, at the specified exploration step, and False otherwise. Checks the current state if no step is given. Does NOT return true if the game state means that the player has an equivalent for that capability (see hasCapabilityOrEquivalent).

Normally, inCommon is set to 'both' by default and so if either the common FocalContext or the active one has the capability, this will return True. inCommon may instead be set to True or False to ask about just the common (or active) focal context.

def hasCapabilityOrEquivalent( self, capability: str, step: Optional[int] = None, location: Optional[Set[int]] = None) -> bool:
8452    def hasCapabilityOrEquivalent(
8453        self,
8454        capability: base.Capability,
8455        step: Optional[int] = None,
8456        location: Optional[Set[base.DecisionID]] = None
8457    ) -> bool:
8458        """
8459        Works like `hasCapability`, but also returns `True` if the
8460        player counts as having the specified capability via an equivalence
8461        that's part of the current graph. As with `hasCapability`, the
8462        optional `step` argument is used to specify which step to check,
8463        with the current step being used as the default.
8464
8465        The `location` set can specify where to start looking for
8466        mechanisms; if left unspecified active decisions for that step
8467        will be used.
8468        """
8469        if step is None:
8470            step = -1
8471        if location is None:
8472            location = self.getActiveDecisions(step)
8473        situation = self.getSituation(step)
8474        return base.hasCapabilityOrEquivalent(
8475            capability,
8476            base.RequirementContext(
8477                state=situation.state,
8478                graph=situation.graph,
8479                searchFrom=location
8480            )
8481        )

Works like hasCapability, but also returns True if the player counts as having the specified capability via an equivalence that's part of the current graph. As with hasCapability, the optional step argument is used to specify which step to check, with the current step being used as the default.

The location set can specify where to start looking for mechanisms; if left unspecified active decisions for that step will be used.

def gainCapabilityNow(self, capability: str, inCommon: bool = False) -> None:
8483    def gainCapabilityNow(
8484        self,
8485        capability: base.Capability,
8486        inCommon: bool = False
8487    ) -> None:
8488        """
8489        Modifies the current game state to add the specified `Capability`
8490        to the player's capabilities. No changes are made to the current
8491        graph.
8492
8493        If `inCommon` is set to `True` (default is `False`) then the
8494        capability will be added to the common `FocalContext` and will
8495        therefore persist even when a focal context switch happens.
8496        Normally, it will be added to the currently-active focal
8497        context.
8498        """
8499        state = self.getSituation().state
8500        if inCommon:
8501            context = state['common']
8502        else:
8503            context = state['contexts'][state['activeContext']]
8504        context['capabilities']['capabilities'].add(capability)

Modifies the current game state to add the specified Capability to the player's capabilities. No changes are made to the current graph.

If inCommon is set to True (default is False) then the capability will be added to the common FocalContext and will therefore persist even when a focal context switch happens. Normally, it will be added to the currently-active focal context.

def loseCapabilityNow( self, capability: str, inCommon: Union[bool, Literal['both']] = 'both') -> None:
8506    def loseCapabilityNow(
8507        self,
8508        capability: base.Capability,
8509        inCommon: Union[bool, Literal['both']] = "both"
8510    ) -> None:
8511        """
8512        Modifies the current game state to remove the specified `Capability`
8513        from the player's capabilities. Does nothing if the player
8514        doesn't already have that capability.
8515
8516        By default, this removes the capability from both the common
8517        capabilities set and the active `FocalContext`'s capabilities
8518        set, so that afterwards the player will definitely not have that
8519        capability. However, if you set `inCommon` to either `True` or
8520        `False`, it will remove the capability from just the common
8521        capabilities set (if `True`) or just the active capabilities set
8522        (if `False`). In these cases, removing the capability from just
8523        one capability set will not actually remove it in terms of the
8524        `hasCapability` result if it had been present in the other set.
8525        Set `inCommon` to "both" to use the default behavior explicitly.
8526        """
8527        now = self.getSituation()
8528        if inCommon in ("both", True):
8529            context = now.state['common']
8530            try:
8531                context['capabilities']['capabilities'].remove(capability)
8532            except KeyError:
8533                pass
8534        elif inCommon in ("both", False):
8535            context = now.state['contexts'][now.state['activeContext']]
8536            try:
8537                context['capabilities']['capabilities'].remove(capability)
8538            except KeyError:
8539                pass
8540        else:
8541            raise ValueError(
8542                f"Invalid inCommon value (must be False, True, or"
8543                f" 'both'; got {repr(inCommon)})."
8544            )

Modifies the current game state to remove the specified Capability from the player's capabilities. Does nothing if the player doesn't already have that capability.

By default, this removes the capability from both the common capabilities set and the active FocalContext's capabilities set, so that afterwards the player will definitely not have that capability. However, if you set inCommon to either True or False, it will remove the capability from just the common capabilities set (if True) or just the active capabilities set (if False). In these cases, removing the capability from just one capability set will not actually remove it in terms of the hasCapability result if it had been present in the other set. Set inCommon to "both" to use the default behavior explicitly.

def tokenCountNow(self, tokenType: str) -> Optional[int]:
8546    def tokenCountNow(self, tokenType: base.Token) -> Optional[int]:
8547        """
8548        Returns the number of tokens the player currently has of a given
8549        type. Returns `None` if the player has never acquired or lost
8550        tokens of that type.
8551
8552        This method adds together tokens from the common and active
8553        focal contexts.
8554        """
8555        state = self.getSituation().state
8556        commonContext = state['common']
8557        activeContext = state['contexts'][state['activeContext']]
8558        base = commonContext['capabilities']['tokens'].get(tokenType)
8559        if base is None:
8560            return activeContext['capabilities']['tokens'].get(tokenType)
8561        else:
8562            return base + activeContext['capabilities']['tokens'].get(
8563                tokenType,
8564                0
8565            )

Returns the number of tokens the player currently has of a given type. Returns None if the player has never acquired or lost tokens of that type.

This method adds together tokens from the common and active focal contexts.

def adjustTokensNow(self, tokenType: str, amount: int, inCommon: bool = False) -> None:
8567    def adjustTokensNow(
8568        self,
8569        tokenType: base.Token,
8570        amount: int,
8571        inCommon: bool = False
8572    ) -> None:
8573        """
8574        Modifies the current game state to add the specified number of
8575        `Token`s of the given type to the player's tokens. No changes are
8576        made to the current graph. Reduce the number of tokens by
8577        supplying a negative amount; note that negative token amounts
8578        are possible.
8579
8580        By default, the number of tokens for the current active
8581        `FocalContext` will be adjusted. However, if `inCommon` is set
8582        to `True`, then the number of tokens for the common context will
8583        be adjusted instead.
8584        """
8585        # TODO: Custom token caps!
8586        state = self.getSituation().state
8587        if inCommon:
8588            context = state['common']
8589        else:
8590            context = state['contexts'][state['activeContext']]
8591        tokens = context['capabilities']['tokens']
8592        tokens[tokenType] = tokens.get(tokenType, 0) + amount

Modifies the current game state to add the specified number of Tokens of the given type to the player's tokens. No changes are made to the current graph. Reduce the number of tokens by supplying a negative amount; note that negative token amounts are possible.

By default, the number of tokens for the current active FocalContext will be adjusted. However, if inCommon is set to True, then the number of tokens for the common context will be adjusted instead.

def setTokensNow(self, tokenType: str, amount: int, inCommon: bool = False) -> None:
8594    def setTokensNow(
8595        self,
8596        tokenType: base.Token,
8597        amount: int,
8598        inCommon: bool = False
8599    ) -> None:
8600        """
8601        Modifies the current game state to set number of `Token`s of the
8602        given type to a specific amount, regardless of the old value. No
8603        changes are made to the current graph.
8604
8605        By default this sets the number of tokens for the active
8606        `FocalContext`. But if you set `inCommon` to `True`, it will
8607        set the number of tokens in the common context instead.
8608        """
8609        # TODO: Custom token caps!
8610        state = self.getSituation().state
8611        if inCommon:
8612            context = state['common']
8613        else:
8614            context = state['contexts'][state['activeContext']]
8615        context['capabilities']['tokens'][tokenType] = amount

Modifies the current game state to set number of Tokens of the given type to a specific amount, regardless of the old value. No changes are made to the current graph.

By default this sets the number of tokens for the active FocalContext. But if you set inCommon to True, it will set the number of tokens in the common context instead.

def lookupMechanism( self, mechanism: str, step: Optional[int] = None, where: Union[Tuple[Union[int, exploration.base.DecisionSpecifier, str], Optional[str]], Collection[Union[int, exploration.base.DecisionSpecifier, str]], NoneType] = None) -> int:
8617    def lookupMechanism(
8618        self,
8619        mechanism: base.MechanismName,
8620        step: Optional[int] = None,
8621        where: Union[
8622            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]],
8623            Collection[base.AnyDecisionSpecifier],
8624            None
8625        ] = None
8626    ) -> base.MechanismID:
8627        """
8628        Looks up a mechanism ID by name, in the graph for the specified
8629        step. The `where` argument specifies where to start looking,
8630        which helps disambiguate. It can be a tuple with a decision
8631        specifier and `None` to start from a single decision, or with a
8632        decision specifier and a transition name to start from either
8633        end of that transition. It can also be `None` to look at global
8634        mechanisms and then all decisions directly, although this
8635        increases the chance of a `AmbiguousMechanismError`. Finally, it
8636        can be some other non-tuple collection of decision specifiers to
8637        start from that set.
8638
8639        If no step is specified, uses the current step.
8640        """
8641        if step is None:
8642            step = -1
8643        situation = self.getSituation(step)
8644        graph = situation.graph
8645        searchFrom: Collection[base.AnyDecisionSpecifier]
8646        if where is None:
8647            searchFrom = set()
8648        elif isinstance(where, tuple):
8649            if len(where) != 2:
8650                raise ValueError(
8651                    f"Mechanism lookup location was a tuple with an"
8652                    f" invalid length (must be length-2 if it's a"
8653                    f" tuple):\n  {repr(where)}"
8654                )
8655            where = cast(
8656                Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]],
8657                where
8658            )
8659            if where[1] is None:
8660                searchFrom = {graph.resolveDecision(where[0])}
8661            else:
8662                searchFrom = graph.bothEnds(where[0], where[1])
8663        else:  # must be a collection of specifiers
8664            searchFrom = cast(Collection[base.AnyDecisionSpecifier], where)
8665        return graph.lookupMechanism(searchFrom, mechanism)

Looks up a mechanism ID by name, in the graph for the specified step. The where argument specifies where to start looking, which helps disambiguate. It can be a tuple with a decision specifier and None to start from a single decision, or with a decision specifier and a transition name to start from either end of that transition. It can also be None to look at global mechanisms and then all decisions directly, although this increases the chance of a AmbiguousMechanismError. Finally, it can be some other non-tuple collection of decision specifiers to start from that set.

If no step is specified, uses the current step.

def mechanismState( self, mechanism: Union[int, str, exploration.base.MechanismSpecifier], where: Optional[Set[int]] = None, step: int = -1) -> Optional[str]:
8667    def mechanismState(
8668        self,
8669        mechanism: base.AnyMechanismSpecifier,
8670        where: Optional[Set[base.DecisionID]] = None,
8671        step: int = -1
8672    ) -> Optional[base.MechanismState]:
8673        """
8674        Returns the current state for the specified mechanism (or the
8675        state at the specified step if a step index is given). `where`
8676        may be provided as a set of decision IDs to indicate where to
8677        search for the named mechanism, or a mechanism ID may be provided
8678        in the first place. Mechanism states are properties of a `State`
8679        but are not associated with focal contexts.
8680        """
8681        situation = self.getSituation(step)
8682        mID = situation.graph.resolveMechanism(mechanism, startFrom=where)
8683        return situation.state['mechanisms'].get(
8684            mID,
8685            base.DEFAULT_MECHANISM_STATE
8686        )

Returns the current state for the specified mechanism (or the state at the specified step if a step index is given). where may be provided as a set of decision IDs to indicate where to search for the named mechanism, or a mechanism ID may be provided in the first place. Mechanism states are properties of a State but are not associated with focal contexts.

def setMechanismStateNow( self, mechanism: Union[int, str, exploration.base.MechanismSpecifier], toState: str, where: Optional[Set[int]] = None) -> None:
8688    def setMechanismStateNow(
8689        self,
8690        mechanism: base.AnyMechanismSpecifier,
8691        toState: base.MechanismState,
8692        where: Optional[Set[base.DecisionID]] = None
8693    ) -> None:
8694        """
8695        Sets the state of the specified mechanism to the specified
8696        state. Mechanisms can only be in one state at once, so this
8697        removes any previous states for that mechanism (note that via
8698        equivalences multiple mechanism states can count as active).
8699
8700        The mechanism can be any kind of mechanism specifier (see
8701        `base.AnyMechanismSpecifier`). If it's not a mechanism ID and
8702        doesn't have its own position information, the 'where' argument
8703        can be used to hint where to search for the mechanism.
8704        """
8705        now = self.getSituation()
8706        mID = now.graph.resolveMechanism(mechanism, startFrom=where)
8707        now.state['mechanisms'][mID] = toState

Sets the state of the specified mechanism to the specified state. Mechanisms can only be in one state at once, so this removes any previous states for that mechanism (note that via equivalences multiple mechanism states can count as active).

The mechanism can be any kind of mechanism specifier (see base.AnyMechanismSpecifier). If it's not a mechanism ID and doesn't have its own position information, the 'where' argument can be used to hint where to search for the mechanism.

def skillLevel(self, skill: str, step: Optional[int] = None) -> Optional[int]:
8709    def skillLevel(
8710        self,
8711        skill: base.Skill,
8712        step: Optional[int] = None
8713    ) -> Optional[base.Level]:
8714        """
8715        Returns the skill level the player had in a given skill at a
8716        given step, or for the current step if no step is specified.
8717        Returns `None` if the player had never acquired or lost levels
8718        in that skill before the specified step (skill level would count
8719        as 0 in that case).
8720
8721        This method adds together levels from the common and active
8722        focal contexts.
8723        """
8724        if step is None:
8725            step = -1
8726        state = self.getSituation(step).state
8727        commonContext = state['common']
8728        activeContext = state['contexts'][state['activeContext']]
8729        base = commonContext['capabilities']['skills'].get(skill)
8730        if base is None:
8731            return activeContext['capabilities']['skills'].get(skill)
8732        else:
8733            return base + activeContext['capabilities']['skills'].get(
8734                skill,
8735                0
8736            )

Returns the skill level the player had in a given skill at a given step, or for the current step if no step is specified. Returns None if the player had never acquired or lost levels in that skill before the specified step (skill level would count as 0 in that case).

This method adds together levels from the common and active focal contexts.

def adjustSkillLevelNow(self, skill: str, levels: int, inCommon: bool = False) -> None:
8738    def adjustSkillLevelNow(
8739        self,
8740        skill: base.Skill,
8741        levels: base.Level,
8742        inCommon: bool = False
8743    ) -> None:
8744        """
8745        Modifies the current game state to add the specified number of
8746        `Level`s of the given skill. No changes are made to the current
8747        graph. Reduce the skill level by supplying negative levels; note
8748        that negative skill levels are possible.
8749
8750        By default, the skill level for the current active
8751        `FocalContext` will be adjusted. However, if `inCommon` is set
8752        to `True`, then the skill level for the common context will be
8753        adjusted instead.
8754        """
8755        # TODO: Custom level caps?
8756        state = self.getSituation().state
8757        if inCommon:
8758            context = state['common']
8759        else:
8760            context = state['contexts'][state['activeContext']]
8761        skills = context['capabilities']['skills']
8762        skills[skill] = skills.get(skill, 0) + levels

Modifies the current game state to add the specified number of Levels of the given skill. No changes are made to the current graph. Reduce the skill level by supplying negative levels; note that negative skill levels are possible.

By default, the skill level for the current active FocalContext will be adjusted. However, if inCommon is set to True, then the skill level for the common context will be adjusted instead.

def setSkillLevelNow(self, skill: str, level: int, inCommon: bool = False) -> None:
8764    def setSkillLevelNow(
8765        self,
8766        skill: base.Skill,
8767        level: base.Level,
8768        inCommon: bool = False
8769    ) -> None:
8770        """
8771        Modifies the current game state to set `Skill` `Level` for the
8772        given skill, regardless of the old value. No changes are made to
8773        the current graph.
8774
8775        By default this sets the skill level for the active
8776        `FocalContext`. But if you set `inCommon` to `True`, it will set
8777        the skill level in the common context instead.
8778        """
8779        # TODO: Custom level caps?
8780        state = self.getSituation().state
8781        if inCommon:
8782            context = state['common']
8783        else:
8784            context = state['contexts'][state['activeContext']]
8785        skills = context['capabilities']['skills']
8786        skills[skill] = level

Modifies the current game state to set Skill Level for the given skill, regardless of the old value. No changes are made to the current graph.

By default this sets the skill level for the active FocalContext. But if you set inCommon to True, it will set the skill level in the common context instead.

def updateRequirementNow( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, requirement: Optional[exploration.base.Requirement]) -> None:
8788    def updateRequirementNow(
8789        self,
8790        decision: base.AnyDecisionSpecifier,
8791        transition: base.Transition,
8792        requirement: Optional[base.Requirement]
8793    ) -> None:
8794        """
8795        Updates the requirement for a specific transition in a specific
8796        decision. Use `None` to remove the requirement for that edge.
8797        """
8798        if requirement is None:
8799            requirement = base.ReqNothing()
8800        self.getSituation().graph.setTransitionRequirement(
8801            decision,
8802            transition,
8803            requirement
8804        )

Updates the requirement for a specific transition in a specific decision. Use None to remove the requirement for that edge.

def isTraversable( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, step: int = -1) -> bool:
8806    def isTraversable(
8807        self,
8808        decision: base.AnyDecisionSpecifier,
8809        transition: base.Transition,
8810        step: int = -1
8811    ) -> bool:
8812        """
8813        Returns True if the specified transition from the specified
8814        decision had its requirement satisfied by the game state at the
8815        specified step (or at the current step if no step is specified).
8816        Raises an `IndexError` if the specified step doesn't exist, and
8817        a `KeyError` if the decision or transition specified does not
8818        exist in the `DecisionGraph` at that step.
8819        """
8820        situation = self.getSituation(step)
8821        req = situation.graph.getTransitionRequirement(decision, transition)
8822        ctx = base.contextForTransition(situation, decision, transition)
8823        fromID = situation.graph.resolveDecision(decision)
8824        return (
8825            req.satisfied(ctx)
8826        and (fromID, transition) not in situation.state['deactivated']
8827        )

Returns True if the specified transition from the specified decision had its requirement satisfied by the game state at the specified step (or at the current step if no step is specified). Raises an IndexError if the specified step doesn't exist, and a KeyError if the decision or transition specified does not exist in the DecisionGraph at that step.

def applyTransitionEffect( self, whichEffect: Tuple[int, str, int], moveWhich: Optional[str] = None) -> Optional[int]:
8829    def applyTransitionEffect(
8830        self,
8831        whichEffect: base.EffectSpecifier,
8832        moveWhich: Optional[base.FocalPointName] = None
8833    ) -> Optional[base.DecisionID]:
8834        """
8835        Applies an effect attached to a transition, taking charges and
8836        delay into account based on the current `Situation`.
8837        Modifies the effect's trigger count (but may not actually
8838        trigger the effect if the charges and/or delay values indicate
8839        not to; see `base.doTriggerEffect`).
8840
8841        If a specific focal point in a plural-focalized domain is
8842        triggering the effect, the focal point name should be specified
8843        via `moveWhich` so that goto `Effect`s can know which focal
8844        point to move when it's not explicitly specified in the effect.
8845        TODO: Test this!
8846
8847        Returns None most of the time, but if a 'goto', 'bounce', or
8848        'follow' effect was applied, it returns the decision ID for that
8849        effect's destination, which would override a transition's normal
8850        destination. If it returns a destination ID, then the exploration
8851        state will already have been updated to set the position there,
8852        and further position updates are not needed.
8853
8854        Note that transition effects which update active decisions will
8855        also update the exploration status of those decisions to
8856        'exploring' if they had been in an unvisited status (see
8857        `updatePosition` and `hasBeenVisited`).
8858
8859        Note: callers should immediately update situation-based variables
8860        that might have been changes by a 'revert' effect.
8861        """
8862        now = self.getSituation()
8863        effect, triggerCount = base.doTriggerEffect(
8864            now.state,
8865            now.graph,
8866            whichEffect
8867        )
8868        if triggerCount is not None:
8869            return self.applyExtraneousEffect(
8870                effect,
8871                where=whichEffect[:2],
8872                moveWhich=moveWhich
8873            )
8874        else:
8875            return None

Applies an effect attached to a transition, taking charges and delay into account based on the current Situation. Modifies the effect's trigger count (but may not actually trigger the effect if the charges and/or delay values indicate not to; see base.doTriggerEffect).

If a specific focal point in a plural-focalized domain is triggering the effect, the focal point name should be specified via moveWhich so that goto Effects can know which focal point to move when it's not explicitly specified in the effect. TODO: Test this!

Returns None most of the time, but if a 'goto', 'bounce', or 'follow' effect was applied, it returns the decision ID for that effect's destination, which would override a transition's normal destination. If it returns a destination ID, then the exploration state will already have been updated to set the position there, and further position updates are not needed.

Note that transition effects which update active decisions will also update the exploration status of those decisions to 'exploring' if they had been in an unvisited status (see updatePosition and hasBeenVisited).

Note: callers should immediately update situation-based variables that might have been changes by a 'revert' effect.

def applyExtraneousEffect( self, effect: exploration.base.Effect, where: Optional[Tuple[Union[int, exploration.base.DecisionSpecifier, str], Optional[str]]] = None, moveWhich: Optional[str] = None, challengePolicy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'specified') -> Optional[int]:
8877    def applyExtraneousEffect(
8878        self,
8879        effect: base.Effect,
8880        where: Optional[
8881            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]]
8882        ] = None,
8883        moveWhich: Optional[base.FocalPointName] = None,
8884        challengePolicy: base.ChallengePolicy = "specified"
8885    ) -> Optional[base.DecisionID]:
8886        """
8887        Applies a single extraneous effect to the state & graph,
8888        *without* accounting for charges or delay values, since the
8889        effect is not part of the graph (use `applyTransitionEffect` to
8890        apply effects that are attached to transitions, which is almost
8891        always the function you should be using). An associated
8892        transition for the extraneous effect can be supplied using the
8893        `where` argument, and effects like 'deactivate' and 'edit' will
8894        affect it (but the effect's charges and delay values will still
8895        be ignored).
8896
8897        If the effect would change the destination of a transition, the
8898        altered destination ID is returned: 'bounce' effects return the
8899        provided decision part of `where`, 'goto' effects return their
8900        target, and 'follow' effects return the destination followed to
8901        (possibly via chained follows in the extreme case). In all other
8902        cases, `None` is returned indicating no change to a normal
8903        destination.
8904
8905        If a specific focal point in a plural-focalized domain is
8906        triggering the effect, the focal point name should be specified
8907        via `moveWhich` so that goto `Effect`s can know which focal
8908        point to move when it's not explicitly specified in the effect.
8909        TODO: Test this!
8910
8911        Note that transition effects which update active decisions will
8912        also update the exploration status of those decisions to
8913        'exploring' if they had been in an unvisited status and will
8914        remove any 'unconfirmed' tag they might still have (see
8915        `updatePosition` and `hasBeenVisited`).
8916
8917        The given `challengePolicy` is applied when traversing further
8918        transitions due to 'follow' effects.
8919
8920        Note: Anyone calling `applyExtraneousEffect` should update any
8921        situation-based variables immediately after the call, as a
8922        'revert' effect may have changed the current graph and/or state.
8923        """
8924        typ = effect['type']
8925        value = effect['value']
8926        applyTo = effect['applyTo']
8927        inCommon = applyTo == 'common'
8928
8929        now = self.getSituation()
8930
8931        if where is not None:
8932            if where[1] is not None:
8933                searchFrom = now.graph.bothEnds(where[0], where[1])
8934            else:
8935                searchFrom = {now.graph.resolveDecision(where[0])}
8936        else:
8937            searchFrom = None
8938
8939        # Note: Delay and charges are ignored!
8940
8941        # If it's a simple effect, we can use
8942        # `base.applySimpleEffectToState` to apply it:
8943        if base.isSimple(effect):
8944            # TODO: NOT THIS, since it applies only simple effects of
8945            # followed transitions!!!
8946            return base.applySimpleEffectToState(
8947                now.state,
8948                now.graph,
8949                effect,
8950                where,
8951                moveWhich,
8952                challengePolicy
8953            )
8954        else:
8955            # TODO: HERE
8956            if typ == "edit":
8957                value = cast(List[List[commands.Command]], value)
8958                # If there are no blocks, do nothing
8959                if len(value) > 0:
8960                    # Apply the first block of commands and then rotate the list
8961                    scope: commands.Scope = {}
8962                    if where is not None:
8963                        here: base.DecisionID = now.graph.resolveDecision(
8964                            where[0]
8965                        )
8966                        outwards: Optional[base.Transition] = where[1]
8967                        scope['@'] = here
8968                        scope['@t'] = outwards
8969                        if outwards is not None:
8970                            reciprocal = now.graph.getReciprocal(
8971                                here,
8972                                outwards
8973                            )
8974                            destination = now.graph.getDestination(
8975                                here,
8976                                outwards
8977                            )
8978                        else:
8979                            reciprocal = None
8980                            destination = None
8981                        scope['@r'] = reciprocal
8982                        scope['@d'] = destination
8983                    self.runCommandBlock(value[0], scope)
8984                    value.append(value.pop(0))
8985
8986            elif typ == "follow":
8987                # TODO: Maybe this should remain a non-complex effect?
8988                if applyTo == "both":
8989                    raise ValueError(
8990                        "Can't follow a transition in both common & active"
8991                        " focal contexts."
8992                    )
8993
8994                if where is None:
8995                    raise ValueError(
8996                        f"Can't follow transition {value!r} because there"
8997                        f" is no position information when applying the"
8998                        f" effect."
8999                    )
9000
9001                if where[1] is not None:
9002                    followFrom = now.graph.getDestination(where[0], where[1])
9003                    if followFrom is None:
9004                        raise ValueError(
9005                            f"Can't follow transition {value!r} because the"
9006                            f" position information specifies transition"
9007                            f" {where[1]!r} from decision"
9008                            f" {now.graph.identityOf(where[0])} but that"
9009                            f" transition does not exist."
9010                        )
9011
9012                else:
9013                    followFrom = now.graph.resolveDecision(where[0])
9014
9015                following = cast(base.Transition, value)
9016
9017                followTo = now.graph.getDestination(followFrom, following)
9018
9019                if followTo is None:
9020                    raise ValueError(
9021                        f"Can't follow transition {following!r} because"
9022                        f" that transition doesn't exist at the specified"
9023                        f" destination {now.graph.identityOf(followFrom)}."
9024                    )
9025
9026                if self.isTraversable(followFrom, following):  # skip if not
9027                    # Perform initial position update before following new
9028                    # transition:
9029                    base.updatePosition(
9030                        now.state,
9031                        now.graph,
9032                        followFrom,
9033                        applyTo,
9034                        moveWhich
9035                    )
9036
9037                    # Apply consequences of followed transition
9038                    fullFollowTo = self.applyTransitionConsequence(
9039                        followFrom,
9040                        following,
9041                        moveWhich,
9042                        challengePolicy
9043                    )
9044
9045                    # Now update to end of followed transition
9046                    if fullFollowTo is None:
9047                        base.updatePosition(
9048                            now.state,
9049                            now.graph,
9050                            followTo,
9051                            applyTo,
9052                            moveWhich
9053                        )
9054                        fullFollowTo = followTo
9055
9056                    # Skip the normal update: we've taken care of that
9057                    # plus more
9058                    return fullFollowTo
9059                else:
9060                    # Normal position updates still applies since follow
9061                    # transition wasn't possible
9062                    return None
9063
9064            elif typ == "save":
9065                assert isinstance(value, base.SaveSlot)
9066                now.saves[value] = copy.deepcopy((now.graph, now.state))
9067
9068            else:
9069                raise ValueError(f"Invalid effect type {typ!r}.")
9070
9071        return None  # default return value if we didn't return above

Applies a single extraneous effect to the state & graph, without accounting for charges or delay values, since the effect is not part of the graph (use applyTransitionEffect to apply effects that are attached to transitions, which is almost always the function you should be using). An associated transition for the extraneous effect can be supplied using the where argument, and effects like 'deactivate' and 'edit' will affect it (but the effect's charges and delay values will still be ignored).

If the effect would change the destination of a transition, the altered destination ID is returned: 'bounce' effects return the provided decision part of where, 'goto' effects return their target, and 'follow' effects return the destination followed to (possibly via chained follows in the extreme case). In all other cases, None is returned indicating no change to a normal destination.

If a specific focal point in a plural-focalized domain is triggering the effect, the focal point name should be specified via moveWhich so that goto Effects can know which focal point to move when it's not explicitly specified in the effect. TODO: Test this!

Note that transition effects which update active decisions will also update the exploration status of those decisions to 'exploring' if they had been in an unvisited status and will remove any 'unconfirmed' tag they might still have (see updatePosition and hasBeenVisited).

The given challengePolicy is applied when traversing further transitions due to 'follow' effects.

Note: Anyone calling applyExtraneousEffect should update any situation-based variables immediately after the call, as a 'revert' effect may have changed the current graph and/or state.

def applyExtraneousConsequence( self, consequence: List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]], where: Optional[Tuple[Union[int, exploration.base.DecisionSpecifier, str], Optional[str]]] = None, moveWhich: Optional[str] = None) -> Optional[int]:
9073    def applyExtraneousConsequence(
9074        self,
9075        consequence: base.Consequence,
9076        where: Optional[
9077            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]]
9078        ] = None,
9079        moveWhich: Optional[base.FocalPointName] = None
9080    ) -> Optional[base.DecisionID]:
9081        """
9082        Applies an extraneous consequence not associated with a
9083        transition. Unlike `applyTransitionConsequence`, the provided
9084        `base.Consequence` must already have observed outcomes (see
9085        `base.observeChallengeOutcomes`). Returns the decision ID for a
9086        decision implied by a goto, follow, or bounce effect, or `None`
9087        if no effect implies a destination.
9088
9089        The `where` and `moveWhich` optional arguments specify which
9090        decision and/or transition to use as the application position,
9091        and/or which focal point to move. This affects mechanism lookup
9092        as well as the end position when 'follow' effects are used.
9093        Specifically:
9094
9095        - A 'follow' trigger will search for transitions to follow from
9096            the destination of the specified transition, or if only a
9097            decision was supplied, from that decision.
9098        - Mechanism lookups will start with both ends of the specified
9099            transition as their search field (or with just the specified
9100            decision if no transition is included).
9101
9102        'bounce' effects will cause an error unless position information
9103        is provided, and will set the position to the base decision
9104        provided in `where`.
9105
9106        Note: callers should update any situation-based variables
9107        immediately after calling this as a 'revert' effect could change
9108        the current graph and/or state and other changes could get lost
9109        if they get applied to a stale graph/state.
9110
9111        # TODO: Examples for goto and follow effects.
9112        """
9113        now = self.getSituation()
9114        searchFrom = set()
9115        if where is not None:
9116            if where[1] is not None:
9117                searchFrom = now.graph.bothEnds(where[0], where[1])
9118            else:
9119                searchFrom = {now.graph.resolveDecision(where[0])}
9120
9121        context = base.RequirementContext(
9122            state=now.state,
9123            graph=now.graph,
9124            searchFrom=searchFrom
9125        )
9126
9127        effectIndices = base.observedEffects(context, consequence)
9128        destID = None
9129        for index in effectIndices:
9130            effect = base.consequencePart(consequence, index)
9131            if not isinstance(effect, dict) or 'value' not in effect:
9132                raise RuntimeError(
9133                    f"Invalid effect index {index}: Consequence part at"
9134                    f" that index is not an Effect. Got:\n{effect}"
9135                )
9136            effect = cast(base.Effect, effect)
9137            destID = self.applyExtraneousEffect(
9138                effect,
9139                where,
9140                moveWhich
9141            )
9142            # technically this variable is not used later in this
9143            # function, but the `applyExtraneousEffect` call means it
9144            # needs an update, so we're doing that in case someone later
9145            # adds code to this function that uses 'now' after this
9146            # point.
9147            now = self.getSituation()
9148
9149        return destID

Applies an extraneous consequence not associated with a transition. Unlike applyTransitionConsequence, the provided base.Consequence must already have observed outcomes (see base.observeChallengeOutcomes). Returns the decision ID for a decision implied by a goto, follow, or bounce effect, or None if no effect implies a destination.

The where and moveWhich optional arguments specify which decision and/or transition to use as the application position, and/or which focal point to move. This affects mechanism lookup as well as the end position when 'follow' effects are used. Specifically:

  • A 'follow' trigger will search for transitions to follow from the destination of the specified transition, or if only a decision was supplied, from that decision.
  • Mechanism lookups will start with both ends of the specified transition as their search field (or with just the specified decision if no transition is included).

'bounce' effects will cause an error unless position information is provided, and will set the position to the base decision provided in where.

Note: callers should update any situation-based variables immediately after calling this as a 'revert' effect could change the current graph and/or state and other changes could get lost if they get applied to a stale graph/state.

TODO: Examples for goto and follow effects.

def applyTransitionConsequence( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: Union[str, Tuple[str, List[bool]]], moveWhich: Optional[str] = None, policy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'specified', fromIndex: Optional[int] = None, toIndex: Optional[int] = None) -> Optional[int]:
9151    def applyTransitionConsequence(
9152        self,
9153        decision: base.AnyDecisionSpecifier,
9154        transition: base.AnyTransition,
9155        moveWhich: Optional[base.FocalPointName] = None,
9156        policy: base.ChallengePolicy = "specified",
9157        fromIndex: Optional[int] = None,
9158        toIndex: Optional[int] = None
9159    ) -> Optional[base.DecisionID]:
9160        """
9161        Applies the effects of the specified transition to the current
9162        graph and state, possibly overriding observed outcomes using
9163        outcomes specified as part of a `base.TransitionWithOutcomes`.
9164
9165        The `where` and `moveWhich` function serve the same purpose as
9166        for `applyExtraneousEffect`. If `where` is `None`, then the
9167        effects will be applied as extraneous effects, meaning that
9168        their delay and charges values will be ignored and their trigger
9169        count will not be tracked. If `where` is supplied
9170
9171        Returns either None to indicate that the position update for the
9172        transition should apply as usual, or a decision ID indicating
9173        another destination which has already been applied by a
9174        transition effect.
9175
9176        If `fromIndex` and/or `toIndex` are specified, then only effects
9177        which have indices between those two (inclusive) will be
9178        applied, and other effects will neither apply nor be updated in
9179        any way. Note that `onlyPart` does not override the challenge
9180        policy: if the effects in the specified part are not applied due
9181        to a challenge outcome, they still won't happen, including
9182        challenge outcomes outside of that part. Also, outcomes for
9183        challenges of the entire consequence are re-observed if the
9184        challenge policy implies it.
9185
9186        Note: Anyone calling this should update any situation-based
9187        variables immediately after the call, as a 'revert' effect may
9188        have changed the current graph and/or state.
9189        """
9190        now = self.getSituation()
9191        dID = now.graph.resolveDecision(decision)
9192
9193        transitionName, outcomes = base.nameAndOutcomes(transition)
9194
9195        searchFrom = set()
9196        searchFrom = now.graph.bothEnds(dID, transitionName)
9197
9198        context = base.RequirementContext(
9199            state=now.state,
9200            graph=now.graph,
9201            searchFrom=searchFrom
9202        )
9203
9204        consequence = now.graph.getConsequence(dID, transitionName)
9205
9206        # Make sure that challenge outcomes are known
9207        if policy != "specified":
9208            base.resetChallengeOutcomes(consequence)
9209        useUp = outcomes[:]
9210        base.observeChallengeOutcomes(
9211            context,
9212            consequence,
9213            location=searchFrom,
9214            policy=policy,
9215            knownOutcomes=useUp
9216        )
9217        if len(useUp) > 0:
9218            raise ValueError(
9219                f"More outcomes specified than challenges observed in"
9220                f" consequence:\n{consequence}"
9221                f"\nRemaining outcomes:\n{useUp}"
9222            )
9223
9224        # Figure out which effects apply, and apply each of them
9225        effectIndices = base.observedEffects(context, consequence)
9226        if fromIndex is None:
9227            fromIndex = 0
9228
9229        altDest = None
9230        for index in effectIndices:
9231            if (
9232                index >= fromIndex
9233            and (toIndex is None or index <= toIndex)
9234            ):
9235                thisDest = self.applyTransitionEffect(
9236                    (dID, transitionName, index),
9237                    moveWhich
9238                )
9239                if thisDest is not None:
9240                    altDest = thisDest
9241                # TODO: What if this updates state with 'revert' to a
9242                # graph that doesn't contain the same effects?
9243                # TODO: Update 'now' and 'context'?!
9244        return altDest

Applies the effects of the specified transition to the current graph and state, possibly overriding observed outcomes using outcomes specified as part of a base.TransitionWithOutcomes.

The where and moveWhich function serve the same purpose as for applyExtraneousEffect. If where is None, then the effects will be applied as extraneous effects, meaning that their delay and charges values will be ignored and their trigger count will not be tracked. If where is supplied

Returns either None to indicate that the position update for the transition should apply as usual, or a decision ID indicating another destination which has already been applied by a transition effect.

If fromIndex and/or toIndex are specified, then only effects which have indices between those two (inclusive) will be applied, and other effects will neither apply nor be updated in any way. Note that onlyPart does not override the challenge policy: if the effects in the specified part are not applied due to a challenge outcome, they still won't happen, including challenge outcomes outside of that part. Also, outcomes for challenges of the entire consequence are re-observed if the challenge policy implies it.

Note: Anyone calling this should update any situation-based variables immediately after the call, as a 'revert' effect may have changed the current graph and/or state.

def allDecisions(self) -> List[int]:
9246    def allDecisions(self) -> List[base.DecisionID]:
9247        """
9248        Returns the list of all decisions which existed at any point
9249        within the exploration. Example:
9250
9251        >>> ex = DiscreteExploration()
9252        >>> ex.start('A')
9253        0
9254        >>> ex.observe('A', 'right')
9255        1
9256        >>> ex.explore('right', 'B', 'left')
9257        1
9258        >>> ex.observe('B', 'right')
9259        2
9260        >>> ex.allDecisions()  # 'A', 'B', and the unnamed 'right of B'
9261        [0, 1, 2]
9262        """
9263        seen = set()
9264        result = []
9265        for situation in self:
9266            for decision in situation.graph:
9267                if decision not in seen:
9268                    result.append(decision)
9269                    seen.add(decision)
9270
9271        return result

Returns the list of all decisions which existed at any point within the exploration. Example:

>>> ex = DiscreteExploration()
>>> ex.start('A')
0
>>> ex.observe('A', 'right')
1
>>> ex.explore('right', 'B', 'left')
1
>>> ex.observe('B', 'right')
2
>>> ex.allDecisions()  # 'A', 'B', and the unnamed 'right of B'
[0, 1, 2]
def allExploredDecisions(self) -> List[int]:
9273    def allExploredDecisions(self) -> List[base.DecisionID]:
9274        """
9275        Returns the list of all decisions which existed at any point
9276        within the exploration, excluding decisions whose highest
9277        exploration status was `noticed` or lower. May still include
9278        decisions which don't exist in the final situation's graph due to
9279        things like decision merging. Example:
9280
9281        >>> ex = DiscreteExploration()
9282        >>> idA = ex.start('A')
9283        >>> idB = ex.observe('A', 'right')
9284        >>> ex.explore('right', 'B', 'left') == idB
9285        True
9286        >>> idU = ex.observe('B', 'right')
9287        >>> graph = ex.getSituation().graph
9288        >>> idC = graph.addDecision('C')  # add isolated decision;
9289        >>>                               # doesn't set status
9290        >>> ex.hasBeenVisited('C')
9291        False
9292        >>> ex.allExploredDecisions() == [idA, idB]
9293        True
9294        >>> ex.setExplorationStatus('C', 'exploring')
9295        >>> ex.allExploredDecisions() == [idA, idB, idC]
9296        True
9297        >>> ex.setExplorationStatus('A', 'explored')
9298        >>> ex.allExploredDecisions() == [idA, idB, idC]
9299        True
9300        >>> ex.setExplorationStatus('A', 'unknown')
9301        >>> # remains visisted in an earlier step
9302        >>> ex.allExploredDecisions() == [idA, idB, idC]
9303        True
9304        >>> ex.setExplorationStatus('C', 'unknown')  # not explored earlier
9305        >>> ex.allExploredDecisions() == [idA, idB]
9306        True
9307        """
9308        seen = set()
9309        result = []
9310        for situation in self:
9311            graph = situation.graph
9312            for decision in graph:
9313                if (
9314                    decision not in seen
9315                and base.hasBeenVisited(situation.state, decision)
9316                ):
9317                    result.append(decision)
9318                    seen.add(decision)
9319
9320        return result

Returns the list of all decisions which existed at any point within the exploration, excluding decisions whose highest exploration status was noticed or lower. May still include decisions which don't exist in the final situation's graph due to things like decision merging. Example:

>>> ex = DiscreteExploration()
>>> idA = ex.start('A')
>>> idB = ex.observe('A', 'right')
>>> ex.explore('right', 'B', 'left') == idB
True
>>> idU = ex.observe('B', 'right')
>>> graph = ex.getSituation().graph
>>> idC = graph.addDecision('C')  # add isolated decision;
>>>                               # doesn't set status
>>> ex.hasBeenVisited('C')
False
>>> ex.allExploredDecisions() == [idA, idB]
True
>>> ex.setExplorationStatus('C', 'exploring')
>>> ex.allExploredDecisions() == [idA, idB, idC]
True
>>> ex.setExplorationStatus('A', 'explored')
>>> ex.allExploredDecisions() == [idA, idB, idC]
True
>>> ex.setExplorationStatus('A', 'unknown')
>>> # remains visisted in an earlier step
>>> ex.allExploredDecisions() == [idA, idB, idC]
True
>>> ex.setExplorationStatus('C', 'unknown')  # not explored earlier
>>> ex.allExploredDecisions() == [idA, idB]
True
def allVisitedDecisions(self) -> List[int]:
9322    def allVisitedDecisions(self) -> List[base.DecisionID]:
9323        """
9324        Returns the list of all decisions which existed at any point
9325        within the exploration and which were visited at least once.
9326        Orders them in the same order they were visited in.
9327
9328        Usually all of these decisions will be present in the final
9329        situation's graph, but sometimes merging or other factors means
9330        there might be some that won't be. Being present on the game
9331        state's 'active' list in a step for its domain is what counts as
9332        "being visited," which means that nodes which were passed through
9333        directly via a 'follow' effect won't be counted, for example.
9334
9335        This should usually correspond with the absence of the
9336        'unconfirmed' tag.
9337
9338        Example:
9339
9340        >>> ex = DiscreteExploration()
9341        >>> ex.start('A')
9342        0
9343        >>> ex.observe('A', 'right')
9344        1
9345        >>> ex.explore('right', 'B', 'left')
9346        1
9347        >>> ex.observe('B', 'right')
9348        2
9349        >>> ex.getSituation().graph.addDecision('C')  # add isolated decision
9350        3
9351        >>> av = ex.allVisitedDecisions()
9352        >>> av
9353        [0, 1]
9354        >>> all(  # no decisions in the 'visited' list are tagged
9355        ...     'unconfirmed' not in ex.getSituation().graph.decisionTags(d)
9356        ...     for d in av
9357        ... )
9358        True
9359        >>> graph = ex.getSituation().graph
9360        >>> 'unconfirmed' in graph.decisionTags(0)
9361        False
9362        >>> 'unconfirmed' in graph.decisionTags(1)
9363        False
9364        >>> 'unconfirmed' in graph.decisionTags(2)
9365        True
9366        >>> 'unconfirmed' in graph.decisionTags(3)  # not tagged; not explored
9367        False
9368        """
9369        seen = set()
9370        result = []
9371        for step in range(len(self)):
9372            active = self.getActiveDecisions(step)
9373            for dID in active:
9374                if dID not in seen:
9375                    result.append(dID)
9376                    seen.add(dID)
9377
9378        return result

Returns the list of all decisions which existed at any point within the exploration and which were visited at least once. Orders them in the same order they were visited in.

Usually all of these decisions will be present in the final situation's graph, but sometimes merging or other factors means there might be some that won't be. Being present on the game state's 'active' list in a step for its domain is what counts as "being visited," which means that nodes which were passed through directly via a 'follow' effect won't be counted, for example.

This should usually correspond with the absence of the 'unconfirmed' tag.

Example:

>>> ex = DiscreteExploration()
>>> ex.start('A')
0
>>> ex.observe('A', 'right')
1
>>> ex.explore('right', 'B', 'left')
1
>>> ex.observe('B', 'right')
2
>>> ex.getSituation().graph.addDecision('C')  # add isolated decision
3
>>> av = ex.allVisitedDecisions()
>>> av
[0, 1]
>>> all(  # no decisions in the 'visited' list are tagged
...     'unconfirmed' not in ex.getSituation().graph.decisionTags(d)
...     for d in av
... )
True
>>> graph = ex.getSituation().graph
>>> 'unconfirmed' in graph.decisionTags(0)
False
>>> 'unconfirmed' in graph.decisionTags(1)
False
>>> 'unconfirmed' in graph.decisionTags(2)
True
>>> 'unconfirmed' in graph.decisionTags(3)  # not tagged; not explored
False
def allTransitions(self) -> List[Tuple[int, str, int]]:
9380    def allTransitions(self) -> List[
9381        Tuple[base.DecisionID, base.Transition, base.DecisionID]
9382    ]:
9383        """
9384        Returns the list of all transitions which existed at any point
9385        within the exploration, as 3-tuples with source decision ID,
9386        transition name, and destination decision ID. Note that since
9387        transitions can be deleted or re-targeted, and a transition name
9388        can be re-used after being deleted, things can get messy in the
9389        edges cases (see `allFinalTransitions`). When the same transition
9390        name is used in different steps with different decision targets,
9391        we end up including each possible source-transition-destination
9392        triple. Example:
9393
9394        >>> ex = DiscreteExploration()
9395        >>> ex.start('A')
9396        0
9397        >>> ex.observe('A', 'right', None, 'return')
9398        1
9399        >>> ex.explore('right', 'B', 'left')
9400        1
9401        >>> ex.observe('B', 'right')
9402        2
9403        >>> ex.wait()  # leave behind a step where 'B' has a 'right'
9404        >>> ex.primaryDecision(0)
9405        >>> ex.primaryDecision(1)
9406        0
9407        >>> ex.primaryDecision(2)
9408        1
9409        >>> ex.primaryDecision(3)
9410        1
9411        >>> len(ex)
9412        4
9413        >>> ex[3].graph.removeDecision(2)  # delete 'right of B'
9414        >>> ex.observe('B', 'down')
9415        3
9416        >>> # Decisions are: 'A', 'B', and the unnamed 'right of B'
9417        >>> # (now-deleted), and the unnamed 'down from B'
9418        >>> ex.allDecisions()
9419        [0, 1, 2, 3]
9420        >>> for tr in ex.allTransitions():
9421        ...     print(tr)
9422        ...
9423        (0, 'right', 1)
9424        (1, 'return', 0)
9425        (1, 'left', 0)
9426        (1, 'right', 2)
9427        (1, 'down', 3)
9428        >>> # Note transitions from now-deleted nodes, and 'return'
9429        >>> # transitions for unexplored nodes before they get explored
9430        """
9431        seen = set()
9432        result = []
9433        for situation in self:
9434            graph = situation.graph
9435            for (src, dst, transition) in graph.allEdges():  # type:ignore
9436                trans = (src, transition, dst)
9437                if trans not in seen:
9438                    result.append(trans)
9439                    seen.add(trans)
9440
9441        return result

Returns the list of all transitions which existed at any point within the exploration, as 3-tuples with source decision ID, transition name, and destination decision ID. Note that since transitions can be deleted or re-targeted, and a transition name can be re-used after being deleted, things can get messy in the edges cases (see allFinalTransitions). When the same transition name is used in different steps with different decision targets, we end up including each possible source-transition-destination triple. Example:

>>> ex = DiscreteExploration()
>>> ex.start('A')
0
>>> ex.observe('A', 'right', None, 'return')
1
>>> ex.explore('right', 'B', 'left')
1
>>> ex.observe('B', 'right')
2
>>> ex.wait()  # leave behind a step where 'B' has a 'right'
>>> ex.primaryDecision(0)
>>> ex.primaryDecision(1)
0
>>> ex.primaryDecision(2)
1
>>> ex.primaryDecision(3)
1
>>> len(ex)
4
>>> ex[3].graph.removeDecision(2)  # delete 'right of B'
>>> ex.observe('B', 'down')
3
>>> # Decisions are: 'A', 'B', and the unnamed 'right of B'
>>> # (now-deleted), and the unnamed 'down from B'
>>> ex.allDecisions()
[0, 1, 2, 3]
>>> for tr in ex.allTransitions():
...     print(tr)
...
(0, 'right', 1)
(1, 'return', 0)
(1, 'left', 0)
(1, 'right', 2)
(1, 'down', 3)
>>> # Note transitions from now-deleted nodes, and 'return'
>>> # transitions for unexplored nodes before they get explored
def allFinalTransitions(self) -> List[Tuple[int, str]]:
9443    def allFinalTransitions(self) -> List[
9444        Tuple[base.DecisionID, base.Transition]
9445    ]:
9446        """
9447        Returns the list of all transitions which exist in the final
9448        situation's graph, as 2-tuples of source decision ID and
9449        transition name. Compare `allTransitions` which tracks all
9450        transitions that existed at any point in the exploration.
9451        
9452        Example:
9453
9454        >>> ex = DiscreteExploration()
9455        >>> ex.start('A')
9456        0
9457        >>> ex.observe('A', 'right', None, 'return')
9458        1
9459        >>> ex.explore('right', 'B', 'left')
9460        1
9461        >>> ex.observe('B', 'right')
9462        2
9463        >>> ex.wait()  # leave behind a step where 'B' has a 'right'
9464        >>> ex.primaryDecision(0)
9465        >>> ex.primaryDecision(1)
9466        0
9467        >>> ex.primaryDecision(2)
9468        1
9469        >>> ex.primaryDecision(3)
9470        1
9471        >>> len(ex)
9472        4
9473        >>> ex[3].graph.removeDecision(2)  # delete 'right of B'
9474        >>> ex.observe('B', 'down')
9475        3
9476        >>> # Decisions are: 'A', 'B', and the unnamed 'right of B'
9477        >>> # (now-deleted), and the unnamed 'down from B'
9478        >>> ex.allDecisions()
9479        [0, 1, 2, 3]
9480        >>> for tr in ex.allFinalTransitions():
9481        ...     print(tr)
9482        ...
9483        (0, 'right')
9484        (1, 'left')
9485        (1, 'down')
9486        >>> # Note only transitions present in final graph
9487        """
9488        if len(self) == 0:
9489            return []
9490        graph = self[-1].graph;
9491        result = []
9492        seen = set()
9493        for (src, dst, transition) in graph.allEdges():  # type:ignore
9494            trans = (src, transition)
9495            if trans not in seen:
9496                result.append(trans)
9497                seen.add(trans)
9498
9499        return result

Returns the list of all transitions which exist in the final situation's graph, as 2-tuples of source decision ID and transition name. Compare allTransitions which tracks all transitions that existed at any point in the exploration.

Example:

>>> ex = DiscreteExploration()
>>> ex.start('A')
0
>>> ex.observe('A', 'right', None, 'return')
1
>>> ex.explore('right', 'B', 'left')
1
>>> ex.observe('B', 'right')
2
>>> ex.wait()  # leave behind a step where 'B' has a 'right'
>>> ex.primaryDecision(0)
>>> ex.primaryDecision(1)
0
>>> ex.primaryDecision(2)
1
>>> ex.primaryDecision(3)
1
>>> len(ex)
4
>>> ex[3].graph.removeDecision(2)  # delete 'right of B'
>>> ex.observe('B', 'down')
3
>>> # Decisions are: 'A', 'B', and the unnamed 'right of B'
>>> # (now-deleted), and the unnamed 'down from B'
>>> ex.allDecisions()
[0, 1, 2, 3]
>>> for tr in ex.allFinalTransitions():
...     print(tr)
...
(0, 'right')
(1, 'left')
(1, 'down')
>>> # Note only transitions present in final graph
def start( self, decision: Union[int, exploration.base.DecisionSpecifier, str], startCapabilities: Optional[exploration.base.CapabilitySet] = None, setMechanismStates: Optional[Dict[int, str]] = None, setCustomState: Optional[dict] = None, decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'imposed') -> int:
9501    def start(
9502        self,
9503        decision: base.AnyDecisionSpecifier,
9504        startCapabilities: Optional[base.CapabilitySet] = None,
9505        setMechanismStates: Optional[
9506            Dict[base.MechanismID, base.MechanismState]
9507        ] = None,
9508        setCustomState: Optional[dict] = None,
9509        decisionType: base.DecisionType = "imposed"
9510    ) -> base.DecisionID:
9511        """
9512        Sets the initial position information for a newly-relevant
9513        domain for the current focal context. Creates a new decision
9514        if the decision is specified by name or `DecisionSpecifier` and
9515        that decision doesn't already exist. Returns the decision ID for
9516        the newly-placed decision (or for the specified decision if it
9517        already existed).
9518
9519        Raises a `BadStart` error if the current focal context already
9520        has position information for the specified domain.
9521
9522        - The given `startCapabilities` replaces any existing
9523            capabilities for the current focal context, although you can
9524            leave it as the default `None` to avoid that and retain any
9525            capabilities that have been set up already.
9526        - The given `setMechanismStates` and `setCustomState`
9527            dictionaries override all previous mechanism states & custom
9528            states in the new situation. Leave these as the default
9529            `None` to maintain those states.
9530        - If created, the decision will be placed in the DEFAULT_DOMAIN
9531            domain unless it's specified as a `base.DecisionSpecifier`
9532            with a domain part, in which case that domain is used.
9533        - If specified as a `base.DecisionSpecifier` with a zone part
9534            and a new decision needs to be created, the decision will be
9535            added to that zone, creating it at level 0 if necessary,
9536            although otherwise no zone information will be changed.
9537        - Resets the decision type to "pending" and the action taken to
9538            `None`. Sets the decision type of the previous situation to
9539            'imposed' (or the specified `decisionType`) and sets an
9540            appropriate 'start' action for that situation.
9541        - Tags the step with 'start'.
9542        - Even in a plural- or spreading-focalized domain, you still need
9543            to pick one decision to start at.
9544        """
9545        now = self.getSituation()
9546
9547        startID = now.graph.getDecision(decision)
9548        zone = None
9549        domain = base.DEFAULT_DOMAIN
9550        if startID is None:
9551            if isinstance(decision, base.DecisionID):
9552                raise MissingDecisionError(
9553                    f"Cannot start at decision {decision} because no"
9554                    f" decision with that ID exists. Supply a name or"
9555                    f" DecisionSpecifier if you need the start decision"
9556                    f" to be created automatically."
9557                )
9558            elif isinstance(decision, base.DecisionName):
9559                decision = base.DecisionSpecifier(
9560                    domain=None,
9561                    zone=None,
9562                    name=decision
9563                )
9564            startID = now.graph.addDecision(
9565                decision.name,
9566                domain=decision.domain
9567            )
9568            zone = decision.zone
9569            if decision.domain is not None:
9570                domain = decision.domain
9571
9572        if zone is not None:
9573            if now.graph.getZoneInfo(zone) is None:
9574                now.graph.createZone(zone, 0)
9575            now.graph.addDecisionToZone(startID, zone)
9576
9577        action: base.ExplorationAction = (
9578            'start',
9579            startID,
9580            startID,
9581            domain,
9582            startCapabilities,
9583            setMechanismStates,
9584            setCustomState
9585        )
9586
9587        self.advanceSituation(action, decisionType)
9588
9589        return startID

Sets the initial position information for a newly-relevant domain for the current focal context. Creates a new decision if the decision is specified by name or DecisionSpecifier and that decision doesn't already exist. Returns the decision ID for the newly-placed decision (or for the specified decision if it already existed).

Raises a BadStart error if the current focal context already has position information for the specified domain.

  • The given startCapabilities replaces any existing capabilities for the current focal context, although you can leave it as the default None to avoid that and retain any capabilities that have been set up already.
  • The given setMechanismStates and setCustomState dictionaries override all previous mechanism states & custom states in the new situation. Leave these as the default None to maintain those states.
  • If created, the decision will be placed in the DEFAULT_DOMAIN domain unless it's specified as a base.DecisionSpecifier with a domain part, in which case that domain is used.
  • If specified as a base.DecisionSpecifier with a zone part and a new decision needs to be created, the decision will be added to that zone, creating it at level 0 if necessary, although otherwise no zone information will be changed.
  • Resets the decision type to "pending" and the action taken to None. Sets the decision type of the previous situation to 'imposed' (or the specified decisionType) and sets an appropriate 'start' action for that situation.
  • Tags the step with 'start'.
  • Even in a plural- or spreading-focalized domain, you still need to pick one decision to start at.
def hasBeenVisited( self, decision: Union[int, exploration.base.DecisionSpecifier, str], step: int = -1):
9591    def hasBeenVisited(
9592        self,
9593        decision: base.AnyDecisionSpecifier,
9594        step: int = -1
9595    ):
9596        """
9597        Returns whether or not the specified decision has been visited in
9598        or prior to the specified step (default current step).
9599        """
9600        situation = self.getSituation(step)
9601        return base.hasBeenVisited(
9602            situation.state,
9603            situation.graph.resolveDecision(decision)
9604        )

Returns whether or not the specified decision has been visited in or prior to the specified step (default current step).

def setExplorationStatus( self, decision: Union[int, exploration.base.DecisionSpecifier, str], status: Literal['unknown', 'hypothesized', 'noticed', 'exploring', 'explored'], upgradeOnly: bool = False):
9606    def setExplorationStatus(
9607        self,
9608        decision: base.AnyDecisionSpecifier,
9609        status: base.ExplorationStatus,
9610        upgradeOnly: bool = False
9611    ):
9612        """
9613        Updates the current exploration status of a specific decision in
9614        the current situation. If `upgradeOnly` is true (default is
9615        `False` then the update will only apply if the new exploration
9616        status counts as 'more-explored' than the old one (see
9617        `base.moreExplored`).
9618        """
9619        now = self.getSituation()
9620        base.setExplorationStatus(
9621            now.state,
9622            now.graph.resolveDecision(decision),
9623            status,
9624            upgradeOnly
9625        )

Updates the current exploration status of a specific decision in the current situation. If upgradeOnly is true (default is False then the update will only apply if the new exploration status counts as 'more-explored' than the old one (see base.moreExplored).

def getExplorationStatus( self, decision: Union[int, exploration.base.DecisionSpecifier, str], step: int = -1):
9627    def getExplorationStatus(
9628        self,
9629        decision: base.AnyDecisionSpecifier,
9630        step: int = -1
9631    ):
9632        """
9633        Returns the exploration status of the specified decision at the
9634        specified step (default is last step). Decisions whose
9635        exploration status has never been set will have a default status
9636        of 'unknown'.
9637        """
9638        situation = self.getSituation(step)
9639        dID = situation.graph.resolveDecision(decision)
9640        return base.explorationStatusOf(
9641            situation.state,
9642            dID,
9643            default='unknown'
9644        )

Returns the exploration status of the specified decision at the specified step (default is last step). Decisions whose exploration status has never been set will have a default status of 'unknown'.

def deduceTransitionDetailsAtStep( self, step: int, transition: str, fromDecision: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None, whichFocus: Optional[Tuple[Literal['common', 'active'], str, str]] = None, inCommon: Union[bool, Literal['auto']] = 'auto') -> Tuple[Literal['common', 'active'], int, int, Optional[Tuple[Literal['common', 'active'], str, str]]]:
9646    def deduceTransitionDetailsAtStep(
9647        self,
9648        step: int,
9649        transition: base.Transition,
9650        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
9651        whichFocus: Optional[base.FocalPointSpecifier] = None,
9652        inCommon: Union[bool, Literal["auto"]] = "auto"
9653    ) -> Tuple[
9654        base.ContextSpecifier,
9655        base.DecisionID,
9656        base.DecisionID,
9657        Optional[base.FocalPointSpecifier]
9658    ]:
9659        """
9660        Given just a transition name which the player intends to take in
9661        a specific step, deduces the `ContextSpecifier` for which
9662        context should be updated, the source and destination
9663        `DecisionID`s for the transition, and if the destination
9664        decision's domain is plural-focalized, the `FocalPointName`
9665        specifying which focal point should be moved.
9666
9667        Because many of those things are ambiguous, you may get an
9668        `AmbiguousTransitionError` when things are underspecified, and
9669        there are options for specifying some of the extra information
9670        directly:
9671
9672        - `fromDecision` may be used to specify the source decision.
9673        - `whichFocus` may be used to specify the focal point (within a
9674            particular context/domain) being updated. When focal point
9675            ambiguity remains and this is unspecified, the
9676            alphabetically-earliest relevant focal point will be used
9677            (either among all focal points which activate the source
9678            decision, if there are any, or among all focal points for
9679            the entire domain of the destination decision).
9680        - `inCommon` (a `ContextSpecifier`) may be used to specify which
9681            context to update. The default of "auto" will cause the
9682            active context to be selected unless it does not activate
9683            the source decision, in which case the common context will
9684            be selected.
9685
9686        A `MissingDecisionError` will be raised if there are no current
9687        active decisions (e.g., before `start` has been called), and a
9688        `MissingTransitionError` will be raised if the listed transition
9689        does not exist from any active decision (or from the specified
9690        decision if `fromDecision` is used).
9691        """
9692        now = self.getSituation(step)
9693        active = self.getActiveDecisions(step)
9694        if len(active) == 0:
9695            raise MissingDecisionError(
9696                f"There are no active decisions from which transition"
9697                f" {repr(transition)} could be taken at step {step}."
9698            )
9699
9700        # All source/destination decision pairs for transitions with the
9701        # given transition name.
9702        allDecisionPairs: Dict[base.DecisionID, base.DecisionID] = {}
9703
9704        # TODO: When should we be trimming the active decisions to match
9705        # any alterations to the graph?
9706        for dID in active:
9707            outgoing = now.graph.destinationsFrom(dID)
9708            if transition in outgoing:
9709                allDecisionPairs[dID] = outgoing[transition]
9710
9711        if len(allDecisionPairs) == 0:
9712            raise MissingTransitionError(
9713                f"No transitions named {repr(transition)} are outgoing"
9714                f" from active decisions at step {step}."
9715                f"\nActive decisions are:"
9716                f"\n{now.graph.namesListing(active)}"
9717            )
9718
9719        if (
9720            fromDecision is not None
9721        and fromDecision not in allDecisionPairs
9722        ):
9723            raise MissingTransitionError(
9724                f"{fromDecision} was specified as the source decision"
9725                f" for traversing transition {repr(transition)} but"
9726                f" there is no transition of that name from that"
9727                f" decision at step {step}."
9728                f"\nValid source decisions are:"
9729                f"\n{now.graph.namesListing(allDecisionPairs)}"
9730            )
9731        elif fromDecision is not None:
9732            fromID = now.graph.resolveDecision(fromDecision)
9733            destID = allDecisionPairs[fromID]
9734            fromDomain = now.graph.domainFor(fromID)
9735        elif len(allDecisionPairs) == 1:
9736            fromID, destID = list(allDecisionPairs.items())[0]
9737            fromDomain = now.graph.domainFor(fromID)
9738        else:
9739            fromID = None
9740            destID = None
9741            fromDomain = None
9742            # Still ambiguous; resolve this below
9743
9744        # Use whichFocus if provided
9745        if whichFocus is not None:
9746            # Type/value check for whichFocus
9747            if (
9748                not isinstance(whichFocus, tuple)
9749             or len(whichFocus) != 3
9750             or whichFocus[0] not in ("active", "common")
9751             or not isinstance(whichFocus[1], base.Domain)
9752             or not isinstance(whichFocus[2], base.FocalPointName)
9753            ):
9754                raise ValueError(
9755                    f"Invalid whichFocus value {repr(whichFocus)}."
9756                    f"\nMust be a length-3 tuple with 'active' or 'common'"
9757                    f" as the first element, a Domain as the second"
9758                    f" element, and a FocalPointName as the third"
9759                    f" element."
9760                )
9761
9762            # Resolve focal point specified
9763            fromID = base.resolvePosition(
9764                now.state,
9765                whichFocus
9766            )
9767            if fromID is None:
9768                raise MissingTransitionError(
9769                    f"Focal point {repr(whichFocus)} was specified as"
9770                    f" the transition source, but that focal point does"
9771                    f" not have a position."
9772                )
9773            else:
9774                destID = now.graph.destination(fromID, transition)
9775                fromDomain = now.graph.domainFor(fromID)
9776
9777        elif fromID is None:  # whichFocus is None, so it can't disambiguate
9778            raise AmbiguousTransitionError(
9779                f"Transition {repr(transition)} was selected for"
9780                f" disambiguation, but there are multiple transitions"
9781                f" with that name from currently-active decisions, and"
9782                f" neither fromDecision nor whichFocus adequately"
9783                f" disambiguates the specific transition taken."
9784                f"\nValid source decisions at step {step} are:"
9785                f"\n{now.graph.namesListing(allDecisionPairs)}"
9786            )
9787
9788        # At this point, fromID, destID, and fromDomain have
9789        # been resolved.
9790        if fromID is None or destID is None or fromDomain is None:
9791            raise RuntimeError(
9792                f"One of fromID, destID, or fromDomain was None after"
9793                f" disambiguation was finished:"
9794                f"\nfromID: {fromID}, destID: {destID}, fromDomain:"
9795                f" {repr(fromDomain)}"
9796            )
9797
9798        # Now figure out which context activated the source so we know
9799        # which focal point we're moving:
9800        context = self.getActiveContext()
9801        active = base.activeDecisionSet(context)
9802        using: base.ContextSpecifier = "active"
9803        if fromID not in active:
9804            context = self.getCommonContext(step)
9805            using = "common"
9806
9807        destDomain = now.graph.domainFor(destID)
9808        if (
9809            whichFocus is None
9810        and base.getDomainFocalization(context, destDomain) == 'plural'
9811        ):
9812            # Need to figure out which focal point is moving; use the
9813            # alphabetically earliest one that's positioned at the
9814            # fromID, or just the earliest one overall if none of them
9815            # are there.
9816            contextFocalPoints: Dict[
9817                base.FocalPointName,
9818                Optional[base.DecisionID]
9819            ] = cast(
9820                Dict[base.FocalPointName, Optional[base.DecisionID]],
9821                context['activeDecisions'][destDomain]
9822            )
9823            if not isinstance(contextFocalPoints, dict):
9824                raise RuntimeError(
9825                    f"Active decisions specifier for domain"
9826                    f" {repr(destDomain)} with plural focalization has"
9827                    f" a non-dictionary value."
9828                )
9829
9830            if fromDomain == destDomain:
9831                focalCandidates = [
9832                    fp
9833                    for fp, pos in contextFocalPoints.items()
9834                    if pos == fromID
9835                ]
9836            else:
9837                focalCandidates = list(contextFocalPoints)
9838
9839            whichFocus = (using, destDomain, min(focalCandidates))
9840
9841        # Now whichFocus has been set if it wasn't already specified;
9842        # might still be None if it's not relevant.
9843        return (using, fromID, destID, whichFocus)

Given just a transition name which the player intends to take in a specific step, deduces the ContextSpecifier for which context should be updated, the source and destination DecisionIDs for the transition, and if the destination decision's domain is plural-focalized, the FocalPointName specifying which focal point should be moved.

Because many of those things are ambiguous, you may get an AmbiguousTransitionError when things are underspecified, and there are options for specifying some of the extra information directly:

  • fromDecision may be used to specify the source decision.
  • whichFocus may be used to specify the focal point (within a particular context/domain) being updated. When focal point ambiguity remains and this is unspecified, the alphabetically-earliest relevant focal point will be used (either among all focal points which activate the source decision, if there are any, or among all focal points for the entire domain of the destination decision).
  • inCommon (a ContextSpecifier) may be used to specify which context to update. The default of "auto" will cause the active context to be selected unless it does not activate the source decision, in which case the common context will be selected.

A MissingDecisionError will be raised if there are no current active decisions (e.g., before start has been called), and a MissingTransitionError will be raised if the listed transition does not exist from any active decision (or from the specified decision if fromDecision is used).

def advanceSituation( self, action: Union[Tuple[Literal['noAction']], Tuple[Literal['start'], Union[int, Dict[str, int], Set[int]], Optional[int], str, Optional[exploration.base.CapabilitySet], Optional[Dict[int, str]], Optional[dict]], Tuple[Literal['explore'], Literal['common', 'active'], int, Tuple[str, List[bool]], Union[str, int, NoneType], Optional[str], Optional[str]], Tuple[Literal['explore'], Tuple[Literal['common', 'active'], str, str], Tuple[str, List[bool]], Union[str, int, NoneType], Optional[str], Optional[str]], Tuple[Literal['take'], Literal['common', 'active'], int, Tuple[str, List[bool]]], Tuple[Literal['take'], Tuple[Literal['common', 'active'], str, str], Tuple[str, List[bool]]], Tuple[Literal['warp'], Literal['common', 'active'], int], Tuple[Literal['warp'], Tuple[Literal['common', 'active'], str, str], int], Tuple[Literal['focus'], Literal['common', 'active'], Set[str], Set[str]], Tuple[Literal['swap'], str], Tuple[Literal['focalize'], str], Tuple[Literal['revertTo'], str, Set[str]]], decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active', challengePolicy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'specified') -> Tuple[exploration.base.Situation, Set[int]]:
 9845    def advanceSituation(
 9846        self,
 9847        action: base.ExplorationAction,
 9848        decisionType: base.DecisionType = "active",
 9849        challengePolicy: base.ChallengePolicy = "specified"
 9850    ) -> Tuple[base.Situation, Set[base.DecisionID]]:
 9851        """
 9852        Given an `ExplorationAction`, sets that as the action taken in
 9853        the current situation, and adds a new situation with the results
 9854        of that action. A `DoubleActionError` will be raised if the
 9855        current situation already has an action specified, and/or has a
 9856        decision type other than 'pending'. By default the type of the
 9857        decision will be 'active' but another `DecisionType` can be
 9858        specified via the `decisionType` parameter.
 9859
 9860        If the action specified is `('noAction',)`, then the new
 9861        situation will be a copy of the old one; this represents waiting
 9862        or being at an ending (a decision type other than 'pending'
 9863        should be used).
 9864
 9865        Although `None` can appear as the action entry in situations
 9866        with pending decisions, you cannot call `advanceSituation` with
 9867        `None` as the action.
 9868
 9869        If the action includes taking a transition whose requirements
 9870        are not satisfied, the transition will still be taken (and any
 9871        consequences applied) but a `TransitionBlockedWarning` will be
 9872        issued.
 9873
 9874        A `ChallengePolicy` may be specified, the default is 'specified'
 9875        which requires that outcomes are pre-specified. If any other
 9876        policy is set, the challenge outcomes will be reset before
 9877        re-resolving them according to the provided policy.
 9878
 9879        The new situation will have decision type 'pending' and `None`
 9880        as the action.
 9881
 9882        The new situation created as a result of the action is returned,
 9883        along with the set of destination decision IDs, including
 9884        possibly a modified destination via 'bounce', 'goto', and/or
 9885        'follow' effects. For actions that don't have a destination, the
 9886        second part of the returned tuple will be an empty set. Multiple
 9887        IDs may be in the set when using a start action in a plural- or
 9888        spreading-focalized domain, for example.
 9889
 9890        If the action updates active decisions (including via transition
 9891        effects) this will also update the exploration status of those
 9892        decisions to 'exploring' if they had been in an unvisited
 9893        status (see `updatePosition` and `hasBeenVisited`). This
 9894        includes decisions traveled through but not ultimately arrived
 9895        at via 'follow' effects. These will also lose any 'unconfirmed'
 9896        tags they might have had.
 9897
 9898        If any decisions are active in the `ENDINGS_DOMAIN`, attempting
 9899        to 'warp', 'explore', 'take', or 'start' will raise an
 9900        `InvalidActionError`.
 9901        """
 9902        now = self.getSituation()
 9903        if now.type != 'pending' or now.action is not None:
 9904            raise DoubleActionError(
 9905                f"Attempted to take action {repr(action)} at step"
 9906                f" {len(self) - 1}, but an action and/or decision type"
 9907                f" had already been specified:"
 9908                f"\nAction: {repr(now.action)}"
 9909                f"\nType: {repr(now.type)}"
 9910            )
 9911
 9912        # Update the now situation to add in the decision type and
 9913        # action taken:
 9914        revised = base.Situation(
 9915            now.graph,
 9916            now.state,
 9917            decisionType,
 9918            action,
 9919            now.saves,
 9920            now.tags,
 9921            now.annotations
 9922        )
 9923        self.situations[-1] = revised
 9924
 9925        # Separate update process when reverting (this branch returns)
 9926        if (
 9927            action is not None
 9928        and isinstance(action, tuple)
 9929        and len(action) == 3
 9930        and action[0] == 'revertTo'
 9931        and isinstance(action[1], base.SaveSlot)
 9932        and isinstance(action[2], set)
 9933        and all(isinstance(x, str) for x in action[2])
 9934        ):
 9935            _, slot, aspects = action
 9936            if slot not in now.saves:
 9937                raise KeyError(
 9938                    f"Cannot load save slot {slot!r} because no save"
 9939                    f" data has been established for that slot."
 9940                )
 9941            load = now.saves[slot]
 9942            rGraph, rState = base.revertedState(
 9943                (now.graph, now.state),
 9944                load,
 9945                aspects
 9946            )
 9947            reverted = base.Situation(
 9948                graph=rGraph,
 9949                state=rState,
 9950                type='pending',
 9951                action=None,
 9952                saves=copy.deepcopy(now.saves),
 9953                tags={},
 9954                annotations=[]
 9955            )
 9956            self.situations.append(reverted)
 9957            # Apply any active triggers (edits reverted)
 9958            self.applyActiveTriggers()
 9959            # Figure out destinations set to return
 9960            newDestinations = set()
 9961            newPr = rState['primaryDecision']
 9962            if newPr is not None:
 9963                newDestinations.add(newPr)
 9964            return (reverted, newDestinations)
 9965
 9966        # TODO: These deep copies are expensive time-wise. Can we avoid
 9967        # them? Probably not.
 9968        newGraph = copy.deepcopy(now.graph)
 9969        newState = copy.deepcopy(now.state)
 9970        newSaves = copy.copy(now.saves)  # a shallow copy
 9971        newTags: Dict[base.Tag, base.TagValue] = {}
 9972        newAnnotations: List[base.Annotation] = []
 9973        updated = base.Situation(
 9974            graph=newGraph,
 9975            state=newState,
 9976            type='pending',
 9977            action=None,
 9978            saves=newSaves,
 9979            tags=newTags,
 9980            annotations=newAnnotations
 9981        )
 9982
 9983        targetContext: base.FocalContext
 9984
 9985        # Now that action effects have been imprinted into the updated
 9986        # situation, append it to our situations list
 9987        self.situations.append(updated)
 9988
 9989        # Figure out effects of the action:
 9990        if action is None:
 9991            raise InvalidActionError(
 9992                "None cannot be used as an action when advancing the"
 9993                " situation."
 9994            )
 9995
 9996        aLen = len(action)
 9997
 9998        destIDs = set()
 9999
10000        if (
10001            action[0] in ('start', 'take', 'explore', 'warp')
10002        and any(
10003                newGraph.domainFor(d) == ENDINGS_DOMAIN
10004                for d in self.getActiveDecisions()
10005            )
10006        ):
10007            activeEndings = [
10008                d
10009                for d in self.getActiveDecisions()
10010                if newGraph.domainFor(d) == ENDINGS_DOMAIN
10011            ]
10012            raise InvalidActionError(
10013                f"Attempted to {action[0]!r} while an ending was"
10014                f" active. Active endings are:"
10015                f"\n{newGraph.namesListing(activeEndings)}"
10016            )
10017
10018        if action == ('noAction',):
10019            # No updates needed
10020            pass
10021
10022        elif (
10023            not isinstance(action, tuple)
10024         or (action[0] not in get_args(base.ExplorationActionType))
10025         or not (2 <= aLen <= 7)
10026        ):
10027            raise InvalidActionError(
10028                f"Invalid ExplorationAction tuple (must be a tuple that"
10029                f" starts with an ExplorationActionType and has 2-6"
10030                f" entries if it's not ('noAction',)):"
10031                f"\n{repr(action)}"
10032            )
10033
10034        elif action[0] == 'start':
10035            (
10036                _,
10037                positionSpecifier,
10038                primary,
10039                domain,
10040                capabilities,
10041                mechanismStates,
10042                customState
10043            ) = cast(
10044                Tuple[
10045                    Literal['start'],
10046                    Union[
10047                        base.DecisionID,
10048                        Dict[base.FocalPointName, base.DecisionID],
10049                        Set[base.DecisionID]
10050                    ],
10051                    Optional[base.DecisionID],
10052                    base.Domain,
10053                    Optional[base.CapabilitySet],
10054                    Optional[Dict[base.MechanismID, base.MechanismState]],
10055                    Optional[dict]
10056                ],
10057                action
10058            )
10059            targetContext = newState['contexts'][
10060                newState['activeContext']
10061            ]
10062
10063            targetFocalization = base.getDomainFocalization(
10064                targetContext,
10065                domain
10066            )  # sets up 'singular' as default if
10067
10068            # Check if there are any already-active decisions.
10069            if targetContext['activeDecisions'][domain] is not None:
10070                raise BadStart(
10071                    f"Cannot start in domain {repr(domain)} because"
10072                    f" that domain already has a position. 'start' may"
10073                    f" only be used with domains that don't yet have"
10074                    f" any position information."
10075                )
10076
10077            # Make the domain active
10078            if domain not in targetContext['activeDomains']:
10079                targetContext['activeDomains'].add(domain)
10080
10081            # Check position info matches focalization type and update
10082            # exploration statuses
10083            if isinstance(positionSpecifier, base.DecisionID):
10084                if targetFocalization != 'singular':
10085                    raise BadStart(
10086                        f"Invalid position specifier"
10087                        f" {repr(positionSpecifier)} (type"
10088                        f" {type(positionSpecifier)}). Domain"
10089                        f" {repr(domain)} has {targetFocalization}"
10090                        f" focalization."
10091                    )
10092                base.setExplorationStatus(
10093                    updated.state,
10094                    updated.graph.resolveDecision(positionSpecifier),
10095                    'exploring',
10096                    upgradeOnly=True
10097                )
10098                destIDs.add(positionSpecifier)
10099            elif isinstance(positionSpecifier, dict):
10100                if targetFocalization != 'plural':
10101                    raise BadStart(
10102                        f"Invalid position specifier"
10103                        f" {repr(positionSpecifier)} (type"
10104                        f" {type(positionSpecifier)}). Domain"
10105                        f" {repr(domain)} has {targetFocalization}"
10106                        f" focalization."
10107                    )
10108                destIDs |= set(positionSpecifier.values())
10109            elif isinstance(positionSpecifier, set):
10110                if targetFocalization != 'spreading':
10111                    raise BadStart(
10112                        f"Invalid position specifier"
10113                        f" {repr(positionSpecifier)} (type"
10114                        f" {type(positionSpecifier)}). Domain"
10115                        f" {repr(domain)} has {targetFocalization}"
10116                        f" focalization."
10117                    )
10118                destIDs |= positionSpecifier
10119            else:
10120                raise TypeError(
10121                    f"Invalid position specifier"
10122                    f" {repr(positionSpecifier)} (type"
10123                    f" {type(positionSpecifier)}). It must be a"
10124                    f" DecisionID, a dictionary from FocalPointNames to"
10125                    f" DecisionIDs, or a set of DecisionIDs, according"
10126                    f" to the focalization of the relevant domain."
10127                )
10128
10129            # Put specified position(s) in place
10130            # TODO: This cast is really silly...
10131            targetContext['activeDecisions'][domain] = cast(
10132                Union[
10133                    None,
10134                    base.DecisionID,
10135                    Dict[base.FocalPointName, Optional[base.DecisionID]],
10136                    Set[base.DecisionID]
10137                ],
10138                positionSpecifier
10139            )
10140
10141            # Set primary decision
10142            newState['primaryDecision'] = primary
10143
10144            # Set capabilities
10145            if capabilities is not None:
10146                targetContext['capabilities'] = capabilities
10147
10148            # Set mechanism states
10149            if mechanismStates is not None:
10150                newState['mechanisms'] = mechanismStates
10151
10152            # Set custom state
10153            if customState is not None:
10154                newState['custom'] = customState
10155
10156        elif action[0] in ('explore', 'take', 'warp'):  # similar handling
10157            assert (
10158                len(action) == 3
10159             or len(action) == 4
10160             or len(action) == 6
10161             or len(action) == 7
10162            )
10163            # Set up necessary variables
10164            cSpec: base.ContextSpecifier = "active"
10165            fromID: Optional[base.DecisionID] = None
10166            takeTransition: Optional[base.Transition] = None
10167            outcomes: List[bool] = []
10168            destID: base.DecisionID  # No starting value as it's not optional
10169            moveInDomain: Optional[base.Domain] = None
10170            moveWhich: Optional[base.FocalPointName] = None
10171
10172            # Figure out target context
10173            if isinstance(action[1], str):
10174                if action[1] not in get_args(base.ContextSpecifier):
10175                    raise InvalidActionError(
10176                        f"Action specifies {repr(action[1])} context,"
10177                        f" but that's not a valid context specifier."
10178                        f" The valid options are:"
10179                        f"\n{repr(get_args(base.ContextSpecifier))}"
10180                    )
10181                else:
10182                    cSpec = cast(base.ContextSpecifier, action[1])
10183            else:  # Must be a `FocalPointSpecifier`
10184                cSpec, moveInDomain, moveWhich = cast(
10185                    base.FocalPointSpecifier,
10186                    action[1]
10187                )
10188                assert moveInDomain is not None
10189
10190            # Grab target context to work in
10191            if cSpec == 'common':
10192                targetContext = newState['common']
10193            else:
10194                targetContext = newState['contexts'][
10195                    newState['activeContext']
10196                ]
10197
10198            # Check focalization of the target domain
10199            if moveInDomain is not None:
10200                fType = base.getDomainFocalization(
10201                    targetContext,
10202                    moveInDomain
10203                )
10204                if (
10205                    (
10206                        isinstance(action[1], str)
10207                    and fType == 'plural'
10208                    ) or (
10209                        not isinstance(action[1], str)
10210                    and fType != 'plural'
10211                    )
10212                ):
10213                    raise ImpossibleActionError(
10214                        f"Invalid ExplorationAction (moves in"
10215                        f" plural-focalized domains must include a"
10216                        f" FocalPointSpecifier, while moves in"
10217                        f" non-plural-focalized domains must not."
10218                        f" Domain {repr(moveInDomain)} is"
10219                        f" {fType}-focalized):"
10220                        f"\n{repr(action)}"
10221                    )
10222
10223            if action[0] == "warp":
10224                # It's a warp, so destination is specified directly
10225                if not isinstance(action[2], base.DecisionID):
10226                    raise TypeError(
10227                        f"Invalid ExplorationAction tuple (third part"
10228                        f" must be a decision ID for 'warp' actions):"
10229                        f"\n{repr(action)}"
10230                    )
10231                else:
10232                    destID = cast(base.DecisionID, action[2])
10233
10234            elif aLen == 4 or aLen == 7:
10235                # direct 'take' or 'explore'
10236                fromID = cast(base.DecisionID, action[2])
10237                takeTransition, outcomes = cast(
10238                    base.TransitionWithOutcomes,
10239                    action[3]  # type: ignore [misc]
10240                )
10241                if (
10242                    not isinstance(fromID, base.DecisionID)
10243                 or not isinstance(takeTransition, base.Transition)
10244                ):
10245                    raise InvalidActionError(
10246                        f"Invalid ExplorationAction tuple (for 'take' or"
10247                        f" 'explore', if the length is 4/7, parts 2-4"
10248                        f" must be a context specifier, a decision ID, and a"
10249                        f" transition name. Got:"
10250                        f"\n{repr(action)}"
10251                    )
10252
10253                try:
10254                    destID = newGraph.destination(fromID, takeTransition)
10255                except MissingDecisionError:
10256                    raise ImpossibleActionError(
10257                        f"Invalid ExplorationAction: move from decision"
10258                        f" {fromID} is invalid because there is no"
10259                        f" decision with that ID in the current"
10260                        f" graph."
10261                        f"\nValid decisions are:"
10262                        f"\n{newGraph.namesListing(newGraph)}"
10263                    )
10264                except MissingTransitionError:
10265                    valid = newGraph.destinationsFrom(fromID)
10266                    listing = newGraph.destinationsListing(valid)
10267                    raise ImpossibleActionError(
10268                        f"Invalid ExplorationAction: move from decision"
10269                        f" {newGraph.identityOf(fromID)}"
10270                        f" along transition {repr(takeTransition)} is"
10271                        f" invalid because there is no such transition"
10272                        f" at that decision."
10273                        f"\nValid transitions there are:"
10274                        f"\n{listing}"
10275                    )
10276                targetActive = targetContext['activeDecisions']
10277                if moveInDomain is not None:
10278                    activeInDomain = targetActive[moveInDomain]
10279                    if (
10280                        (
10281                            isinstance(activeInDomain, base.DecisionID)
10282                        and fromID != activeInDomain
10283                        )
10284                     or (
10285                            isinstance(activeInDomain, set)
10286                        and fromID not in activeInDomain
10287                        )
10288                     or (
10289                            isinstance(activeInDomain, dict)
10290                        and fromID not in activeInDomain.values()
10291                        )
10292                    ):
10293                        raise ImpossibleActionError(
10294                            f"Invalid ExplorationAction: move from"
10295                            f" decision {fromID} is invalid because"
10296                            f" that decision is not active in domain"
10297                            f" {repr(moveInDomain)} in the current"
10298                            f" graph."
10299                            f"\nValid decisions are:"
10300                            f"\n{newGraph.namesListing(newGraph)}"
10301                        )
10302
10303            elif aLen == 3 or aLen == 6:
10304                # 'take' or 'explore' focal point
10305                # We know that moveInDomain is not None here.
10306                assert moveInDomain is not None
10307                if not isinstance(action[2], base.Transition):
10308                    raise InvalidActionError(
10309                        f"Invalid ExplorationAction tuple (for 'take'"
10310                        f" actions if the second part is a"
10311                        f" FocalPointSpecifier the third part must be a"
10312                        f" transition name):"
10313                        f"\n{repr(action)}"
10314                    )
10315
10316                takeTransition, outcomes = cast(
10317                    base.TransitionWithOutcomes,
10318                    action[2]
10319                )
10320                targetActive = targetContext['activeDecisions']
10321                activeInDomain = cast(
10322                    Dict[base.FocalPointName, Optional[base.DecisionID]],
10323                    targetActive[moveInDomain]
10324                )
10325                if (
10326                    moveInDomain is not None
10327                and (
10328                        not isinstance(activeInDomain, dict)
10329                     or moveWhich not in activeInDomain
10330                    )
10331                ):
10332                    raise ImpossibleActionError(
10333                        f"Invalid ExplorationAction: move of focal"
10334                        f" point {repr(moveWhich)} in domain"
10335                        f" {repr(moveInDomain)} is invalid because"
10336                        f" that domain does not have a focal point"
10337                        f" with that name."
10338                    )
10339                fromID = activeInDomain[moveWhich]
10340                if fromID is None:
10341                    raise ImpossibleActionError(
10342                        f"Invalid ExplorationAction: move of focal"
10343                        f" point {repr(moveWhich)} in domain"
10344                        f" {repr(moveInDomain)} is invalid because"
10345                        f" that focal point does not have a position"
10346                        f" at this step."
10347                    )
10348                try:
10349                    destID = newGraph.destination(fromID, takeTransition)
10350                except MissingDecisionError:
10351                    raise ImpossibleActionError(
10352                        f"Invalid exploration state: focal point"
10353                        f" {repr(moveWhich)} in domain"
10354                        f" {repr(moveInDomain)} specifies decision"
10355                        f" {fromID} as the current position, but"
10356                        f" that decision does not exist!"
10357                    )
10358                except MissingTransitionError:
10359                    valid = newGraph.destinationsFrom(fromID)
10360                    listing = newGraph.destinationsListing(valid)
10361                    raise ImpossibleActionError(
10362                        f"Invalid ExplorationAction: move of focal"
10363                        f" point {repr(moveWhich)} in domain"
10364                        f" {repr(moveInDomain)} along transition"
10365                        f" {repr(takeTransition)} is invalid because"
10366                        f" that focal point is at decision"
10367                        f" {newGraph.identityOf(fromID)} and that"
10368                        f" decision does not have an outgoing"
10369                        f" transition with that name.\nValid"
10370                        f" transitions from that decision are:"
10371                        f"\n{listing}"
10372                    )
10373
10374            else:
10375                raise InvalidActionError(
10376                    f"Invalid ExplorationAction: unrecognized"
10377                    f" 'explore', 'take' or 'warp' format:"
10378                    f"\n{action}"
10379                )
10380
10381            # If we're exploring, update information for the destination
10382            if action[0] == 'explore':
10383                zone = cast(Optional[base.Zone], action[-1])
10384                recipName = cast(Optional[base.Transition], action[-2])
10385                destOrName = cast(
10386                    Union[base.DecisionName, base.DecisionID, None],
10387                    action[-3]
10388                )
10389                if isinstance(destOrName, base.DecisionID):
10390                    destID = destOrName
10391
10392                if fromID is None or takeTransition is None:
10393                    raise ImpossibleActionError(
10394                        f"Invalid ExplorationAction: exploration"
10395                        f" has unclear origin decision or transition."
10396                        f" Got:\n{action}"
10397                    )
10398
10399                currentDest = newGraph.destination(fromID, takeTransition)
10400                if not newGraph.isConfirmed(currentDest):
10401                    newGraph.replaceUnconfirmed(
10402                        fromID,
10403                        takeTransition,
10404                        destOrName,
10405                        recipName,
10406                        placeInZone=zone,
10407                        forceNew=not isinstance(destOrName, base.DecisionID)
10408                    )
10409                else:
10410                    # Otherwise, since the destination already existed
10411                    # and was hooked up at the right decision, no graph
10412                    # edits need to be made, unless we need to rename
10413                    # the reciprocal.
10414                    # TODO: Do we care about zones here?
10415                    if recipName is not None:
10416                        oldReciprocal = newGraph.getReciprocal(
10417                            fromID,
10418                            takeTransition
10419                        )
10420                        if (
10421                            oldReciprocal is not None
10422                        and oldReciprocal != recipName
10423                        ):
10424                            newGraph.addTransition(
10425                                destID,
10426                                recipName,
10427                                fromID,
10428                                None
10429                            )
10430                            newGraph.setReciprocal(
10431                                destID,
10432                                recipName,
10433                                takeTransition,
10434                                setBoth=True
10435                            )
10436                            newGraph.mergeTransitions(
10437                                destID,
10438                                oldReciprocal,
10439                                recipName
10440                            )
10441
10442            # If we are moving along a transition, check requirements
10443            # and apply transition effects *before* updating our
10444            # position, and check that they don't cancel the normal
10445            # position update
10446            finalDest = None
10447            if takeTransition is not None:
10448                assert fromID is not None  # both or neither
10449                if not self.isTraversable(fromID, takeTransition):
10450                    if (fromID, takeTransition) in now.state['deactivated']:
10451                        warnings.warn(
10452                            (
10453                                f"The transition {takeTransition!r}"
10454                                f" from decision"
10455                                f" {now.graph.identityOf(fromID)} was"
10456                                f" already deactivated before step"
10457                                f" {len(self) - 1}."
10458                            ),
10459                            TransitionBlockedWarning
10460                        )
10461                    else:
10462                        req = now.graph.getTransitionRequirement(
10463                            fromID,
10464                            takeTransition
10465                        )
10466                        warnings.warn(
10467                            (
10468                                f"The requirements for transition"
10469                                f" {takeTransition!r} from decision"
10470                                f" {now.graph.identityOf(fromID)} are"
10471                                f" not met at step {len(self) - 1}:"
10472                                f"\n{req}"
10473                            ),
10474                            TransitionBlockedWarning
10475                        )
10476
10477                # Apply transition consequences to our new state and
10478                # figure out if we need to skip our normal update or not
10479                finalDest = self.applyTransitionConsequence(
10480                    fromID,
10481                    (takeTransition, outcomes),
10482                    moveWhich,
10483                    challengePolicy
10484                )
10485
10486            # Check moveInDomain
10487            destDomain = newGraph.domainFor(destID)
10488            if moveInDomain is not None and moveInDomain != destDomain:
10489                raise ImpossibleActionError(
10490                    f"Invalid ExplorationAction: move specified"
10491                    f" domain {repr(moveInDomain)} as the domain of"
10492                    f" the focal point to move, but the destination"
10493                    f" of the move is {now.graph.identityOf(destID)}"
10494                    f" which is in domain {repr(destDomain)}, so focal"
10495                    f" point {repr(moveWhich)} cannot be moved there."
10496                )
10497
10498            # Now that we know where we're going, update position
10499            # information (assuming it wasn't already set):
10500            if finalDest is None:
10501                finalDest = destID
10502                base.updatePosition(
10503                    updated.state,
10504                    updated.graph,
10505                    destID,
10506                    cSpec,
10507                    moveWhich
10508                )
10509
10510            destIDs.add(finalDest)
10511
10512        elif action[0] == "focus":
10513            # Figure out target context
10514            action = cast(
10515                Tuple[
10516                    Literal['focus'],
10517                    base.ContextSpecifier,
10518                    Set[base.Domain],
10519                    Set[base.Domain]
10520                ],
10521                action
10522            )
10523            contextSpecifier: base.ContextSpecifier = action[1]
10524            if contextSpecifier == 'common':
10525                targetContext = newState['common']
10526            else:
10527                targetContext = newState['contexts'][
10528                    newState['activeContext']
10529                ]
10530
10531            # Just need to swap out active domains
10532            goingOut, comingIn = cast(
10533                Tuple[Set[base.Domain], Set[base.Domain]],
10534                action[2:]
10535            )
10536            if (
10537                not isinstance(goingOut, set)
10538             or not isinstance(comingIn, set)
10539             or not all(isinstance(d, base.Domain) for d in goingOut)
10540             or not all(isinstance(d, base.Domain) for d in comingIn)
10541            ):
10542                raise InvalidActionError(
10543                    f"Invalid ExplorationAction tuple (must have 4"
10544                    f" parts if the first part is 'focus' and"
10545                    f" the third and fourth parts must be sets of"
10546                    f" domains):"
10547                    f"\n{repr(action)}"
10548                )
10549            activeSet = targetContext['activeDomains']
10550            for dom in goingOut:
10551                try:
10552                    activeSet.remove(dom)
10553                except KeyError:
10554                    warnings.warn(
10555                        (
10556                            f"Domain {repr(dom)} was deactivated at"
10557                            f" step {len(self)} but it was already"
10558                            f" inactive at that point."
10559                        ),
10560                        InactiveDomainWarning
10561                    )
10562            # TODO: Also warn for doubly-activated domains?
10563            activeSet |= comingIn
10564
10565            # destIDs remains empty in this case
10566
10567        elif action[0] == 'swap':  # update which `FocalContext` is active
10568            newContext = cast(base.FocalContextName, action[1])
10569            if newContext not in newState['contexts']:
10570                raise MissingFocalContextError(
10571                    f"'swap' action with target {repr(newContext)} is"
10572                    f" invalid because no context with that name"
10573                    f" exists."
10574                )
10575            newState['activeContext'] = newContext
10576
10577            # destIDs remains empty in this case
10578
10579        elif action[0] == 'focalize':  # create new `FocalContext`
10580            newContext = cast(base.FocalContextName, action[1])
10581            if newContext in newState['contexts']:
10582                raise FocalContextCollisionError(
10583                    f"'focalize' action with target {repr(newContext)}"
10584                    f" is invalid because a context with that name"
10585                    f" already exists."
10586                )
10587            newState['contexts'][newContext] = base.emptyFocalContext()
10588            newState['activeContext'] = newContext
10589
10590            # destIDs remains empty in this case
10591
10592        # revertTo is handled above
10593        else:
10594            raise InvalidActionError(
10595                f"Invalid ExplorationAction tuple (first item must be"
10596                f" an ExplorationActionType, and tuple must be length-1"
10597                f" if the action type is 'noAction'):"
10598                f"\n{repr(action)}"
10599            )
10600
10601        # Apply any active triggers
10602        followTo = self.applyActiveTriggers()
10603        if followTo is not None:
10604            destIDs.add(followTo)
10605            # TODO: Re-work to work with multiple position updates in
10606            # different focal contexts, domains, and/or for different
10607            # focal points in plural-focalized domains.
10608
10609        return (updated, destIDs)

Given an ExplorationAction, sets that as the action taken in the current situation, and adds a new situation with the results of that action. A DoubleActionError will be raised if the current situation already has an action specified, and/or has a decision type other than 'pending'. By default the type of the decision will be 'active' but another DecisionType can be specified via the decisionType parameter.

If the action specified is ('noAction',), then the new situation will be a copy of the old one; this represents waiting or being at an ending (a decision type other than 'pending' should be used).

Although None can appear as the action entry in situations with pending decisions, you cannot call advanceSituation with None as the action.

If the action includes taking a transition whose requirements are not satisfied, the transition will still be taken (and any consequences applied) but a TransitionBlockedWarning will be issued.

A ChallengePolicy may be specified, the default is 'specified' which requires that outcomes are pre-specified. If any other policy is set, the challenge outcomes will be reset before re-resolving them according to the provided policy.

The new situation will have decision type 'pending' and None as the action.

The new situation created as a result of the action is returned, along with the set of destination decision IDs, including possibly a modified destination via 'bounce', 'goto', and/or 'follow' effects. For actions that don't have a destination, the second part of the returned tuple will be an empty set. Multiple IDs may be in the set when using a start action in a plural- or spreading-focalized domain, for example.

If the action updates active decisions (including via transition effects) this will also update the exploration status of those decisions to 'exploring' if they had been in an unvisited status (see updatePosition and hasBeenVisited). This includes decisions traveled through but not ultimately arrived at via 'follow' effects. These will also lose any 'unconfirmed' tags they might have had.

If any decisions are active in the ENDINGS_DOMAIN, attempting to 'warp', 'explore', 'take', or 'start' will raise an InvalidActionError.

def applyActiveTriggers(self) -> Optional[int]:
10611    def applyActiveTriggers(self) -> Optional[base.DecisionID]:
10612        """
10613        Finds all actions with the 'trigger' tag attached to currently
10614        active decisions, and applies their effects if their requirements
10615        are met (ordered by decision-ID with ties broken alphabetically
10616        by action name).
10617
10618        'bounce', 'goto' and 'follow' effects may apply. However, any
10619        new triggers that would be activated because of decisions
10620        reached by such effects will not apply. Note that 'bounce'
10621        effects update position to the decision where the action was
10622        attached, which is usually a no-op. This function returns the
10623        decision ID of the decision reached by the last decision-moving
10624        effect applied, or `None` if no such effects triggered.
10625
10626        TODO: What about situations where positions are updated in
10627        multiple domains or multiple foal points in a plural domain are
10628        independently updated?
10629
10630        TODO: Tests for this!
10631        """
10632        active = self.getActiveDecisions()
10633        now = self.getSituation()
10634        graph = now.graph
10635        finalFollow = None
10636        for decision in sorted(active):
10637            for action in sorted(graph.decisionActions(decision)):
10638                if (
10639                    'trigger' in graph.transitionTags(decision, action)
10640                and self.isTraversable(decision, action)
10641                ):
10642                    followTo = self.applyTransitionConsequence(
10643                        decision,
10644                        action
10645                    )
10646                    if followTo is not None:
10647                        # TODO: How will triggers interact with
10648                        # plural-focalized domains? Probably need to fix
10649                        # this to detect moveWhich based on which focal
10650                        # points are at the decision where the transition
10651                        # is, and then apply this to each of them?
10652                        base.updatePosition(now.state, now.graph, followTo)
10653                        finalFollow = followTo
10654
10655        return finalFollow

Finds all actions with the 'trigger' tag attached to currently active decisions, and applies their effects if their requirements are met (ordered by decision-ID with ties broken alphabetically by action name).

'bounce', 'goto' and 'follow' effects may apply. However, any new triggers that would be activated because of decisions reached by such effects will not apply. Note that 'bounce' effects update position to the decision where the action was attached, which is usually a no-op. This function returns the decision ID of the decision reached by the last decision-moving effect applied, or None if no such effects triggered.

TODO: What about situations where positions are updated in multiple domains or multiple foal points in a plural domain are independently updated?

TODO: Tests for this!

def explore( self, transition: Union[str, Tuple[str, List[bool]]], destination: Union[str, int, NoneType], reciprocal: Optional[str] = None, zone: Optional[str] = '', fromDecision: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None, whichFocus: Optional[Tuple[Literal['common', 'active'], str, str]] = None, inCommon: Union[bool, Literal['auto']] = 'auto', decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active', challengePolicy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'specified') -> int:
10657    def explore(
10658        self,
10659        transition: base.AnyTransition,
10660        destination: Union[base.DecisionName, base.DecisionID, None],
10661        reciprocal: Optional[base.Transition] = None,
10662        zone: Optional[base.Zone] = base.DefaultZone,
10663        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
10664        whichFocus: Optional[base.FocalPointSpecifier] = None,
10665        inCommon: Union[bool, Literal["auto"]] = "auto",
10666        decisionType: base.DecisionType = "active",
10667        challengePolicy: base.ChallengePolicy = "specified"
10668    ) -> base.DecisionID:
10669        """
10670        Adds a new situation to the exploration representing the
10671        traversal of the specified transition (possibly with outcomes
10672        specified for challenges among that transitions consequences).
10673        Uses `deduceTransitionDetailsAtStep` to figure out from the
10674        transition name which specific transition is taken (and which
10675        focal point is updated if necessary). This uses the
10676        `fromDecision`, `whichFocus`, and `inCommon` optional
10677        parameters, and also determines whether to update the common or
10678        the active `FocalContext`. Sets the exploration status of the
10679        decision explored to 'exploring'. Returns the decision ID for
10680        the destination reached, accounting for goto/bounce/follow
10681        effects that might have triggered.
10682
10683        If multiple decisions are reached (e.g., in multiple domains,
10684        like you arrive at the destination but also die) it returns the
10685        decision with the highest decision ID (i.e., discovered latest)
10686        among decisions in the same domain as the natural endpoint of the
10687        transition taken, or if there are no such decisions, it returns
10688        the decision with the highest ID out of all newly-arrived-at
10689        decisions.
10690
10691        The `destination` will be used to name the newly-explored
10692        decision, except when it's a `DecisionID`, in which case that
10693        decision must be unvisited, and we'll connect the specified
10694        transition to that decision.
10695
10696        The focalization of the destination domain in the context to be
10697        updated determines how active decisions are changed:
10698
10699        - If the destination domain is focalized as 'single', then in
10700            the subsequent `Situation`, the destination decision will
10701            become the single active decision in that domain.
10702        - If it's focalized as 'plural', then one of the
10703            `FocalPointName`s for that domain will be moved to activate
10704            that decision; which one can be specified using `whichFocus`
10705            or if left unspecified, will be deduced: if the starting
10706            decision is in the same domain, then the
10707            alphabetically-earliest focal point which is at the starting
10708            decision will be moved. If the starting position is in a
10709            different domain, then the alphabetically earliest focal
10710            point among all focal points in the destination domain will
10711            be moved.
10712        - If it's focalized as 'spreading', then the destination
10713            decision will be added to the set of active decisions in
10714            that domain, without removing any.
10715
10716        The transition named must have been pointing to an unvisited
10717        decision (see `hasBeenVisited`), and the name of that decision
10718        will be updated if a `destination` value is given (a
10719        `DecisionCollisionWarning` will be issued if the destination
10720        name is a duplicate of another name in the graph, although this
10721        is not an error). Additionally:
10722
10723        - If a `reciprocal` name is specified, the reciprocal transition
10724            will be renamed using that name, or created with that name if
10725            it didn't already exist. If reciprocal is left as `None` (the
10726            default) then no change will be made to the reciprocal
10727            transition, and it will not be created if it doesn't exist.
10728        - If a `zone` is specified, the newly-explored decision will be
10729            added to that zone (and that zone will be created at level 0
10730            if it didn't already exist). If `zone` is set to `None` then
10731            it will not be added to any new zones. If `zone` is left as
10732            the default (the `base.DefaultZone` value) then the explored
10733            decision will be added to each zone that the decision it was
10734            explored from is a part of. If a zone needs to be created,
10735            that zone will be added as a sub-zone of each zone which is a
10736            parent of a zone that directly contains the origin decision.
10737        - An `ExplorationStatusError` will be raised if the specified
10738            transition leads to a decision whose `ExplorationStatus` is
10739            'exploring' or higher (i.e., `hasBeenVisited`). (Use
10740            `returnTo` instead to adjust things when a transition to an
10741            unknown destination turns out to lead to an already-known
10742            destination.)
10743        - A `TransitionBlockedWarning` will be issued if the specified
10744            transition is not traversable given the current game state
10745            (but in that last case the step will still be taken).
10746        - By default, the decision type for the new step will be
10747            'active', but a `decisionType` value can be specified to
10748            override that.
10749        - By default, the 'mostLikely' `ChallengePolicy` will be used to
10750            resolve challenges in the consequence of the transition
10751            taken, but an alternate policy can be supplied using the
10752            `challengePolicy` argument.
10753        """
10754        now = self.getSituation()
10755
10756        transitionName, outcomes = base.nameAndOutcomes(transition)
10757
10758        # Deduce transition details from the name + optional specifiers
10759        (
10760            using,
10761            fromID,
10762            destID,
10763            whichFocus
10764        ) = self.deduceTransitionDetailsAtStep(
10765            -1,
10766            transitionName,
10767            fromDecision,
10768            whichFocus,
10769            inCommon
10770        )
10771
10772        # Issue a warning if the destination name is already in use
10773        if destination is not None:
10774            if isinstance(destination, base.DecisionName):
10775                try:
10776                    existingID = now.graph.resolveDecision(destination)
10777                    collision = existingID != destID
10778                except MissingDecisionError:
10779                    collision = False
10780                except AmbiguousDecisionSpecifierError:
10781                    collision = True
10782
10783                if collision and WARN_OF_NAME_COLLISIONS:
10784                    warnings.warn(
10785                        (
10786                            f"The destination name {repr(destination)} is"
10787                            f" already in use when exploring transition"
10788                            f" {repr(transition)} from decision"
10789                            f" {now.graph.identityOf(fromID)} at step"
10790                            f" {len(self) - 1}."
10791                        ),
10792                        DecisionCollisionWarning
10793                    )
10794
10795        # TODO: Different terminology for "exploration state above
10796        # noticed" vs. "DG thinks it's been visited"...
10797        if (
10798            self.hasBeenVisited(destID)
10799        ):
10800            frStr = ''
10801            if fromDecision is not None:
10802                frStr = f"from decision {now.graph.identityOf(fromDecision)} "
10803            raise ExplorationStatusError(
10804                f"Cannot explore {frStr}to decision"
10805                f" {now.graph.identityOf(destID)} because it has"
10806                f" already been visited. Use returnTo instead of"
10807                f" explore when discovering a connection back to a"
10808                f" previously-explored decision."
10809            )
10810
10811        if (
10812            isinstance(destination, base.DecisionID)
10813        and self.hasBeenVisited(destination)
10814        ):
10815            frStr = ''
10816            if fromDecision is not None:
10817                frStr = f"from decision {now.graph.identityOf(fromDecision)} "
10818            raise ExplorationStatusError(
10819                f"Cannot explore {frStr}to decision"
10820                f" {now.graph.identityOf(destination)} because it has"
10821                f" already been visited. Use returnTo instead of"
10822                f" explore when discovering a connection back to a"
10823                f" previously-explored decision."
10824            )
10825
10826        actionTaken: base.ExplorationAction = (
10827            'explore',
10828            using,
10829            fromID,
10830            (transitionName, outcomes),
10831            destination,
10832            reciprocal,
10833            zone
10834        )
10835        if whichFocus is not None:
10836            # A move-from-specific-focal-point action
10837            actionTaken = (
10838                'explore',
10839                whichFocus,
10840                (transitionName, outcomes),
10841                destination,
10842                reciprocal,
10843                zone
10844            )
10845
10846        # Advance the situation, applying transition effects and
10847        # updating the destination decision.
10848        _, finalDests = self.advanceSituation(
10849            actionTaken,
10850            decisionType,
10851            challengePolicy
10852        )
10853
10854        return self.mostApplicableDestination(
10855            now.graph,
10856            fromID,
10857            destID,
10858            finalDests
10859        )

Adds a new situation to the exploration representing the traversal of the specified transition (possibly with outcomes specified for challenges among that transitions consequences). Uses deduceTransitionDetailsAtStep to figure out from the transition name which specific transition is taken (and which focal point is updated if necessary). This uses the fromDecision, whichFocus, and inCommon optional parameters, and also determines whether to update the common or the active FocalContext. Sets the exploration status of the decision explored to 'exploring'. Returns the decision ID for the destination reached, accounting for goto/bounce/follow effects that might have triggered.

If multiple decisions are reached (e.g., in multiple domains, like you arrive at the destination but also die) it returns the decision with the highest decision ID (i.e., discovered latest) among decisions in the same domain as the natural endpoint of the transition taken, or if there are no such decisions, it returns the decision with the highest ID out of all newly-arrived-at decisions.

The destination will be used to name the newly-explored decision, except when it's a DecisionID, in which case that decision must be unvisited, and we'll connect the specified transition to that decision.

The focalization of the destination domain in the context to be updated determines how active decisions are changed:

  • If the destination domain is focalized as 'single', then in the subsequent Situation, the destination decision will become the single active decision in that domain.
  • If it's focalized as 'plural', then one of the FocalPointNames for that domain will be moved to activate that decision; which one can be specified using whichFocus or if left unspecified, will be deduced: if the starting decision is in the same domain, then the alphabetically-earliest focal point which is at the starting decision will be moved. If the starting position is in a different domain, then the alphabetically earliest focal point among all focal points in the destination domain will be moved.
  • If it's focalized as 'spreading', then the destination decision will be added to the set of active decisions in that domain, without removing any.

The transition named must have been pointing to an unvisited decision (see hasBeenVisited), and the name of that decision will be updated if a destination value is given (a DecisionCollisionWarning will be issued if the destination name is a duplicate of another name in the graph, although this is not an error). Additionally:

  • If a reciprocal name is specified, the reciprocal transition will be renamed using that name, or created with that name if it didn't already exist. If reciprocal is left as None (the default) then no change will be made to the reciprocal transition, and it will not be created if it doesn't exist.
  • If a zone is specified, the newly-explored decision will be added to that zone (and that zone will be created at level 0 if it didn't already exist). If zone is set to None then it will not be added to any new zones. If zone is left as the default (the base.DefaultZone value) then the explored decision will be added to each zone that the decision it was explored from is a part of. If a zone needs to be created, that zone will be added as a sub-zone of each zone which is a parent of a zone that directly contains the origin decision.
  • An ExplorationStatusError will be raised if the specified transition leads to a decision whose ExplorationStatus is 'exploring' or higher (i.e., hasBeenVisited). (Use returnTo instead to adjust things when a transition to an unknown destination turns out to lead to an already-known destination.)
  • A TransitionBlockedWarning will be issued if the specified transition is not traversable given the current game state (but in that last case the step will still be taken).
  • By default, the decision type for the new step will be 'active', but a decisionType value can be specified to override that.
  • By default, the 'mostLikely' ChallengePolicy will be used to resolve challenges in the consequence of the transition taken, but an alternate policy can be supplied using the challengePolicy argument.
def mostApplicableDestination( self, graph: DecisionGraph, fromID: int, destID: int, destinationSet: Set[int]) -> int:
10861    def mostApplicableDestination(
10862        self,
10863        graph: DecisionGraph,
10864        fromID: base.DecisionID,
10865        destID: base.DecisionID,
10866        destinationSet: Set[base.DecisionID]
10867    ) -> base.DecisionID:
10868        """
10869        Returns the single decision ID that's "most applicable" as the
10870        destination of an action that moved from the given `fromID`
10871        decision to the given `destID` decision (naively) on the given
10872        `graph` with the given `destinationSet` as the set of newly-active
10873        decisions from an `advanceSituation` call.
10874
10875        `advanceSituation` can return multiple or zero active decisions
10876        (e.g., if you take a transition but then die as a consequence,
10877        you'll be at the destination plus at the death ending in the
10878        endings domain, or if you take a transition with 'follow'
10879        consequences in a spreading-focalized domain).
10880
10881        When multiple decisions are present in the destination set, this
10882        function returns the decision with the highest ID (i.e.,
10883        discovered most recently) that's in the same domain as the
10884        destination decision, or if there are none in that domain, the
10885        one with the highest decision ID overall.
10886
10887        If the destination set is empty, it returns the `fromID`.
10888        """
10889        if len(destinationSet) == 0:
10890            return fromID
10891        elif len(destinationSet) > 1:
10892            # Figure out which destination(s) are in the same domain as
10893            # the natural destination, and return the one with the
10894            # highest ID among those, or the one with the highest ID
10895            # overall if there are none.
10896            destDomain = graph.domainFor(destID)
10897            inSame = [
10898                x
10899                for x in destinationSet
10900                if graph.domainFor(x) == destDomain
10901            ]
10902            if len(inSame) == 0:
10903                return max(destinationSet)
10904            else:
10905                return max(inSame)
10906        else:
10907            return next(x for x in destinationSet)

Returns the single decision ID that's "most applicable" as the destination of an action that moved from the given fromID decision to the given destID decision (naively) on the given graph with the given destinationSet as the set of newly-active decisions from an advanceSituation call.

advanceSituation can return multiple or zero active decisions (e.g., if you take a transition but then die as a consequence, you'll be at the destination plus at the death ending in the endings domain, or if you take a transition with 'follow' consequences in a spreading-focalized domain).

When multiple decisions are present in the destination set, this function returns the decision with the highest ID (i.e., discovered most recently) that's in the same domain as the destination decision, or if there are none in that domain, the one with the highest decision ID overall.

If the destination set is empty, it returns the fromID.

def returnTo( self, transition: Union[str, Tuple[str, List[bool]]], destination: Union[int, exploration.base.DecisionSpecifier, str], reciprocal: Optional[str] = None, fromDecision: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None, whichFocus: Optional[Tuple[Literal['common', 'active'], str, str]] = None, inCommon: Union[bool, Literal['auto']] = 'auto', decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active', challengePolicy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'specified') -> int:
10909    def returnTo(
10910        self,
10911        transition: base.AnyTransition,
10912        destination: base.AnyDecisionSpecifier,
10913        reciprocal: Optional[base.Transition] = None,
10914        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
10915        whichFocus: Optional[base.FocalPointSpecifier] = None,
10916        inCommon: Union[bool, Literal["auto"]] = "auto",
10917        decisionType: base.DecisionType = "active",
10918        challengePolicy: base.ChallengePolicy = "specified"
10919    ) -> base.DecisionID:
10920        """
10921        Adds a new graph to the exploration that replaces the given
10922        transition at the current position (which must lead to an unknown
10923        node, or a `MissingDecisionError` will result). The new
10924        transition will connect back to the specified destination, which
10925        must already exist (or a different `ValueError` will be raised).
10926        Returns the decision ID for the destination reached.
10927
10928        Deduces transition details using the optional `fromDecision`,
10929        `whichFocus`, and `inCommon` arguments in addition to the
10930        `transition` value; see `deduceTransitionDetailsAtStep`.
10931
10932        If a `reciprocal` transition is specified, that transition must
10933        either not already exist in the destination decision or lead to
10934        an unknown region; it will be replaced (or added) as an edge
10935        leading back to the current position.
10936
10937        The `decisionType` and `challengePolicy` optional arguments are
10938        used for `advanceSituation`.
10939
10940        A `TransitionBlockedWarning` will be issued if the requirements
10941        for the transition are not met, but the step will still be taken.
10942        Raises a `MissingDecisionError` if there is no current
10943        transition.
10944        """
10945        now = self.getSituation()
10946
10947        transitionName, outcomes = base.nameAndOutcomes(transition)
10948
10949        # Deduce transition details from the name + optional specifiers
10950        (
10951            using,
10952            fromID,
10953            destID,
10954            whichFocus
10955        ) = self.deduceTransitionDetailsAtStep(
10956            -1,
10957            transitionName,
10958            fromDecision,
10959            whichFocus,
10960            inCommon
10961        )
10962
10963        # Replace with connection to existing destination
10964        destID = now.graph.resolveDecision(destination)
10965        if not self.hasBeenVisited(destID):
10966            raise ExplorationStatusError(
10967                f"Cannot return to decision"
10968                f" {now.graph.identityOf(destID)} because it has NOT"
10969                f" already been at least partially explored. Use"
10970                f" explore instead of returnTo when discovering a"
10971                f" connection to a previously-unexplored decision."
10972            )
10973
10974        now.graph.replaceUnconfirmed(
10975            fromID,
10976            transitionName,
10977            destID,
10978            reciprocal
10979        )
10980
10981        # A move-from-decision action
10982        actionTaken: base.ExplorationAction = (
10983            'take',
10984            using,
10985            fromID,
10986            (transitionName, outcomes)
10987        )
10988        if whichFocus is not None:
10989            # A move-from-specific-focal-point action
10990            actionTaken = ('take', whichFocus, (transitionName, outcomes))
10991
10992        # Next, advance the situation, applying transition effects
10993        _, finalDests = self.advanceSituation(
10994            actionTaken,
10995            decisionType,
10996            challengePolicy
10997        )
10998
10999        return self.mostApplicableDestination(
11000            now.graph,
11001            fromID,
11002            destID,
11003            finalDests
11004        )

Adds a new graph to the exploration that replaces the given transition at the current position (which must lead to an unknown node, or a MissingDecisionError will result). The new transition will connect back to the specified destination, which must already exist (or a different ValueError will be raised). Returns the decision ID for the destination reached.

Deduces transition details using the optional fromDecision, whichFocus, and inCommon arguments in addition to the transition value; see deduceTransitionDetailsAtStep.

If a reciprocal transition is specified, that transition must either not already exist in the destination decision or lead to an unknown region; it will be replaced (or added) as an edge leading back to the current position.

The decisionType and challengePolicy optional arguments are used for advanceSituation.

A TransitionBlockedWarning will be issued if the requirements for the transition are not met, but the step will still be taken. Raises a MissingDecisionError if there is no current transition.

def takeAction( self, action: Union[str, Tuple[str, List[bool]]], requires: Optional[exploration.base.Requirement] = None, consequence: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None, fromDecision: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None, whichFocus: Optional[Tuple[Literal['common', 'active'], str, str]] = None, inCommon: Union[bool, Literal['auto']] = 'auto', decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active', challengePolicy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'specified') -> int:
11006    def takeAction(
11007        self,
11008        action: base.AnyTransition,
11009        requires: Optional[base.Requirement] = None,
11010        consequence: Optional[base.Consequence] = None,
11011        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
11012        whichFocus: Optional[base.FocalPointSpecifier] = None,
11013        inCommon: Union[bool, Literal["auto"]] = "auto",
11014        decisionType: base.DecisionType = "active",
11015        challengePolicy: base.ChallengePolicy = "specified"
11016    ) -> base.DecisionID:
11017        """
11018        Adds a new graph to the exploration based on taking the given
11019        action, which must be a self-transition in the graph. If the
11020        action does not already exist in the graph, it will be created.
11021        Either way if requirements and/or a consequence are supplied,
11022        the requirements and consequence of the action will be updated
11023        to match them, and those are the requirements/consequence that
11024        will count.
11025
11026        Returns the decision ID for the decision reached, which normally
11027        is the same action you were just at, but which might be altered
11028        by goto, bounce, and/or follow effects.
11029
11030        Issues a `TransitionBlockedWarning` if the current game state
11031        doesn't satisfy the requirements for the action.
11032
11033        The `fromDecision`, `whichFocus`, and `inCommon` arguments are
11034        used for `deduceTransitionDetailsAtStep`, while `decisionType`
11035        and `challengePolicy` are used for `advanceSituation`.
11036
11037        When an action is being created, `fromDecision` (or
11038        `whichFocus`) must be specified, since the source decision won't
11039        be deducible from the transition name. Note that if a transition
11040        with the given name exists from *any* active decision, it will
11041        be used instead of creating a new action (possibly resulting in
11042        an error if it's not a self-loop transition). Also, you may get
11043        an `AmbiguousTransitionError` if several transitions with that
11044        name exist; in that case use `fromDecision` and/or `whichFocus`
11045        to disambiguate.
11046        """
11047        now = self.getSituation()
11048        graph = now.graph
11049
11050        actionName, outcomes = base.nameAndOutcomes(action)
11051
11052        try:
11053            (
11054                using,
11055                fromID,
11056                destID,
11057                whichFocus
11058            ) = self.deduceTransitionDetailsAtStep(
11059                -1,
11060                actionName,
11061                fromDecision,
11062                whichFocus,
11063                inCommon
11064            )
11065
11066            if destID != fromID:
11067                raise ValueError(
11068                    f"Cannot take action {repr(action)} because it's a"
11069                    f" transition to another decision, not an action"
11070                    f" (use explore, returnTo, and/or retrace instead)."
11071                )
11072
11073        except MissingTransitionError:
11074            using = 'active'
11075            if inCommon is True:
11076                using = 'common'
11077
11078            if fromDecision is not None:
11079                fromID = graph.resolveDecision(fromDecision)
11080            elif whichFocus is not None:
11081                maybeFromID = base.resolvePosition(now.state, whichFocus)
11082                if maybeFromID is None:
11083                    raise MissingDecisionError(
11084                        f"Focal point {repr(whichFocus)} was specified"
11085                        f" in takeAction but that focal point doesn't"
11086                        f" have a position."
11087                    )
11088                else:
11089                    fromID = maybeFromID
11090            else:
11091                raise AmbiguousTransitionError(
11092                    f"Taking action {repr(action)} is ambiguous because"
11093                    f" the source decision has not been specified via"
11094                    f" either fromDecision or whichFocus, and we"
11095                    f" couldn't find an existing action with that name."
11096                )
11097
11098            destID = fromID
11099
11100            # Since the action doesn't exist, add it:
11101            graph.addAction(fromID, actionName, requires, consequence)
11102
11103        # Update the transition requirement/consequence if requested
11104        # (before the action is taken)
11105        if requires is not None:
11106            graph.setTransitionRequirement(fromID, actionName, requires)
11107        if consequence is not None:
11108            graph.setConsequence(fromID, actionName, consequence)
11109
11110        # A move-from-decision action
11111        actionTaken: base.ExplorationAction = (
11112            'take',
11113            using,
11114            fromID,
11115            (actionName, outcomes)
11116        )
11117        if whichFocus is not None:
11118            # A move-from-specific-focal-point action
11119            actionTaken = ('take', whichFocus, (actionName, outcomes))
11120
11121        _, finalDests = self.advanceSituation(
11122            actionTaken,
11123            decisionType,
11124            challengePolicy
11125        )
11126
11127        return self.mostApplicableDestination(
11128            graph,
11129            fromID,
11130            destID,
11131            finalDests
11132        )

Adds a new graph to the exploration based on taking the given action, which must be a self-transition in the graph. If the action does not already exist in the graph, it will be created. Either way if requirements and/or a consequence are supplied, the requirements and consequence of the action will be updated to match them, and those are the requirements/consequence that will count.

Returns the decision ID for the decision reached, which normally is the same action you were just at, but which might be altered by goto, bounce, and/or follow effects.

Issues a TransitionBlockedWarning if the current game state doesn't satisfy the requirements for the action.

The fromDecision, whichFocus, and inCommon arguments are used for deduceTransitionDetailsAtStep, while decisionType and challengePolicy are used for advanceSituation.

When an action is being created, fromDecision (or whichFocus) must be specified, since the source decision won't be deducible from the transition name. Note that if a transition with the given name exists from any active decision, it will be used instead of creating a new action (possibly resulting in an error if it's not a self-loop transition). Also, you may get an AmbiguousTransitionError if several transitions with that name exist; in that case use fromDecision and/or whichFocus to disambiguate.

def retrace( self, transition: Union[str, Tuple[str, List[bool]]], fromDecision: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None, whichFocus: Optional[Tuple[Literal['common', 'active'], str, str]] = None, inCommon: Union[bool, Literal['auto']] = 'auto', decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active', challengePolicy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'specified') -> int:
11134    def retrace(
11135        self,
11136        transition: base.AnyTransition,
11137        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
11138        whichFocus: Optional[base.FocalPointSpecifier] = None,
11139        inCommon: Union[bool, Literal["auto"]] = "auto",
11140        decisionType: base.DecisionType = "active",
11141        challengePolicy: base.ChallengePolicy = "specified"
11142    ) -> base.DecisionID:
11143        """
11144        Adds a new graph to the exploration based on taking the given
11145        transition, which must already exist and which must not lead to
11146        an unknown region. Returns the ID of the destination decision,
11147        accounting for goto, bounce, and/or follow effects.
11148
11149        Issues a `TransitionBlockedWarning` if the current game state
11150        doesn't satisfy the requirements for the transition.
11151
11152        The `fromDecision`, `whichFocus`, and `inCommon` arguments are
11153        used for `deduceTransitionDetailsAtStep`, while `decisionType`
11154        and `challengePolicy` are used for `advanceSituation`.
11155        """
11156        now = self.getSituation()
11157
11158        transitionName, outcomes = base.nameAndOutcomes(transition)
11159
11160        (
11161            using,
11162            fromID,
11163            destID,
11164            whichFocus
11165        ) = self.deduceTransitionDetailsAtStep(
11166            -1,
11167            transitionName,
11168            fromDecision,
11169            whichFocus,
11170            inCommon
11171        )
11172
11173        visited = self.hasBeenVisited(destID)
11174        confirmed = now.graph.isConfirmed(destID)
11175        if not confirmed:
11176            raise ExplorationStatusError(
11177                f"Cannot retrace transition {transition!r} from"
11178                f" decision {now.graph.identityOf(fromID)} because it"
11179                f" leads to an unconfirmed decision.\nUse"
11180                f" `DiscreteExploration.explore` and provide"
11181                f" destination decision details instead."
11182            )
11183        if not visited:
11184            raise ExplorationStatusError(
11185                f"Cannot retrace transition {transition!r} from"
11186                f" decision {now.graph.identityOf(fromID)} because it"
11187                f" leads to an unvisited decision.\nUse"
11188                f" `DiscreteExploration.explore` and provide"
11189                f" destination decision details instead."
11190            )
11191
11192        # A move-from-decision action
11193        actionTaken: base.ExplorationAction = (
11194            'take',
11195            using,
11196            fromID,
11197            (transitionName, outcomes)
11198        )
11199        if whichFocus is not None:
11200            # A move-from-specific-focal-point action
11201            actionTaken = ('take', whichFocus, (transitionName, outcomes))
11202
11203        _, finalDests = self.advanceSituation(
11204            actionTaken,
11205            decisionType,
11206            challengePolicy
11207        )
11208
11209        return self.mostApplicableDestination(
11210            now.graph,
11211            fromID,
11212            destID,
11213            finalDests
11214        )

Adds a new graph to the exploration based on taking the given transition, which must already exist and which must not lead to an unknown region. Returns the ID of the destination decision, accounting for goto, bounce, and/or follow effects.

Issues a TransitionBlockedWarning if the current game state doesn't satisfy the requirements for the transition.

The fromDecision, whichFocus, and inCommon arguments are used for deduceTransitionDetailsAtStep, while decisionType and challengePolicy are used for advanceSituation.

def warp( self, destination: Union[int, exploration.base.DecisionSpecifier, str], consequence: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None, domain: Optional[str] = None, zone: Optional[str] = '', whichFocus: Optional[Tuple[Literal['common', 'active'], str, str]] = None, inCommon: bool = False, decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active', challengePolicy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'specified', allowNew: bool = False) -> int:
11216    def warp(
11217        self,
11218        destination: base.AnyDecisionSpecifier,
11219        consequence: Optional[base.Consequence] = None,
11220        domain: Optional[base.Domain] = None,
11221        zone: Optional[base.Zone] = base.DefaultZone,
11222        whichFocus: Optional[base.FocalPointSpecifier] = None,
11223        inCommon: Union[bool] = False,
11224        decisionType: base.DecisionType = "active",
11225        challengePolicy: base.ChallengePolicy = "specified",
11226        allowNew: bool = False
11227    ) -> base.DecisionID:
11228        """
11229        Adds a new graph to the exploration that's a copy of the current
11230        graph, with the position updated to be at the destination without
11231        actually creating a transition from the old position to the new
11232        one. Returns the ID of the decision warped to (accounting for
11233        any goto or follow effects triggered).
11234
11235        Any provided consequences are applied, but are not associated
11236        with any transition (so any delays and charges are ignored, and
11237        'bounce' effects don't actually cancel the warp). 'goto' or
11238        'follow' effects might change the warp destination; 'follow'
11239        effects take the original destination as their starting point.
11240        Any mechanisms mentioned in extra consequences will be found
11241        based on the destination. Outcomes in supplied challenges should
11242        be pre-specified, or else they will be resolved with the
11243        `challengePolicy`.
11244
11245        `whichFocus` may be specified when the destination domain's
11246        focalization is 'plural' but for 'singular' or 'spreading'
11247        destination domains it is not allowed. `inCommon` determines
11248        whether the common or the active focal context is updated
11249        (default is to update the active context). The `decisionType`
11250        and `challengePolicy` are used for `advanceSituation`.
11251
11252        - If the destination did not already exist, it will be created if
11253            `allowNew` is `True` (default is `False`). If `allowNew` is
11254            `False` and the destination did not already exist, a
11255            `MissingDecisionError` will be raised. Initially, any
11256            newly-created decision will be disconnected from all other
11257            decisions. In this case, the `domain` value can be used to
11258            put it in a non-default domain.
11259        - The position is set to the specified destination, and if a
11260            `consequence` is specified it is applied. Note that
11261            'deactivate' effects are NOT allowed, and 'edit' effects
11262            must establish their own transition target because there is
11263            no transition that the effects are being applied to.
11264        - If the destination had been unexplored, its exploration status
11265            will be set to 'exploring'.
11266        - If a `zone` is specified, the destination will be added to that
11267            zone (even if the destination already existed) and that zone
11268            will be created (as a level-0 zone) if need be. If `zone` is
11269            set to `None`, then no zone will be applied. If `zone` is
11270            left as the default (`base.DefaultZone`) and the
11271            focalization of the destination domain is 'singular' or
11272            'plural' and the destination is newly created and there is
11273            an origin and the origin is in the same domain as the
11274            destination, then the destination will be added to all zones
11275            that the origin was a part of if the destination is newly
11276            created, but otherwise the destination will not be added to
11277            any zones. If the specified zone has to be created and
11278            there's an origin decision, it will be added as a sub-zone
11279            to all parents of zones directly containing the origin, as
11280            long as the origin is in the same domain as the destination.
11281        """
11282        now = self.getSituation()
11283        graph = now.graph
11284
11285        fromID: Optional[base.DecisionID]
11286
11287        new = False
11288        try:
11289            destID = graph.resolveDecision(destination)
11290        except MissingDecisionError:
11291            if not allowNew:
11292                raise
11293
11294            if isinstance(destination, tuple):
11295                # just the name; ignore zone/domain
11296                destination = destination[-1]
11297
11298            if not isinstance(destination, base.DecisionName):
11299                raise TypeError(
11300                    f"Warp destination {repr(destination)} does not"
11301                    f" exist, and cannot be created as it is not a"
11302                    f" decision name."
11303                )
11304            destID = graph.addDecision(destination, domain)
11305            graph.tagDecision(destID, 'unconfirmed')
11306            self.setExplorationStatus(destID, 'unknown')
11307            new = True
11308
11309        using: base.ContextSpecifier
11310        if inCommon:
11311            targetContext = self.getCommonContext()
11312            using = "common"
11313        else:
11314            targetContext = self.getActiveContext()
11315            using = "active"
11316
11317        destDomain = graph.domainFor(destID)
11318        targetFocalization = base.getDomainFocalization(
11319            targetContext,
11320            destDomain
11321        )
11322        if targetFocalization == 'singular':
11323            targetActive = targetContext['activeDecisions']
11324            if destDomain in targetActive:
11325                fromID = cast(
11326                    base.DecisionID,
11327                    targetContext['activeDecisions'][destDomain]
11328                )
11329            else:
11330                fromID = None
11331        elif targetFocalization == 'plural':
11332            if whichFocus is None:
11333                raise AmbiguousTransitionError(
11334                    f"Warping to {repr(destination)} is ambiguous"
11335                    f" becuase domain {repr(destDomain)} has plural"
11336                    f" focalization, and no whichFocus value was"
11337                    f" specified."
11338                )
11339
11340            fromID = base.resolvePosition(
11341                self.getSituation().state,
11342                whichFocus
11343            )
11344        else:
11345            fromID = None
11346
11347        # Handle zones
11348        if zone == base.DefaultZone:
11349            if (
11350                new
11351            and fromID is not None
11352            and graph.domainFor(fromID) == destDomain
11353            ):
11354                for prevZone in graph.zoneParents(fromID):
11355                    graph.addDecisionToZone(destination, prevZone)
11356            # Otherwise don't update zones
11357        elif zone is not None:
11358            # Newness is ignored when a zone is specified
11359            zone = cast(base.Zone, zone)
11360            # Create the zone at level 0 if it didn't already exist
11361            if graph.getZoneInfo(zone) is None:
11362                graph.createZone(zone, 0)
11363                # Add the newly created zone to each 2nd-level parent of
11364                # the previous decision if there is one and it's in the
11365                # same domain
11366                if (
11367                    fromID is not None
11368                and graph.domainFor(fromID) == destDomain
11369                ):
11370                    for prevZone in graph.zoneParents(fromID):
11371                        for prevUpper in graph.zoneParents(prevZone):
11372                            graph.addZoneToZone(zone, prevUpper)
11373            # Finally add the destination to the (maybe new) zone
11374            graph.addDecisionToZone(destID, zone)
11375        # else don't touch zones
11376
11377        # Encode the action taken
11378        actionTaken: base.ExplorationAction
11379        if whichFocus is None:
11380            actionTaken = (
11381                'warp',
11382                using,
11383                destID
11384            )
11385        else:
11386            actionTaken = (
11387                'warp',
11388                whichFocus,
11389                destID
11390            )
11391
11392        # Advance the situation
11393        _, finalDests = self.advanceSituation(
11394            actionTaken,
11395            decisionType,
11396            challengePolicy
11397        )
11398        now = self.getSituation()  # updating just in case
11399
11400        baseID = fromID
11401        if baseID is None:
11402            baseID = destID
11403
11404        finalDest = self.mostApplicableDestination(
11405            now.graph,
11406            baseID,
11407            destID,
11408            finalDests
11409        )
11410
11411        # Apply additional consequences:
11412        if consequence is not None:
11413            altDest = self.applyExtraneousConsequence(
11414                consequence,
11415                where=(destID, None),
11416                # TODO: Mechanism search from both ends?
11417                moveWhich=(
11418                    whichFocus[-1]
11419                    if whichFocus is not None
11420                    else None
11421                )
11422            )
11423            if altDest is not None:
11424                finalDest = altDest
11425            now = self.getSituation()  # updating just in case
11426
11427        return finalDest

Adds a new graph to the exploration that's a copy of the current graph, with the position updated to be at the destination without actually creating a transition from the old position to the new one. Returns the ID of the decision warped to (accounting for any goto or follow effects triggered).

Any provided consequences are applied, but are not associated with any transition (so any delays and charges are ignored, and 'bounce' effects don't actually cancel the warp). 'goto' or 'follow' effects might change the warp destination; 'follow' effects take the original destination as their starting point. Any mechanisms mentioned in extra consequences will be found based on the destination. Outcomes in supplied challenges should be pre-specified, or else they will be resolved with the challengePolicy.

whichFocus may be specified when the destination domain's focalization is 'plural' but for 'singular' or 'spreading' destination domains it is not allowed. inCommon determines whether the common or the active focal context is updated (default is to update the active context). The decisionType and challengePolicy are used for advanceSituation.

  • If the destination did not already exist, it will be created if allowNew is True (default is False). If allowNew is False and the destination did not already exist, a MissingDecisionError will be raised. Initially, any newly-created decision will be disconnected from all other decisions. In this case, the domain value can be used to put it in a non-default domain.
  • The position is set to the specified destination, and if a consequence is specified it is applied. Note that 'deactivate' effects are NOT allowed, and 'edit' effects must establish their own transition target because there is no transition that the effects are being applied to.
  • If the destination had been unexplored, its exploration status will be set to 'exploring'.
  • If a zone is specified, the destination will be added to that zone (even if the destination already existed) and that zone will be created (as a level-0 zone) if need be. If zone is set to None, then no zone will be applied. If zone is left as the default (base.DefaultZone) and the focalization of the destination domain is 'singular' or 'plural' and the destination is newly created and there is an origin and the origin is in the same domain as the destination, then the destination will be added to all zones that the origin was a part of if the destination is newly created, but otherwise the destination will not be added to any zones. If the specified zone has to be created and there's an origin decision, it will be added as a sub-zone to all parents of zones directly containing the origin, as long as the origin is in the same domain as the destination.
def wait( self, consequence: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None, decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active', challengePolicy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'specified') -> Optional[int]:
11429    def wait(
11430        self,
11431        consequence: Optional[base.Consequence] = None,
11432        decisionType: base.DecisionType = "active",
11433        challengePolicy: base.ChallengePolicy = "specified"
11434    ) -> Optional[base.DecisionID]:
11435        """
11436        Adds a wait step. If a consequence is specified, it is applied,
11437        although it will not have any position/transition information
11438        available during resolution/application.
11439
11440        A decision type other than "active" and/or a challenge policy
11441        other than "specified" can be included (see `advanceSituation`).
11442
11443        The "pending" decision type may not be used, a `ValueError` will
11444        result. This allows None as the action for waiting while
11445        preserving the pending/None type/action combination for
11446        unresolved situations.
11447
11448        If a goto or follow effect in the applied consequence implies a
11449        position update, this will return the new destination ID;
11450        otherwise it will return `None`. Triggering a 'bounce' effect
11451        will be an error, because there is no position information for
11452        the effect.
11453        """
11454        if decisionType == "pending":
11455            raise ValueError(
11456                "The 'pending' decision type may not be used for"
11457                " wait actions."
11458            )
11459        self.advanceSituation(('noAction',), decisionType, challengePolicy)
11460        now = self.getSituation()
11461        if consequence is not None:
11462            if challengePolicy != "specified":
11463                base.resetChallengeOutcomes(consequence)
11464            observed = base.observeChallengeOutcomes(
11465                base.RequirementContext(
11466                    state=now.state,
11467                    graph=now.graph,
11468                    searchFrom=set()
11469                ),
11470                consequence,
11471                location=None,  # No position info
11472                policy=challengePolicy,
11473                knownOutcomes=None  # bake outcomes into the consequence
11474            )
11475            # No location information since we might have multiple
11476            # active decisions and there's no indication of which one
11477            # we're "waiting at."
11478            finalDest = self.applyExtraneousConsequence(observed)
11479            now = self.getSituation()  # updating just in case
11480
11481            return finalDest
11482        else:
11483            return None

Adds a wait step. If a consequence is specified, it is applied, although it will not have any position/transition information available during resolution/application.

A decision type other than "active" and/or a challenge policy other than "specified" can be included (see advanceSituation).

The "pending" decision type may not be used, a ValueError will result. This allows None as the action for waiting while preserving the pending/None type/action combination for unresolved situations.

If a goto or follow effect in the applied consequence implies a position update, this will return the new destination ID; otherwise it will return None. Triggering a 'bounce' effect will be an error, because there is no position information for the effect.

def revert( self, slot: str = 'slot0', aspects: Optional[Set[str]] = None, decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active') -> None:
11485    def revert(
11486        self,
11487        slot: base.SaveSlot = base.DEFAULT_SAVE_SLOT,
11488        aspects: Optional[Set[str]] = None,
11489        decisionType: base.DecisionType = "active"
11490    ) -> None:
11491        """
11492        Reverts the game state to a previously-saved game state (saved
11493        via a 'save' effect). The save slot name and set of aspects to
11494        revert are required. By default, all aspects except the graph
11495        are reverted.
11496        """
11497        if aspects is None:
11498            aspects = set()
11499
11500        action: base.ExplorationAction = ("revertTo", slot, aspects)
11501
11502        self.advanceSituation(action, decisionType)

Reverts the game state to a previously-saved game state (saved via a 'save' effect). The save slot name and set of aspects to revert are required. By default, all aspects except the graph are reverted.

def observeAll( self, where: Union[int, exploration.base.DecisionSpecifier, str], *transitions: Union[str, Tuple[str, Union[int, exploration.base.DecisionSpecifier, str]], Tuple[str, Union[int, exploration.base.DecisionSpecifier, str], str]]) -> List[int]:
11504    def observeAll(
11505        self,
11506        where: base.AnyDecisionSpecifier,
11507        *transitions: Union[
11508            base.Transition,
11509            Tuple[base.Transition, base.AnyDecisionSpecifier],
11510            Tuple[
11511                base.Transition,
11512                base.AnyDecisionSpecifier,
11513                base.Transition
11514            ]
11515        ]
11516    ) -> List[base.DecisionID]:
11517        """
11518        Observes one or more new transitions, applying changes to the
11519        current graph. The transitions can be specified in one of three
11520        ways:
11521
11522        1. A transition name. The transition will be created and will
11523            point to a new unexplored node.
11524        2. A pair containing a transition name and a destination
11525            specifier. If the destination does not exist it will be
11526            created as an unexplored node, although in that case the
11527            decision specifier may not be an ID.
11528        3. A triple containing a transition name, a destination
11529            specifier, and a reciprocal name. Works the same as the pair
11530            case but also specifies the name for the reciprocal
11531            transition.
11532
11533        The new transitions are outgoing from specified decision.
11534
11535        Yields the ID of each decision connected to, whether those are
11536        new or existing decisions.
11537        """
11538        now = self.getSituation()
11539        fromID = now.graph.resolveDecision(where)
11540        result = []
11541        for entry in transitions:
11542            if isinstance(entry, base.Transition):
11543                result.append(self.observe(fromID, entry))
11544            else:
11545                result.append(self.observe(fromID, *entry))
11546        return result

Observes one or more new transitions, applying changes to the current graph. The transitions can be specified in one of three ways:

  1. A transition name. The transition will be created and will point to a new unexplored node.
  2. A pair containing a transition name and a destination specifier. If the destination does not exist it will be created as an unexplored node, although in that case the decision specifier may not be an ID.
  3. A triple containing a transition name, a destination specifier, and a reciprocal name. Works the same as the pair case but also specifies the name for the reciprocal transition.

The new transitions are outgoing from specified decision.

Yields the ID of each decision connected to, whether those are new or existing decisions.

def observe( self, where: Union[int, exploration.base.DecisionSpecifier, str], transition: str, destination: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None, reciprocal: Optional[str] = None) -> int:
11548    def observe(
11549        self,
11550        where: base.AnyDecisionSpecifier,
11551        transition: base.Transition,
11552        destination: Optional[base.AnyDecisionSpecifier] = None,
11553        reciprocal: Optional[base.Transition] = None
11554    ) -> base.DecisionID:
11555        """
11556        Observes a single new outgoing transition from the specified
11557        decision. If specified the transition connects to a specific
11558        destination and/or has a specific reciprocal. The specified
11559        destination will be created if it doesn't exist, or where no
11560        destination is specified, a new unexplored decision will be
11561        added. The ID of the decision connected to is returned.
11562
11563        Sets the exploration status of the observed destination to
11564        "noticed" if a destination is specified and needs to be created
11565        (but not when no destination is specified).
11566
11567        For example:
11568
11569        >>> e = DiscreteExploration()
11570        >>> e.start('start')
11571        0
11572        >>> e.observe('start', 'up')
11573        1
11574        >>> g = e.getSituation().graph
11575        >>> g.destinationsFrom('start')
11576        {'up': 1}
11577        >>> e.getExplorationStatus(1)  # not given a name: assumed unknown
11578        'unknown'
11579        >>> e.observe('start', 'left', 'A')
11580        2
11581        >>> g.destinationsFrom('start')
11582        {'up': 1, 'left': 2}
11583        >>> g.nameFor(2)
11584        'A'
11585        >>> e.getExplorationStatus(2)  # given a name: noticed
11586        'noticed'
11587        >>> e.observe('start', 'up2', 1)
11588        1
11589        >>> g.destinationsFrom('start')
11590        {'up': 1, 'left': 2, 'up2': 1}
11591        >>> e.getExplorationStatus(1)  # existing decision: status unchanged
11592        'unknown'
11593        >>> e.observe('start', 'right', 'B', 'left')
11594        3
11595        >>> g.destinationsFrom('start')
11596        {'up': 1, 'left': 2, 'up2': 1, 'right': 3}
11597        >>> g.nameFor(3)
11598        'B'
11599        >>> e.getExplorationStatus(3)  # new + name -> noticed
11600        'noticed'
11601        >>> e.observe('start', 'right')  # repeat transition name
11602        Traceback (most recent call last):
11603        ...
11604        exploration.core.TransitionCollisionError...
11605        >>> e.observe('start', 'right2', 'B', 'left')  # repeat reciprocal
11606        Traceback (most recent call last):
11607        ...
11608        exploration.core.TransitionCollisionError...
11609        >>> g = e.getSituation().graph
11610        >>> g.createZone('Z', 0)
11611        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
11612 annotations=[])
11613        >>> g.addDecisionToZone('start', 'Z')
11614        >>> e.observe('start', 'down', 'C', 'up')
11615        4
11616        >>> g.destinationsFrom('start')
11617        {'up': 1, 'left': 2, 'up2': 1, 'right': 3, 'down': 4}
11618        >>> g.identityOf('C')
11619        '4 (C)'
11620        >>> g.zoneParents(4)  # not in any zones, 'cause still unexplored
11621        set()
11622        >>> e.observe(
11623        ...     'C',
11624        ...     'right',
11625        ...     base.DecisionSpecifier('main', 'Z2', 'D'),
11626        ... )  # creates zone
11627        5
11628        >>> g.destinationsFrom('C')
11629        {'up': 0, 'right': 5}
11630        >>> g.destinationsFrom('D')  # no reciprocal if not specified
11631        {}
11632        >>> g.identityOf('D')
11633        '5 (Z2::D)'
11634        >>> g.zoneParents(5)
11635        {'Z2'}
11636        """
11637        now = self.getSituation()
11638        fromID = now.graph.resolveDecision(where)
11639
11640        kwargs: Dict[
11641            str,
11642            Union[base.Transition, base.DecisionName, None]
11643        ] = {}
11644        if reciprocal is not None:
11645            kwargs['reciprocal'] = reciprocal
11646
11647        if destination is not None:
11648            try:
11649                destID = now.graph.resolveDecision(destination)
11650                now.graph.addTransition(
11651                    fromID,
11652                    transition,
11653                    destID,
11654                    reciprocal
11655                )
11656                return destID
11657            except MissingDecisionError:
11658                if isinstance(destination, base.DecisionSpecifier):
11659                    kwargs['toDomain'] = destination.domain
11660                    kwargs['placeInZone'] = destination.zone
11661                    kwargs['destinationName'] = destination.name
11662                elif isinstance(destination, base.DecisionName):
11663                    kwargs['destinationName'] = destination
11664                else:
11665                    assert isinstance(destination, base.DecisionID)
11666                    # We got to except by failing to resolve, so it's an
11667                    # invalid ID
11668                    raise
11669
11670        result = now.graph.addUnexploredEdge(
11671            fromID,
11672            transition,
11673            **kwargs  # type: ignore [arg-type]
11674        )
11675        if 'destinationName' in kwargs:
11676            self.setExplorationStatus(result, 'noticed', upgradeOnly=True)
11677        return result

Observes a single new outgoing transition from the specified decision. If specified the transition connects to a specific destination and/or has a specific reciprocal. The specified destination will be created if it doesn't exist, or where no destination is specified, a new unexplored decision will be added. The ID of the decision connected to is returned.

Sets the exploration status of the observed destination to "noticed" if a destination is specified and needs to be created (but not when no destination is specified).

For example:

>>> e = DiscreteExploration()
>>> e.start('start')
0
>>> e.observe('start', 'up')
1
>>> g = e.getSituation().graph
>>> g.destinationsFrom('start')
{'up': 1}
>>> e.getExplorationStatus(1)  # not given a name: assumed unknown
'unknown'
>>> e.observe('start', 'left', 'A')
2
>>> g.destinationsFrom('start')
{'up': 1, 'left': 2}
>>> g.nameFor(2)
'A'
>>> e.getExplorationStatus(2)  # given a name: noticed
'noticed'
>>> e.observe('start', 'up2', 1)
1
>>> g.destinationsFrom('start')
{'up': 1, 'left': 2, 'up2': 1}
>>> e.getExplorationStatus(1)  # existing decision: status unchanged
'unknown'
>>> e.observe('start', 'right', 'B', 'left')
3
>>> g.destinationsFrom('start')
{'up': 1, 'left': 2, 'up2': 1, 'right': 3}
>>> g.nameFor(3)
'B'
>>> e.getExplorationStatus(3)  # new + name -> noticed
'noticed'
>>> e.observe('start', 'right')  # repeat transition name
Traceback (most recent call last):
...
TransitionCollisionError...
>>> e.observe('start', 'right2', 'B', 'left')  # repeat reciprocal
Traceback (most recent call last):
...
TransitionCollisionError...
>>> g = e.getSituation().graph
>>> g.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('start', 'Z')
>>> e.observe('start', 'down', 'C', 'up')
4
>>> g.destinationsFrom('start')
{'up': 1, 'left': 2, 'up2': 1, 'right': 3, 'down': 4}
>>> g.identityOf('C')
'4 (C)'
>>> g.zoneParents(4)  # not in any zones, 'cause still unexplored
set()
>>> e.observe(
...     'C',
...     'right',
...     base.DecisionSpecifier('main', 'Z2', 'D'),
... )  # creates zone
5
>>> g.destinationsFrom('C')
{'up': 0, 'right': 5}
>>> g.destinationsFrom('D')  # no reciprocal if not specified
{}
>>> g.identityOf('D')
'5 (Z2::D)'
>>> g.zoneParents(5)
{'Z2'}
def observeMechanisms( self, where: Union[int, exploration.base.DecisionSpecifier, str, NoneType], *mechanisms: Union[str, Tuple[str, str]]) -> List[int]:
11679    def observeMechanisms(
11680        self,
11681        where: Optional[base.AnyDecisionSpecifier],
11682        *mechanisms: Union[
11683            base.MechanismName,
11684            Tuple[base.MechanismName, base.MechanismState]
11685        ]
11686    ) -> List[base.MechanismID]:
11687        """
11688        Adds one or more mechanisms to the exploration's current graph,
11689        located at the specified decision. Global mechanisms can be
11690        added by using `None` for the location. Mechanisms are named, or
11691        a (name, state) tuple can be used to set them into a specific
11692        state. Mechanisms not set to a state will be in the
11693        `base.DEFAULT_MECHANISM_STATE`.
11694        """
11695        now = self.getSituation()
11696        result = []
11697        for mSpec in mechanisms:
11698            setState = None
11699            if isinstance(mSpec, base.MechanismName):
11700                result.append(now.graph.addMechanism(mSpec, where))
11701            elif (
11702                isinstance(mSpec, tuple)
11703            and len(mSpec) == 2
11704            and isinstance(mSpec[0], base.MechanismName)
11705            and isinstance(mSpec[1], base.MechanismState)
11706            ):
11707                result.append(now.graph.addMechanism(mSpec[0], where))
11708                setState = mSpec[1]
11709            else:
11710                raise TypeError(
11711                    f"Invalid mechanism: {repr(mSpec)} (must be a"
11712                    f" mechanism name or a (name, state) tuple."
11713                )
11714
11715            if setState:
11716                self.setMechanismStateNow(result[-1], setState)
11717
11718        return result

Adds one or more mechanisms to the exploration's current graph, located at the specified decision. Global mechanisms can be added by using None for the location. Mechanisms are named, or a (name, state) tuple can be used to set them into a specific state. Mechanisms not set to a state will be in the base.DEFAULT_MECHANISM_STATE.

def reZone( self, zone: Optional[str], where: Union[int, exploration.base.DecisionSpecifier, str], replace: Union[str, int] = 0) -> None:
11720    def reZone(
11721        self,
11722        zone: Optional[base.Zone],
11723        where: base.AnyDecisionSpecifier,
11724        replace: Union[base.Zone, int] = 0
11725    ) -> None:
11726        """
11727        Alters the current graph without adding a new exploration step.
11728
11729        When given an integer `replace` value, calls
11730        `DecisionGraph.replaceZonesInHierarchy` targeting the
11731        specified decision, replacing ALL zones at the specified
11732        hierarchy level.
11733
11734        If given a zone to replace instead, replaces just that zone by
11735        thoroughly removing the given decision from that zone and then
11736        adding it to the new target zone directly. Thorough removal may
11737        affect membership in other zones...
11738
11739        Use `None` as the zone name to instead remove the current
11740        decision from all zones at the specified hierarchy level, or
11741        from the specified single zone (this uses thorough removal so
11742        may affect membership in lower-level zones).
11743        """
11744        graph = self.getSituation().graph
11745        dID = graph.resolveDecision(where)
11746
11747        if isinstance(replace, int):
11748            # Replace/discard all zones at level
11749            if zone is None:
11750                # Remove from ALL zones at specified level
11751                for escape in graph.zoneAncestors(dID, atLevel=replace):
11752                    graph.removeDecisionFromZone(dID, escape, True)
11753            else:
11754                graph.replaceZonesInHierarchy(dID, zone, replace)
11755        else:
11756            # Replace specific zone
11757            graph.removeDecisionFromZone(dID, replace, True)
11758            if zone is not None:
11759                graph.addDecisionToZone(dID, zone)

Alters the current graph without adding a new exploration step.

When given an integer replace value, calls DecisionGraph.replaceZonesInHierarchy targeting the specified decision, replacing ALL zones at the specified hierarchy level.

If given a zone to replace instead, replaces just that zone by thoroughly removing the given decision from that zone and then adding it to the new target zone directly. Thorough removal may affect membership in other zones...

Use None as the zone name to instead remove the current decision from all zones at the specified hierarchy level, or from the specified single zone (this uses thorough removal so may affect membership in lower-level zones).

11761    def runCommand(
11762        self,
11763        command: commands.Command,
11764        scope: Optional[commands.Scope] = None,
11765        line: int = -1
11766    ) -> commands.CommandResult:
11767        """
11768        Runs a single `Command` applying effects to the exploration, its
11769        current graph, and the provided execution context, and returning
11770        a command result, which contains the modified scope plus
11771        optional skip and label values (see `CommandResult`). This
11772        function also directly modifies the scope you give it. Variable
11773        references in the command are resolved via entries in the
11774        provided scope. If no scope is given, an empty one is created.
11775
11776        A line number may be supplied for use in error messages; if left
11777        out line -1 will be used.
11778
11779        Raises an error if the command is invalid.
11780
11781        For commands that establish a value as the 'current value', that
11782        value will be stored in the '_' variable. When this happens, the
11783        old contents of '_' are stored in '__' first, and the old
11784        contents of '__' are discarded. Note that non-automatic
11785        assignment to '_' does not move the old value to '__'.
11786        """
11787        try:
11788            if scope is None:
11789                scope = {}
11790
11791            skip: Union[int, str, None] = None
11792            label: Optional[str] = None
11793
11794            if command.command == 'val':
11795                command = cast(commands.LiteralValue, command)
11796                result = commands.resolveValue(command.value, scope)
11797                commands.pushCurrentValue(scope, result)
11798
11799            elif command.command == 'empty':
11800                command = cast(commands.EstablishCollection, command)
11801                collection = commands.resolveVarName(command.collection, scope)
11802                commands.pushCurrentValue(
11803                    scope,
11804                    {
11805                        'list': [],
11806                        'tuple': (),
11807                        'set': set(),
11808                        'dict': {},
11809                    }[collection]
11810                )
11811
11812            elif command.command == 'append':
11813                command = cast(commands.AppendValue, command)
11814                target = scope['_']
11815                addIt = commands.resolveValue(command.value, scope)
11816                if isinstance(target, list):
11817                    target.append(addIt)
11818                elif isinstance(target, tuple):
11819                    scope['_'] = target + (addIt,)
11820                elif isinstance(target, set):
11821                    target.add(addIt)
11822                elif isinstance(target, dict):
11823                    raise TypeError(
11824                        "'append' command cannot be used with a"
11825                        " dictionary. Use 'set' instead."
11826                    )
11827                else:
11828                    raise TypeError(
11829                        f"Invalid current value for 'append' command."
11830                        f" The current value must be a list, tuple, or"
11831                        f" set, but it was a '{type(target).__name__}'."
11832                    )
11833
11834            elif command.command == 'set':
11835                command = cast(commands.SetValue, command)
11836                target = scope['_']
11837                where = commands.resolveValue(command.location, scope)
11838                what = commands.resolveValue(command.value, scope)
11839                if isinstance(target, list):
11840                    if not isinstance(where, int):
11841                        raise TypeError(
11842                            f"Cannot set item in list: index {where!r}"
11843                            f" is not an integer."
11844                        )
11845                    target[where] = what
11846                elif isinstance(target, tuple):
11847                    if not isinstance(where, int):
11848                        raise TypeError(
11849                            f"Cannot set item in tuple: index {where!r}"
11850                            f" is not an integer."
11851                        )
11852                    if not (
11853                        0 <= where < len(target)
11854                    or -1 >= where >= -len(target)
11855                    ):
11856                        raise IndexError(
11857                            f"Cannot set item in tuple at index"
11858                            f" {where}: Tuple has length {len(target)}."
11859                        )
11860                    scope['_'] = target[:where] + (what,) + target[where + 1:]
11861                elif isinstance(target, set):
11862                    if what:
11863                        target.add(where)
11864                    else:
11865                        try:
11866                            target.remove(where)
11867                        except KeyError:
11868                            pass
11869                elif isinstance(target, dict):
11870                    target[where] = what
11871
11872            elif command.command == 'pop':
11873                command = cast(commands.PopValue, command)
11874                target = scope['_']
11875                if isinstance(target, list):
11876                    result = target.pop()
11877                    commands.pushCurrentValue(scope, result)
11878                elif isinstance(target, tuple):
11879                    result = target[-1]
11880                    updated = target[:-1]
11881                    scope['__'] = updated
11882                    scope['_'] = result
11883                else:
11884                    raise TypeError(
11885                        f"Cannot 'pop' from a {type(target).__name__}"
11886                        f" (current value must be a list or tuple)."
11887                    )
11888
11889            elif command.command == 'get':
11890                command = cast(commands.GetValue, command)
11891                target = scope['_']
11892                where = commands.resolveValue(command.location, scope)
11893                if isinstance(target, list):
11894                    if not isinstance(where, int):
11895                        raise TypeError(
11896                            f"Cannot get item from list: index"
11897                            f" {where!r} is not an integer."
11898                        )
11899                elif isinstance(target, tuple):
11900                    if not isinstance(where, int):
11901                        raise TypeError(
11902                            f"Cannot get item from tuple: index"
11903                            f" {where!r} is not an integer."
11904                        )
11905                elif isinstance(target, set):
11906                    result = where in target
11907                    commands.pushCurrentValue(scope, result)
11908                elif isinstance(target, dict):
11909                    result = target[where]
11910                    commands.pushCurrentValue(scope, result)
11911                else:
11912                    result = getattr(target, where)
11913                    commands.pushCurrentValue(scope, result)
11914
11915            elif command.command == 'remove':
11916                command = cast(commands.RemoveValue, command)
11917                target = scope['_']
11918                where = commands.resolveValue(command.location, scope)
11919                if isinstance(target, (list, tuple)):
11920                    # this cast is not correct but suppresses warnings
11921                    # given insufficient narrowing by MyPy
11922                    target = cast(Tuple[Any, ...], target)
11923                    if not isinstance(where, int):
11924                        raise TypeError(
11925                            f"Cannot remove item from list or tuple:"
11926                            f" index {where!r} is not an integer."
11927                        )
11928                    scope['_'] = target[:where] + target[where + 1:]
11929                elif isinstance(target, set):
11930                    target.remove(where)
11931                elif isinstance(target, dict):
11932                    del target[where]
11933                else:
11934                    raise TypeError(
11935                        f"Cannot use 'remove' on a/an"
11936                        f" {type(target).__name__}."
11937                    )
11938
11939            elif command.command == 'op':
11940                command = cast(commands.ApplyOperator, command)
11941                left = commands.resolveValue(command.left, scope)
11942                right = commands.resolveValue(command.right, scope)
11943                op = command.op
11944                if op == '+':
11945                    result = left + right
11946                elif op == '-':
11947                    result = left - right
11948                elif op == '*':
11949                    result = left * right
11950                elif op == '/':
11951                    result = left / right
11952                elif op == '//':
11953                    result = left // right
11954                elif op == '**':
11955                    result = left ** right
11956                elif op == '%':
11957                    result = left % right
11958                elif op == '^':
11959                    result = left ^ right
11960                elif op == '|':
11961                    result = left | right
11962                elif op == '&':
11963                    result = left & right
11964                elif op == 'and':
11965                    result = left and right
11966                elif op == 'or':
11967                    result = left or right
11968                elif op == '<':
11969                    result = left < right
11970                elif op == '>':
11971                    result = left > right
11972                elif op == '<=':
11973                    result = left <= right
11974                elif op == '>=':
11975                    result = left >= right
11976                elif op == '==':
11977                    result = left == right
11978                elif op == 'is':
11979                    result = left is right
11980                else:
11981                    raise RuntimeError("Invalid operator '{op}'.")
11982
11983                commands.pushCurrentValue(scope, result)
11984
11985            elif command.command == 'unary':
11986                command = cast(commands.ApplyUnary, command)
11987                value = commands.resolveValue(command.value, scope)
11988                op = command.op
11989                if op == '-':
11990                    result = -value
11991                elif op == '~':
11992                    result = ~value
11993                elif op == 'not':
11994                    result = not value
11995
11996                commands.pushCurrentValue(scope, result)
11997
11998            elif command.command == 'assign':
11999                command = cast(commands.VariableAssignment, command)
12000                varname = commands.resolveVarName(command.varname, scope)
12001                value = commands.resolveValue(command.value, scope)
12002                scope[varname] = value
12003
12004            elif command.command == 'delete':
12005                command = cast(commands.VariableDeletion, command)
12006                varname = commands.resolveVarName(command.varname, scope)
12007                del scope[varname]
12008
12009            elif command.command == 'load':
12010                command = cast(commands.LoadVariable, command)
12011                varname = commands.resolveVarName(command.varname, scope)
12012                commands.pushCurrentValue(scope, scope[varname])
12013
12014            elif command.command == 'call':
12015                command = cast(commands.FunctionCall, command)
12016                function = command.function
12017                if function.startswith('$'):
12018                    function = commands.resolveValue(function, scope)
12019
12020                toCall: Callable
12021                args: Tuple[str, ...]
12022                kwargs: Dict[str, Any]
12023
12024                if command.target == 'builtin':
12025                    toCall = commands.COMMAND_BUILTINS[function]
12026                    args = (scope['_'],)
12027                    kwargs = {}
12028                    if toCall == round:
12029                        if 'ndigits' in scope:
12030                            kwargs['ndigits'] = scope['ndigits']
12031                    elif toCall == range and args[0] is None:
12032                        start = scope.get('start', 0)
12033                        stop = scope['stop']
12034                        step = scope.get('step', 1)
12035                        args = (start, stop, step)
12036
12037                else:
12038                    if command.target == 'stored':
12039                        toCall = function
12040                    elif command.target == 'graph':
12041                        toCall = getattr(self.getSituation().graph, function)
12042                    elif command.target == 'exploration':
12043                        toCall = getattr(self, function)
12044                    else:
12045                        raise TypeError(
12046                            f"Invalid call target '{command.target}'"
12047                            f" (must be one of 'builtin', 'stored',"
12048                            f" 'graph', or 'exploration'."
12049                        )
12050
12051                    # Fill in arguments via kwargs defined in scope
12052                    args = ()
12053                    kwargs = {}
12054                    signature = inspect.signature(toCall)
12055                    # TODO: Maybe try some type-checking here?
12056                    for argName, param in signature.parameters.items():
12057                        if param.kind == inspect.Parameter.VAR_POSITIONAL:
12058                            if argName in scope:
12059                                args = args + tuple(scope[argName])
12060                            # Else leave args as-is
12061                        elif param.kind == inspect.Parameter.KEYWORD_ONLY:
12062                            # These must have a default
12063                            if argName in scope:
12064                                kwargs[argName] = scope[argName]
12065                        elif param.kind == inspect.Parameter.VAR_KEYWORD:
12066                            # treat as a dictionary
12067                            if argName in scope:
12068                                argsToUse = scope[argName]
12069                                if not isinstance(argsToUse, dict):
12070                                    raise TypeError(
12071                                        f"Variable '{argName}' must"
12072                                        f" hold a dictionary when"
12073                                        f" calling function"
12074                                        f" '{toCall.__name__} which"
12075                                        f" uses that argument as a"
12076                                        f" keyword catchall."
12077                                    )
12078                                kwargs.update(scope[argName])
12079                        else:  # a normal parameter
12080                            if argName in scope:
12081                                args = args + (scope[argName],)
12082                            elif param.default == inspect.Parameter.empty:
12083                                raise TypeError(
12084                                    f"No variable named '{argName}' has"
12085                                    f" been defined to supply the"
12086                                    f" required parameter with that"
12087                                    f" name for function"
12088                                    f" '{toCall.__name__}'."
12089                                )
12090
12091                result = toCall(*args, **kwargs)
12092                commands.pushCurrentValue(scope, result)
12093
12094            elif command.command == 'skip':
12095                command = cast(commands.SkipCommands, command)
12096                doIt = commands.resolveValue(command.condition, scope)
12097                if doIt:
12098                    skip = commands.resolveValue(command.amount, scope)
12099                    if not isinstance(skip, (int, str)):
12100                        raise TypeError(
12101                            f"Skip amount must be an integer or a label"
12102                            f" name (got {skip!r})."
12103                        )
12104
12105            elif command.command == 'label':
12106                command = cast(commands.Label, command)
12107                label = commands.resolveValue(command.name, scope)
12108                if not isinstance(label, str):
12109                    raise TypeError(
12110                        f"Label name must be a string (got {label!r})."
12111                    )
12112
12113            else:
12114                raise ValueError(
12115                    f"Invalid command type: {command.command!r}"
12116                )
12117        except ValueError as e:
12118            raise commands.CommandValueError(command, line, e)
12119        except TypeError as e:
12120            raise commands.CommandTypeError(command, line, e)
12121        except IndexError as e:
12122            raise commands.CommandIndexError(command, line, e)
12123        except KeyError as e:
12124            raise commands.CommandKeyError(command, line, e)
12125        except Exception as e:
12126            raise commands.CommandOtherError(command, line, e)
12127
12128        return (scope, skip, label)

Runs a single Command applying effects to the exploration, its current graph, and the provided execution context, and returning a command result, which contains the modified scope plus optional skip and label values (see CommandResult). This function also directly modifies the scope you give it. Variable references in the command are resolved via entries in the provided scope. If no scope is given, an empty one is created.

A line number may be supplied for use in error messages; if left out line -1 will be used.

Raises an error if the command is invalid.

For commands that establish a value as the 'current value', that value will be stored in the '_' variable. When this happens, the old contents of '_' are stored in '__' first, and the old contents of '__' are discarded. Note that non-automatic assignment to '_' does not move the old value to '__'.

12130    def runCommandBlock(
12131        self,
12132        block: List[commands.Command],
12133        scope: Optional[commands.Scope] = None
12134    ) -> commands.Scope:
12135        """
12136        Runs a list of commands, using the given scope (or creating a new
12137        empty scope if none was provided). Returns the scope after
12138        running all of the commands, which may also edit the exploration
12139        and/or the current graph of course.
12140
12141        Note that if a skip command would skip past the end of the
12142        block, execution will end. If a skip command would skip before
12143        the beginning of the block, execution will start from the first
12144        command.
12145
12146        Example:
12147
12148        >>> e = DiscreteExploration()
12149        >>> scope = e.runCommandBlock([
12150        ...    commands.command('assign', 'decision', "'START'"),
12151        ...    commands.command('call', 'exploration', 'start'),
12152        ...    commands.command('assign', 'where', '$decision'),
12153        ...    commands.command('assign', 'transition', "'left'"),
12154        ...    commands.command('call', 'exploration', 'observe'),
12155        ...    commands.command('assign', 'transition', "'right'"),
12156        ...    commands.command('call', 'exploration', 'observe'),
12157        ...    commands.command('call', 'graph', 'destinationsFrom'),
12158        ...    commands.command('call', 'builtin', 'print'),
12159        ...    commands.command('assign', 'transition', "'right'"),
12160        ...    commands.command('assign', 'destination', "'EastRoom'"),
12161        ...    commands.command('call', 'exploration', 'explore'),
12162        ... ])
12163        {'left': 1, 'right': 2}
12164        >>> scope['decision']
12165        'START'
12166        >>> scope['where']
12167        'START'
12168        >>> scope['_']  # result of 'explore' call is dest ID
12169        2
12170        >>> scope['transition']
12171        'right'
12172        >>> scope['destination']
12173        'EastRoom'
12174        >>> g = e.getSituation().graph
12175        >>> len(e)
12176        3
12177        >>> len(g)
12178        3
12179        >>> g.namesListing(g)
12180        '  0 (START)\\n  1 (_u.0)\\n  2 (EastRoom)\\n'
12181        """
12182        if scope is None:
12183            scope = {}
12184
12185        labelPositions: Dict[str, List[int]] = {}
12186
12187        # Keep going until we've exhausted the commands list
12188        index = 0
12189        while index < len(block):
12190
12191            # Execute the next command
12192            scope, skip, label = self.runCommand(
12193                block[index],
12194                scope,
12195                index + 1
12196            )
12197
12198            # Increment our index, or apply a skip
12199            if skip is None:
12200                index = index + 1
12201
12202            elif isinstance(skip, int):  # Integer skip value
12203                if skip < 0:
12204                    index += skip
12205                    if index < 0:  # can't skip before the start
12206                        index = 0
12207                else:
12208                    index += skip + 1  # may end loop if we skip too far
12209
12210            else:  # must be a label name
12211                if skip in labelPositions:  # an established label
12212                    # We jump to the last previous index, or if there
12213                    # are none, to the first future index.
12214                    prevIndices = [
12215                        x
12216                        for x in labelPositions[skip]
12217                        if x < index
12218                    ]
12219                    futureIndices = [
12220                        x
12221                        for x in labelPositions[skip]
12222                        if x >= index
12223                    ]
12224                    if len(prevIndices) > 0:
12225                        index = max(prevIndices)
12226                    else:
12227                        index = min(futureIndices)
12228                else:  # must be a forward-reference
12229                    for future in range(index + 1, len(block)):
12230                        inspect = block[future]
12231                        if inspect.command == 'label':
12232                            inspect = cast(commands.Label, inspect)
12233                            if inspect.name == skip:
12234                                index = future
12235                                break
12236                    else:
12237                        raise KeyError(
12238                            f"Skip command indicated a jump to label"
12239                            f" {skip!r} but that label had not already"
12240                            f" been defined and there is no future"
12241                            f" label with that name either (future"
12242                            f" labels based on variables cannot be"
12243                            f" skipped to from above as their names"
12244                            f" are not known yet)."
12245                        )
12246
12247            # If there's a label, record it
12248            if label is not None:
12249                labelPositions.setdefault(label, []).append(index)
12250
12251            # And now the while loop continues, or ends if we're at the
12252            # end of the commands list.
12253
12254        # Return the scope object.
12255        return scope

Runs a list of commands, using the given scope (or creating a new empty scope if none was provided). Returns the scope after running all of the commands, which may also edit the exploration and/or the current graph of course.

Note that if a skip command would skip past the end of the block, execution will end. If a skip command would skip before the beginning of the block, execution will start from the first command.

Example:

>>> e = DiscreteExploration()
>>> scope = e.runCommandBlock([
...    commands.command('assign', 'decision', "'START'"),
...    commands.command('call', 'exploration', 'start'),
...    commands.command('assign', 'where', '$decision'),
...    commands.command('assign', 'transition', "'left'"),
...    commands.command('call', 'exploration', 'observe'),
...    commands.command('assign', 'transition', "'right'"),
...    commands.command('call', 'exploration', 'observe'),
...    commands.command('call', 'graph', 'destinationsFrom'),
...    commands.command('call', 'builtin', 'print'),
...    commands.command('assign', 'transition', "'right'"),
...    commands.command('assign', 'destination', "'EastRoom'"),
...    commands.command('call', 'exploration', 'explore'),
... ])
{'left': 1, 'right': 2}
>>> scope['decision']
'START'
>>> scope['where']
'START'
>>> scope['_']  # result of 'explore' call is dest ID
2
>>> scope['transition']
'right'
>>> scope['destination']
'EastRoom'
>>> g = e.getSituation().graph
>>> len(e)
3
>>> len(g)
3
>>> g.namesListing(g)
'  0 (START)\n  1 (_u.0)\n  2 (EastRoom)\n'
@staticmethod
def example() -> DiscreteExploration:
12257    @staticmethod
12258    def example() -> 'DiscreteExploration':
12259        """
12260        Returns a little example exploration. Has a few decisions
12261        including one that's unexplored, and uses a few steps to explore
12262        them.
12263
12264        >>> e = DiscreteExploration.example()
12265        >>> len(e)
12266        7
12267        >>> def pg(n):
12268        ...     print(e[n].graph.namesListing(e[n].graph))
12269        >>> pg(0)
12270          0 (House)
12271        <BLANKLINE>
12272        >>> pg(1)
12273          0 (House)
12274          1 (_u.0)
12275          2 (_u.1)
12276          3 (_u.2)
12277        <BLANKLINE>
12278        >>> pg(2)
12279          0 (House)
12280          1 (_u.0)
12281          2 (_u.1)
12282          3 (Yard)
12283          4 (_u.3)
12284          5 (_u.4)
12285        <BLANKLINE>
12286        >>> pg(3)
12287          0 (House)
12288          1 (_u.0)
12289          2 (_u.1)
12290          3 (Yard)
12291          4 (_u.3)
12292          5 (_u.4)
12293        <BLANKLINE>
12294        >>> pg(4)
12295          0 (House)
12296          1 (_u.0)
12297          2 (Cellar)
12298          3 (Yard)
12299          5 (_u.4)
12300        <BLANKLINE>
12301        >>> pg(5)
12302          0 (House)
12303          1 (_u.0)
12304          2 (Cellar)
12305          3 (Yard)
12306          5 (_u.4)
12307        <BLANKLINE>
12308        >>> pg(6)
12309          0 (House)
12310          1 (_u.0)
12311          2 (Cellar)
12312          3 (Yard)
12313          5 (Lane)
12314        <BLANKLINE>
12315        """
12316        result = DiscreteExploration()
12317        result.start("House")
12318        result.observeAll("House", "ladder", "stairsDown", "frontDoor")
12319        result.explore("frontDoor", "Yard", "frontDoor")
12320        result.observe("Yard", "cellarDoors")
12321        result.observe("Yard", "frontGate")
12322        result.retrace("frontDoor")
12323        result.explore("stairsDown", "Cellar", "stairsUp")
12324        result.observe("Cellar", "stairsOut")
12325        result.returnTo("stairsOut", "Yard", "cellarDoors")
12326        result.explore("frontGate", "Lane", "redGate")
12327        return result

Returns a little example exploration. Has a few decisions including one that's unexplored, and uses a few steps to explore them.

>>> e = DiscreteExploration.example()
>>> len(e)
7
>>> def pg(n):
...     print(e[n].graph.namesListing(e[n].graph))
>>> pg(0)
  0 (House)
<BLANKLINE>
>>> pg(1)
  0 (House)
  1 (_u.0)
  2 (_u.1)
  3 (_u.2)
<BLANKLINE>
>>> pg(2)
  0 (House)
  1 (_u.0)
  2 (_u.1)
  3 (Yard)
  4 (_u.3)
  5 (_u.4)
<BLANKLINE>
>>> pg(3)
  0 (House)
  1 (_u.0)
  2 (_u.1)
  3 (Yard)
  4 (_u.3)
  5 (_u.4)
<BLANKLINE>
>>> pg(4)
  0 (House)
  1 (_u.0)
  2 (Cellar)
  3 (Yard)
  5 (_u.4)
<BLANKLINE>
>>> pg(5)
  0 (House)
  1 (_u.0)
  2 (Cellar)
  3 (Yard)
  5 (_u.4)
<BLANKLINE>
>>> pg(6)
  0 (House)
  1 (_u.0)
  2 (Cellar)
  3 (Yard)
  5 (Lane)
<BLANKLINE>