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 ofDecisionGraphs 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
Domain value for endings.
Domain value for triggers.
A type variable for lookup results from the generic
DecisionGraph.localLookup function.
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.
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.
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 aRequirement, although it might beReqNothingif nothing special is required.'consequence': TheConsequenceof the transition.'tags': Any tags applied to the transition (as a dictionary).'annotations': A list of annotations applied to the transition.
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:
- Tags and annotations are combined. Annotations from the second property set are ordered after those from the first.
- If one of the transitions has a
ReqNothinginstance as its requirement, we use the other requirement. If both have complex requirements, we create a newReqAllwhich combines them as the requirement. - 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?).
- 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.
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
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
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
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
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
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
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
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
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
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
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
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
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
Whether or not to issue warnings when two decision names are the same.
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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.
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'}
Number of unknown decisions that have been created (not number of current unknown decisions, which is likely lower)
See base.Equivalences. Determines what capabilities and/or
mechanism states can count as active based on alternate
requirements.
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.
Mapping from MechanismIDs to (DecisionID, MechanismName)
pairs. For global mechanisms, the DecisionID is None.
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.
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']}
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()
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...
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.
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...
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).
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.
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'
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'
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.
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.
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.
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.
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.
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'
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.
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()
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.
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.
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.
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.
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.
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)
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.
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.
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.
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.
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...).
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.
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.
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 aDecisionGraph). Various methods require this tag and many also add or remove it.
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.
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.
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.
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.
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
advanceSituationis 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.
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.
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.
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.
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.
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.
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).
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.
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.
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'}
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...
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=[])
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...
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=[])
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()
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']
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
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}
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...
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}
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...
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']
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']
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'
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
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']
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.
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
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
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.
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.
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}
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...
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.
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.
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.
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.
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.
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.
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
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.
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
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.
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.
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
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'
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
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
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
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']
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...
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'
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}
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.
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
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.
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.
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
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.
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
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}
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.
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.
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.
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.
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.
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.
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).
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.
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.
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
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.
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.
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()
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!!!
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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]
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
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
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
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
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
startCapabilitiesreplaces any existing capabilities for the current focal context, although you can leave it as the defaultNoneto avoid that and retain any capabilities that have been set up already. - The given
setMechanismStatesandsetCustomStatedictionaries override all previous mechanism states & custom states in the new situation. Leave these as the defaultNoneto maintain those states. - If created, the decision will be placed in the DEFAULT_DOMAIN
domain unless it's specified as a
base.DecisionSpecifierwith a domain part, in which case that domain is used. - If specified as a
base.DecisionSpecifierwith 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 specifieddecisionType) 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.
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).
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).
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'.
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:
fromDecisionmay be used to specify the source decision.whichFocusmay 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(aContextSpecifier) 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).
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.
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!
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 usingwhichFocusor 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
reciprocalname 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 asNone(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
zoneis 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). Ifzoneis set toNonethen it will not be added to any new zones. Ifzoneis left as the default (thebase.DefaultZonevalue) 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
ExplorationStatusErrorwill be raised if the specified transition leads to a decision whoseExplorationStatusis 'exploring' or higher (i.e.,hasBeenVisited). (UsereturnToinstead to adjust things when a transition to an unknown destination turns out to lead to an already-known destination.) - A
TransitionBlockedWarningwill 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
decisionTypevalue can be specified to override that. - By default, the 'mostLikely'
ChallengePolicywill be used to resolve challenges in the consequence of the transition taken, but an alternate policy can be supplied using thechallengePolicyargument.
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.
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.
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.
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.
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
allowNewisTrue(default isFalse). IfallowNewisFalseand the destination did not already exist, aMissingDecisionErrorwill be raised. Initially, any newly-created decision will be disconnected from all other decisions. In this case, thedomainvalue can be used to put it in a non-default domain. - The position is set to the specified destination, and if a
consequenceis 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
zoneis 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. Ifzoneis set toNone, then no zone will be applied. Ifzoneis 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.
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.
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.
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:
- A transition name. The transition will be created and will point to a new unexplored node.
- 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.
- 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.
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'}
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.
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'
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>