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 ofDecisionGraph
s 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 MissingMechanismError(KeyError): 230 """ 231 An error raised when attempting to use a mechanism that does not 232 exist. 233 """ 234 pass 235 236 237class MissingZoneError(KeyError): 238 """ 239 An error raised when attempting to use a zone that does not exist. 240 """ 241 pass 242 243 244class InvalidLevelError(ValueError): 245 """ 246 An error raised when an operation fails because of an invalid zone 247 level. 248 """ 249 pass 250 251 252class InvalidDestinationError(ValueError): 253 """ 254 An error raised when attempting to perform an operation with a 255 transition but that transition does not lead to a destination that's 256 compatible with the operation. 257 """ 258 pass 259 260 261class ExplorationStatusError(ValueError): 262 """ 263 An error raised when attempting to perform an operation that 264 requires a previously-visited destination with a decision that 265 represents a not-yet-visited decision, or vice versa. For 266 `Situation`s, Exploration states 'unknown', 'hypothesized', and 267 'noticed' count as "not-yet-visited" while 'exploring' and 'explored' 268 count as "visited" (see `base.hasBeenVisited`) Meanwhile, in a 269 `DecisionGraph` where exploration statuses are not present, the 270 presence or absence of the 'unconfirmed' tag is used to determine 271 whether something has been confirmed or not. 272 """ 273 pass 274 275 276WARN_OF_NAME_COLLISIONS = False 277""" 278Whether or not to issue warnings when two decision names are the same. 279""" 280 281 282class DecisionCollisionWarning(Warning): 283 """ 284 A warning raised when attempting to create a new decision using the 285 name of a decision that already exists. 286 """ 287 pass 288 289 290class TransitionCollisionError(ValueError): 291 """ 292 An error raised when attempting to re-use a transition name for a 293 new transition, or otherwise when a transition name conflicts with 294 an already-established transition. 295 """ 296 pass 297 298 299class MechanismCollisionError(ValueError): 300 """ 301 An error raised when attempting to re-use a mechanism name at the 302 same decision where a mechanism with that name already exists. 303 """ 304 pass 305 306 307class DomainCollisionError(KeyError): 308 """ 309 An error raised when attempting to create a domain with the same 310 name as an existing domain. 311 """ 312 pass 313 314 315class MissingFocalContextError(KeyError): 316 """ 317 An error raised when attempting to pick out a focal context with a 318 name that doesn't exist. 319 """ 320 pass 321 322 323class FocalContextCollisionError(KeyError): 324 """ 325 An error raised when attempting to create a focal context with the 326 same name as an existing focal context. 327 """ 328 pass 329 330 331class InvalidActionError(TypeError): 332 """ 333 An error raised when attempting to take an exploration action which 334 is not correctly formed. 335 """ 336 pass 337 338 339class ImpossibleActionError(ValueError): 340 """ 341 An error raised when attempting to take an exploration action which 342 is correctly formed but which specifies an action that doesn't match 343 up with the graph state. 344 """ 345 pass 346 347 348class DoubleActionError(ValueError): 349 """ 350 An error raised when attempting to set up an `ExplorationAction` 351 when the current situation already has an action specified. 352 """ 353 pass 354 355 356class InactiveDomainWarning(Warning): 357 """ 358 A warning used when an inactive domain is referenced but the 359 operation in progress can still succeed (for example when 360 deactivating an already-inactive domain). 361 """ 362 363 364class ZoneCollisionError(ValueError): 365 """ 366 An error raised when attempting to re-use a zone name for a new zone, 367 or otherwise when a zone name conflicts with an already-established 368 zone. 369 """ 370 pass 371 372 373#---------------------# 374# DecisionGraph class # 375#---------------------# 376 377class DecisionGraph( 378 graphs.UniqueExitsGraph[base.DecisionID, base.Transition] 379): 380 """ 381 Represents a view of the world as a topological graph at a moment in 382 time. It derives from `networkx.MultiDiGraph`. 383 384 Each node (a `Decision`) represents a place in the world where there 385 are multiple opportunities for travel/action, or a dead end where 386 you must turn around and go back; typically this is a single room in 387 a game, but sometimes one room has multiple decision points. Edges 388 (`Transition`s) represent choices that can be made to travel to 389 other decision points (e.g., taking the left door), or when they are 390 self-edges, they represent actions that can be taken within a 391 location that affect the world or the game state. 392 393 Each `Transition` includes a `Effects` dictionary 394 indicating the effects that it has. Other effects of the transition 395 that are not simple enough to be included in this format may be 396 represented in an `DiscreteExploration` by changing the graph in the 397 next step to reflect further effects of a transition. 398 399 In addition to normal transitions between decisions, a 400 `DecisionGraph` can represent potential transitions which lead to 401 unknown destinations. These are represented by adding decisions with 402 the `'unconfirmed'` tag (whose names where not specified begin with 403 `'_u.'`) with a separate unconfirmed decision for each transition 404 (although where it's known that two transitions lead to the same 405 unconfirmed decision, this can be represented as well). 406 407 Both nodes and edges can have `Annotation`s associated with them that 408 include extra details about the explorer's perception of the 409 situation. They can also have `Tag`s, which represent specific 410 categories a transition or decision falls into. 411 412 Nodes can also be part of one or more `Zones`, and zones can also be 413 part of other zones, allowing for a hierarchical description of the 414 underlying space. 415 416 Equivalences can be specified to mark that some combination of 417 capabilities can stand in for another capability. 418 """ 419 def __init__(self) -> None: 420 super().__init__() 421 422 self.zones: Dict[base.Zone, base.ZoneInfo] = {} 423 """ 424 Mapping from zone names to zone info 425 """ 426 427 self.unknownCount: int = 0 428 """ 429 Number of unknown decisions that have been created (not number 430 of current unknown decisions, which is likely lower) 431 """ 432 433 self.equivalences: base.Equivalences = {} 434 """ 435 See `base.Equivalences`. Determines what capabilities and/or 436 mechanism states can count as active based on alternate 437 requirements. 438 """ 439 440 self.reversionTypes: Dict[str, Set[str]] = {} 441 """ 442 This tracks shorthand reversion types. See `base.revertedState` 443 for how these are applied. Keys are custom names and values are 444 reversion type strings that `base.revertedState` could access. 445 """ 446 447 self.nextID: base.DecisionID = 0 448 """ 449 The ID to use for the next new decision we create. 450 """ 451 452 self.nextMechanismID: base.MechanismID = 0 453 """ 454 ID for the next mechanism. 455 """ 456 457 self.mechanisms: Dict[ 458 base.MechanismID, 459 Tuple[Optional[base.DecisionID], base.MechanismName] 460 ] = {} 461 """ 462 Mapping from `MechanismID`s to (`DecisionID`, `MechanismName`) 463 pairs. For global mechanisms, the `DecisionID` is None. 464 """ 465 466 self.globalMechanisms: Dict[ 467 base.MechanismName, 468 base.MechanismID 469 ] = {} 470 """ 471 Global mechanisms 472 """ 473 474 self.nameLookup: Dict[base.DecisionName, List[base.DecisionID]] = {} 475 """ 476 A cache for name -> ID lookups 477 """ 478 479 # Note: not hashable 480 481 def __eq__(self, other): 482 """ 483 Equality checker. `DecisionGraph`s can only be equal to other 484 `DecisionGraph`s, not to other kinds of things. 485 """ 486 if not isinstance(other, DecisionGraph): 487 return False 488 else: 489 # Checks nodes, edges, and all attached data 490 if not super().__eq__(other): 491 return False 492 493 # Check unknown count 494 if self.unknownCount != other.unknownCount: 495 return False 496 497 # Check zones 498 if self.zones != other.zones: 499 return False 500 501 # Check equivalences 502 if self.equivalences != other.equivalences: 503 return False 504 505 # Check reversion types 506 if self.reversionTypes != other.reversionTypes: 507 return False 508 509 # Check mechanisms 510 if self.nextMechanismID != other.nextMechanismID: 511 return False 512 513 if self.mechanisms != other.mechanisms: 514 return False 515 516 if self.globalMechanisms != other.globalMechanisms: 517 return False 518 519 # Check names: 520 if self.nameLookup != other.nameLookup: 521 return False 522 523 return True 524 525 def listDifferences( 526 self, 527 other: 'DecisionGraph' 528 ) -> Generator[str, None, None]: 529 """ 530 Generates strings describing differences between this graph and 531 another graph. This does NOT perform graph matching, so it will 532 consider graphs different even if they have identical structures 533 but use different IDs for the nodes in those structures. 534 """ 535 if not isinstance(other, DecisionGraph): 536 yield "other is not a graph" 537 else: 538 suppress = False 539 myNodes = set(self.nodes) 540 theirNodes = set(other.nodes) 541 for n in myNodes: 542 if n not in theirNodes: 543 suppress = True 544 yield ( 545 f"other graph missing node {n}" 546 ) 547 else: 548 if self.nodes[n] != other.nodes[n]: 549 suppress = True 550 yield ( 551 f"other graph has differences at node {n}:" 552 f"\n Ours: {self.nodes[n]}" 553 f"\nTheirs: {other.nodes[n]}" 554 ) 555 myDests = self.destinationsFrom(n) 556 theirDests = other.destinationsFrom(n) 557 for tr in myDests: 558 myTo = myDests[tr] 559 if tr not in theirDests: 560 suppress = True 561 yield ( 562 f"at {self.identityOf(n)}: other graph" 563 f" missing transition {tr!r}" 564 ) 565 else: 566 theirTo = theirDests[tr] 567 if myTo != theirTo: 568 suppress = True 569 yield ( 570 f"at {self.identityOf(n)}: other" 571 f" graph transition {tr!r} leads to" 572 f" {theirTo} instead of {myTo}" 573 ) 574 else: 575 myProps = self.edges[n, myTo, tr] # type:ignore [index] # noqa 576 theirProps = other.edges[n, myTo, tr] # type:ignore [index] # noqa 577 if myProps != theirProps: 578 suppress = True 579 yield ( 580 f"at {self.identityOf(n)}: other" 581 f" graph transition {tr!r} has" 582 f" different properties:" 583 f"\n Ours: {myProps}" 584 f"\nTheirs: {theirProps}" 585 ) 586 for extra in theirNodes - myNodes: 587 suppress = True 588 yield ( 589 f"other graph has extra node {extra}" 590 ) 591 592 # TODO: Fix networkx stubs! 593 if self.graph != other.graph: # type:ignore [attr-defined] 594 suppress = True 595 yield ( 596 " different graph attributes:" # type:ignore [attr-defined] # noqa 597 f"\n Ours: {self.graph}" 598 f"\nTheirs: {other.graph}" 599 ) 600 601 # Checks any other graph data we might have missed 602 if not super().__eq__(other) and not suppress: 603 for attr in dir(self): 604 if attr.startswith('__') and attr.endswith('__'): 605 continue 606 if not hasattr(other, attr): 607 yield f"other graph missing attribute: {attr!r}" 608 else: 609 myVal = getattr(self, attr) 610 theirVal = getattr(other, attr) 611 if ( 612 myVal != theirVal 613 and not ((callable(myVal) and callable(theirVal))) 614 ): 615 yield ( 616 f"other has different val for {attr!r}:" 617 f"\n Ours: {myVal}" 618 f"\nTheirs: {theirVal}" 619 ) 620 for attr in sorted(set(dir(other)) - set(dir(self))): 621 yield f"other has extra attribute: {attr!r}" 622 yield "graph data is different" 623 # TODO: More detail here! 624 625 # Check unknown count 626 if self.unknownCount != other.unknownCount: 627 yield "unknown count is different" 628 629 # Check zones 630 if self.zones != other.zones: 631 yield "zones are different" 632 633 # Check equivalences 634 if self.equivalences != other.equivalences: 635 yield "equivalences are different" 636 637 # Check reversion types 638 if self.reversionTypes != other.reversionTypes: 639 yield "reversionTypes are different" 640 641 # Check mechanisms 642 if self.nextMechanismID != other.nextMechanismID: 643 yield "nextMechanismID is different" 644 645 if self.mechanisms != other.mechanisms: 646 yield "mechanisms are different" 647 648 if self.globalMechanisms != other.globalMechanisms: 649 yield "global mechanisms are different" 650 651 # Check names: 652 if self.nameLookup != other.nameLookup: 653 for name in self.nameLookup: 654 if name not in other.nameLookup: 655 yield ( 656 f"other graph is missing name lookup entry" 657 f" for {name!r}" 658 ) 659 else: 660 mine = self.nameLookup[name] 661 theirs = other.nameLookup[name] 662 if theirs != mine: 663 yield ( 664 f"name lookup for {name!r} is {theirs}" 665 f" instead of {mine}" 666 ) 667 extras = set(other.nameLookup) - set(self.nameLookup) 668 if extras: 669 yield ( 670 f"other graph has extra name lookup entries:" 671 f" {extras}" 672 ) 673 674 def _assignID(self) -> base.DecisionID: 675 """ 676 Returns the next `base.DecisionID` to use and increments the 677 next ID counter. 678 """ 679 result = self.nextID 680 self.nextID += 1 681 return result 682 683 def _assignMechanismID(self) -> base.MechanismID: 684 """ 685 Returns the next `base.MechanismID` to use and increments the 686 next ID counter. 687 """ 688 result = self.nextMechanismID 689 self.nextMechanismID += 1 690 return result 691 692 def decisionInfo(self, dID: base.DecisionID) -> DecisionInfo: 693 """ 694 Retrieves the decision info for the specified decision, as a 695 live editable dictionary. 696 697 For example: 698 699 >>> g = DecisionGraph() 700 >>> g.addDecision('A') 701 0 702 >>> g.annotateDecision('A', 'note') 703 >>> g.decisionInfo(0) 704 {'name': 'A', 'domain': 'main', 'tags': {}, 'annotations': ['note']} 705 """ 706 return cast(DecisionInfo, self.nodes[dID]) 707 708 def resolveDecision( 709 self, 710 spec: base.AnyDecisionSpecifier, 711 zoneHint: Optional[base.Zone] = None, 712 domainHint: Optional[base.Domain] = None 713 ) -> base.DecisionID: 714 """ 715 Given a decision specifier returns the ID associated with that 716 decision, or raises an `AmbiguousDecisionSpecifierError` or a 717 `MissingDecisionError` if the specified decision is either 718 missing or ambiguous. Cannot handle strings that contain domain 719 and/or zone parts; use 720 `parsing.ParseFormat.parseDecisionSpecifier` to turn such 721 strings into `DecisionSpecifier`s if you need to first. 722 723 Examples: 724 725 >>> g = DecisionGraph() 726 >>> g.addDecision('A') 727 0 728 >>> g.addDecision('B') 729 1 730 >>> g.addDecision('C') 731 2 732 >>> g.addDecision('A') 733 3 734 >>> g.addDecision('B', 'menu') 735 4 736 >>> g.createZone('Z', 0) 737 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 738 annotations=[]) 739 >>> g.createZone('Z2', 0) 740 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 741 annotations=[]) 742 >>> g.createZone('Zup', 1) 743 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 744 annotations=[]) 745 >>> g.addDecisionToZone(0, 'Z') 746 >>> g.addDecisionToZone(1, 'Z') 747 >>> g.addDecisionToZone(2, 'Z') 748 >>> g.addDecisionToZone(3, 'Z2') 749 >>> g.addZoneToZone('Z', 'Zup') 750 >>> g.addZoneToZone('Z2', 'Zup') 751 >>> g.resolveDecision(1) 752 1 753 >>> g.resolveDecision('A') 754 Traceback (most recent call last): 755 ... 756 exploration.core.AmbiguousDecisionSpecifierError... 757 >>> g.resolveDecision('B') 758 Traceback (most recent call last): 759 ... 760 exploration.core.AmbiguousDecisionSpecifierError... 761 >>> g.resolveDecision('C') 762 2 763 >>> g.resolveDecision('A', 'Z') 764 0 765 >>> g.resolveDecision('A', zoneHint='Z2') 766 3 767 >>> g.resolveDecision('B', domainHint='main') 768 1 769 >>> g.resolveDecision('B', None, 'menu') 770 4 771 >>> g.resolveDecision('B', zoneHint='Z2') 772 Traceback (most recent call last): 773 ... 774 exploration.core.MissingDecisionError... 775 >>> g.resolveDecision('A', domainHint='menu') 776 Traceback (most recent call last): 777 ... 778 exploration.core.MissingDecisionError... 779 >>> g.resolveDecision('A', domainHint='madeup') 780 Traceback (most recent call last): 781 ... 782 exploration.core.MissingDecisionError... 783 >>> g.resolveDecision('A', zoneHint='madeup') 784 Traceback (most recent call last): 785 ... 786 exploration.core.MissingDecisionError... 787 """ 788 # Parse it to either an ID or specifier if it's a string: 789 if isinstance(spec, str): 790 try: 791 spec = int(spec) 792 except ValueError: 793 pass 794 795 # If it's an ID, check for existence: 796 if isinstance(spec, base.DecisionID): 797 if spec in self: 798 return spec 799 else: 800 raise MissingDecisionError( 801 f"There is no decision with ID {spec!r}." 802 ) 803 else: 804 if isinstance(spec, base.DecisionName): 805 spec = base.DecisionSpecifier( 806 domain=None, 807 zone=None, 808 name=spec 809 ) 810 elif not isinstance(spec, base.DecisionSpecifier): 811 raise TypeError( 812 f"Specification is not provided as a" 813 f" DecisionSpecifier or other valid type. (got type" 814 f" {type(spec)})." 815 ) 816 817 # Merge domain hints from spec/args 818 if ( 819 spec.domain is not None 820 and domainHint is not None 821 and spec.domain != domainHint 822 ): 823 raise ValueError( 824 f"Specifier {repr(spec)} includes domain hint" 825 f" {repr(spec.domain)} which is incompatible with" 826 f" explicit domain hint {repr(domainHint)}." 827 ) 828 else: 829 domainHint = spec.domain or domainHint 830 831 # Merge zone hints from spec/args 832 if ( 833 spec.zone is not None 834 and zoneHint is not None 835 and spec.zone != zoneHint 836 ): 837 raise ValueError( 838 f"Specifier {repr(spec)} includes zone hint" 839 f" {repr(spec.zone)} which is incompatible with" 840 f" explicit zone hint {repr(zoneHint)}." 841 ) 842 else: 843 zoneHint = spec.zone or zoneHint 844 845 if spec.name not in self.nameLookup: 846 raise MissingDecisionError( 847 f"No decision named {repr(spec.name)}." 848 ) 849 else: 850 options = self.nameLookup[spec.name] 851 if len(options) == 0: 852 raise MissingDecisionError( 853 f"No decision named {repr(spec.name)}." 854 ) 855 filtered = [ 856 opt 857 for opt in options 858 if ( 859 domainHint is None 860 or self.domainFor(opt) == domainHint 861 ) and ( 862 zoneHint is None 863 or zoneHint in self.zoneAncestors(opt) 864 ) 865 ] 866 if len(filtered) == 1: 867 return filtered[0] 868 else: 869 filterDesc = "" 870 if domainHint is not None: 871 filterDesc += f" in domain {repr(domainHint)}" 872 if zoneHint is not None: 873 filterDesc += f" in zone {repr(zoneHint)}" 874 if len(filtered) == 0: 875 raise MissingDecisionError( 876 f"No decisions named" 877 f" {repr(spec.name)}{filterDesc}." 878 ) 879 else: 880 raise AmbiguousDecisionSpecifierError( 881 f"There are {len(filtered)} decisions" 882 f" named {repr(spec.name)}{filterDesc}." 883 ) 884 885 def getDecision( 886 self, 887 decision: base.AnyDecisionSpecifier, 888 zoneHint: Optional[base.Zone] = None, 889 domainHint: Optional[base.Domain] = None 890 ) -> Optional[base.DecisionID]: 891 """ 892 Works like `resolveDecision` but returns None instead of raising 893 a `MissingDecisionError` if the specified decision isn't listed. 894 May still raise an `AmbiguousDecisionSpecifierError`. 895 """ 896 try: 897 return self.resolveDecision( 898 decision, 899 zoneHint, 900 domainHint 901 ) 902 except MissingDecisionError: 903 return None 904 905 def nameFor( 906 self, 907 decision: base.AnyDecisionSpecifier 908 ) -> base.DecisionName: 909 """ 910 Returns the name of the specified decision. Note that names are 911 not necessarily unique. 912 913 Example: 914 915 >>> d = DecisionGraph() 916 >>> d.addDecision('A') 917 0 918 >>> d.addDecision('B') 919 1 920 >>> d.addDecision('B') 921 2 922 >>> d.nameFor(0) 923 'A' 924 >>> d.nameFor(1) 925 'B' 926 >>> d.nameFor(2) 927 'B' 928 >>> d.nameFor(3) 929 Traceback (most recent call last): 930 ... 931 exploration.core.MissingDecisionError... 932 """ 933 dID = self.resolveDecision(decision) 934 return self.nodes[dID]['name'] 935 936 def shortIdentity( 937 self, 938 decision: Optional[base.AnyDecisionSpecifier], 939 includeZones: bool = True, 940 alwaysDomain: Optional[bool] = None 941 ): 942 """ 943 Returns a string containing the name for the given decision, 944 prefixed by its level-0 zone(s) and domain. If the value provided 945 is `None`, it returns the string "(nowhere)". 946 947 If `includeZones` is true (the default) then zone information 948 is included before the decision name. 949 950 If `alwaysDomain` is true or false, then the domain information 951 will always (or never) be included. If it's `None` (the default) 952 then domain info will only be included for decisions which are 953 not in the default domain. 954 """ 955 if decision is None: 956 return "(nowhere)" 957 else: 958 dID = self.resolveDecision(decision) 959 thisDomain = self.domainFor(dID) 960 dSpec = '' 961 zSpec = '' 962 if ( 963 alwaysDomain is True 964 or ( 965 alwaysDomain is None 966 and thisDomain != base.DEFAULT_DOMAIN 967 ) 968 ): 969 dSpec = thisDomain + '//' # TODO: Don't hardcode this? 970 if includeZones: 971 zones = [ 972 z 973 for z in self.zoneParents(dID) 974 if self.zones[z].level == 0 975 ] 976 if len(zones) == 1: 977 zSpec = zones[0] + '::' # TODO: Don't hardcode this? 978 elif len(zones) > 1: 979 zSpec = '[' + ', '.join(sorted(zones)) + ']::' 980 # else leave zSpec empty 981 982 return f"{dSpec}{zSpec}{self.nameFor(dID)}" 983 984 def identityOf( 985 self, 986 decision: Optional[base.AnyDecisionSpecifier], 987 includeZones: bool = True, 988 alwaysDomain: Optional[bool] = None 989 ) -> str: 990 """ 991 Returns the given node's ID, plus its `shortIdentity` in 992 parentheses. Arguments are passed through to `shortIdentity`. 993 """ 994 if decision is None: 995 return "(nowhere)" 996 else: 997 dID = self.resolveDecision(decision) 998 short = self.shortIdentity(decision, includeZones, alwaysDomain) 999 return f"{dID} ({short})" 1000 1001 def namesListing( 1002 self, 1003 decisions: Collection[base.DecisionID], 1004 includeZones: bool = True, 1005 indent: int = 2 1006 ) -> str: 1007 """ 1008 Returns a multi-line string containing an indented listing of 1009 the provided decision IDs with their names in parentheses after 1010 each. Useful for debugging & error messages. 1011 1012 Includes level-0 zones where applicable, with a zone separator 1013 before the decision, unless `includeZones` is set to False. Where 1014 there are multiple level-0 zones, they're listed together in 1015 brackets. 1016 1017 Uses the string '(none)' when there are no decisions are in the 1018 list. 1019 1020 Set `indent` to something other than 2 to control how much 1021 indentation is added. 1022 1023 For example: 1024 1025 >>> g = DecisionGraph() 1026 >>> g.addDecision('A') 1027 0 1028 >>> g.addDecision('B') 1029 1 1030 >>> g.addDecision('C') 1031 2 1032 >>> g.namesListing(['A', 'C', 'B']) 1033 ' 0 (A)\\n 2 (C)\\n 1 (B)\\n' 1034 >>> g.namesListing([]) 1035 ' (none)\\n' 1036 >>> g.createZone('zone', 0) 1037 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1038 annotations=[]) 1039 >>> g.createZone('zone2', 0) 1040 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1041 annotations=[]) 1042 >>> g.createZone('zoneUp', 1) 1043 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 1044 annotations=[]) 1045 >>> g.addDecisionToZone(0, 'zone') 1046 >>> g.addDecisionToZone(1, 'zone') 1047 >>> g.addDecisionToZone(1, 'zone2') 1048 >>> g.addDecisionToZone(2, 'zoneUp') # won't be listed: it's level-1 1049 >>> g.namesListing(['A', 'C', 'B']) 1050 ' 0 (zone::A)\\n 2 (C)\\n 1 ([zone, zone2]::B)\\n' 1051 """ 1052 ind = ' ' * indent 1053 if len(decisions) == 0: 1054 return ind + '(none)\n' 1055 else: 1056 result = '' 1057 for dID in decisions: 1058 result += ind + self.identityOf(dID, includeZones) + '\n' 1059 return result 1060 1061 def destinationsListing( 1062 self, 1063 destinations: Dict[base.Transition, base.DecisionID], 1064 includeZones: bool = True, 1065 indent: int = 2 1066 ) -> str: 1067 """ 1068 Returns a multi-line string containing an indented listing of 1069 the provided transitions along with their destinations and the 1070 names of those destinations in parentheses. Useful for debugging 1071 & error messages. (Use e.g., `destinationsFrom` to get a 1072 transitions -> destinations dictionary in the required format.) 1073 1074 Uses the string '(no transitions)' when there are no transitions 1075 in the dictionary. 1076 1077 Set `indent` to something other than 2 to control how much 1078 indentation is added. 1079 1080 For example: 1081 1082 >>> g = DecisionGraph() 1083 >>> g.addDecision('A') 1084 0 1085 >>> g.addDecision('B') 1086 1 1087 >>> g.addDecision('C') 1088 2 1089 >>> g.addTransition('A', 'north', 'B', 'south') 1090 >>> g.addTransition('B', 'east', 'C', 'west') 1091 >>> g.addTransition('C', 'southwest', 'A', 'northeast') 1092 >>> g.destinationsListing(g.destinationsFrom('A')) 1093 ' north to 1 (B)\\n northeast to 2 (C)\\n' 1094 >>> g.destinationsListing(g.destinationsFrom('B')) 1095 ' south to 0 (A)\\n east to 2 (C)\\n' 1096 >>> g.destinationsListing({}) 1097 ' (none)\\n' 1098 >>> g.createZone('zone', 0) 1099 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1100 annotations=[]) 1101 >>> g.addDecisionToZone(0, 'zone') 1102 >>> g.destinationsListing(g.destinationsFrom('B')) 1103 ' south to 0 (zone::A)\\n east to 2 (C)\\n' 1104 """ 1105 ind = ' ' * indent 1106 if len(destinations) == 0: 1107 return ind + '(none)\n' 1108 else: 1109 result = '' 1110 for transition, dID in destinations.items(): 1111 line = f"{transition} to {self.identityOf(dID, includeZones)}" 1112 result += ind + line + '\n' 1113 return result 1114 1115 def domainFor(self, decision: base.AnyDecisionSpecifier) -> base.Domain: 1116 """ 1117 Returns the domain that a decision belongs to. 1118 """ 1119 dID = self.resolveDecision(decision) 1120 return self.nodes[dID]['domain'] 1121 1122 def allDecisionsInDomain( 1123 self, 1124 domain: base.Domain 1125 ) -> Set[base.DecisionID]: 1126 """ 1127 Returns the set of all `DecisionID`s for decisions in the 1128 specified domain. 1129 """ 1130 return set(dID for dID in self if self.nodes[dID]['domain'] == domain) 1131 1132 def destination( 1133 self, 1134 decision: base.AnyDecisionSpecifier, 1135 transition: base.Transition 1136 ) -> base.DecisionID: 1137 """ 1138 Overrides base `UniqueExitsGraph.destination` to raise 1139 `MissingDecisionError` or `MissingTransitionError` as 1140 appropriate, and to work with an `AnyDecisionSpecifier`. 1141 """ 1142 dID = self.resolveDecision(decision) 1143 try: 1144 return super().destination(dID, transition) 1145 except KeyError: 1146 raise MissingTransitionError( 1147 f"Transition {transition!r} does not exist at decision" 1148 f" {self.identityOf(dID)}." 1149 ) 1150 1151 def getDestination( 1152 self, 1153 decision: base.AnyDecisionSpecifier, 1154 transition: base.Transition, 1155 default: Any = None 1156 ) -> Optional[base.DecisionID]: 1157 """ 1158 Overrides base `UniqueExitsGraph.getDestination` with different 1159 argument names, since those matter for the edit DSL. 1160 """ 1161 dID = self.resolveDecision(decision) 1162 return super().getDestination(dID, transition) 1163 1164 def destinationsFrom( 1165 self, 1166 decision: base.AnyDecisionSpecifier 1167 ) -> Dict[base.Transition, base.DecisionID]: 1168 """ 1169 Override that just changes the type of the exception from a 1170 `KeyError` to a `MissingDecisionError` when the source does not 1171 exist. 1172 """ 1173 dID = self.resolveDecision(decision) 1174 return super().destinationsFrom(dID) 1175 1176 def bothEnds( 1177 self, 1178 decision: base.AnyDecisionSpecifier, 1179 transition: base.Transition 1180 ) -> Set[base.DecisionID]: 1181 """ 1182 Returns a set containing the `DecisionID`(s) for both the start 1183 and end of the specified transition. Raises a 1184 `MissingDecisionError` or `MissingTransitionError`if the 1185 specified decision and/or transition do not exist. 1186 1187 Note that for actions since the source and destination are the 1188 same, the set will have only one element. 1189 """ 1190 dID = self.resolveDecision(decision) 1191 result = {dID} 1192 dest = self.destination(dID, transition) 1193 if dest is not None: 1194 result.add(dest) 1195 return result 1196 1197 def decisionActions( 1198 self, 1199 decision: base.AnyDecisionSpecifier 1200 ) -> Set[base.Transition]: 1201 """ 1202 Retrieves the set of self-edges at a decision. Editing the set 1203 will not affect the graph. 1204 1205 Example: 1206 1207 >>> g = DecisionGraph() 1208 >>> g.addDecision('A') 1209 0 1210 >>> g.addDecision('B') 1211 1 1212 >>> g.addDecision('C') 1213 2 1214 >>> g.addAction('A', 'action1') 1215 >>> g.addAction('A', 'action2') 1216 >>> g.addAction('B', 'action3') 1217 >>> sorted(g.decisionActions('A')) 1218 ['action1', 'action2'] 1219 >>> g.decisionActions('B') 1220 {'action3'} 1221 >>> g.decisionActions('C') 1222 set() 1223 """ 1224 result = set() 1225 dID = self.resolveDecision(decision) 1226 for transition, dest in self.destinationsFrom(dID).items(): 1227 if dest == dID: 1228 result.add(transition) 1229 return result 1230 1231 def getTransitionProperties( 1232 self, 1233 decision: base.AnyDecisionSpecifier, 1234 transition: base.Transition 1235 ) -> TransitionProperties: 1236 """ 1237 Returns a dictionary containing transition properties for the 1238 specified transition from the specified decision. The properties 1239 included are: 1240 1241 - 'requirement': The requirement for the transition. 1242 - 'consequence': Any consequence of the transition. 1243 - 'tags': Any tags applied to the transition. 1244 - 'annotations': Any annotations on the transition. 1245 1246 The reciprocal of the transition is not included. 1247 1248 The result is a clone of the stored properties; edits to the 1249 dictionary will NOT modify the graph. 1250 """ 1251 dID = self.resolveDecision(decision) 1252 dest = self.destination(dID, transition) 1253 1254 info: TransitionProperties = copy.deepcopy( 1255 self.edges[dID, dest, transition] # type:ignore 1256 ) 1257 return { 1258 'requirement': info.get('requirement', base.ReqNothing()), 1259 'consequence': info.get('consequence', []), 1260 'tags': info.get('tags', {}), 1261 'annotations': info.get('annotations', []) 1262 } 1263 1264 def setTransitionProperties( 1265 self, 1266 decision: base.AnyDecisionSpecifier, 1267 transition: base.Transition, 1268 requirement: Optional[base.Requirement] = None, 1269 consequence: Optional[base.Consequence] = None, 1270 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 1271 annotations: Optional[List[base.Annotation]] = None 1272 ) -> None: 1273 """ 1274 Sets one or more transition properties all at once. Can be used 1275 to set the requirement, consequence, tags, and/or annotations. 1276 Old values are overwritten, although if `None`s are provided (or 1277 arguments are omitted), corresponding properties are not 1278 updated. 1279 1280 To add tags or annotations to existing tags/annotations instead 1281 of replacing them, use `tagTransition` or `annotateTransition` 1282 instead. 1283 """ 1284 dID = self.resolveDecision(decision) 1285 if requirement is not None: 1286 self.setTransitionRequirement(dID, transition, requirement) 1287 if consequence is not None: 1288 self.setConsequence(dID, transition, consequence) 1289 if tags is not None: 1290 dest = self.destination(dID, transition) 1291 # TODO: Submit pull request to update MultiDiGraph stubs in 1292 # types-networkx to include OutMultiEdgeView that accepts 1293 # from/to/key tuples as indices. 1294 info = cast( 1295 TransitionProperties, 1296 self.edges[dID, dest, transition] # type:ignore 1297 ) 1298 info['tags'] = tags 1299 if annotations is not None: 1300 dest = self.destination(dID, transition) 1301 info = cast( 1302 TransitionProperties, 1303 self.edges[dID, dest, transition] # type:ignore 1304 ) 1305 info['annotations'] = annotations 1306 1307 def getTransitionRequirement( 1308 self, 1309 decision: base.AnyDecisionSpecifier, 1310 transition: base.Transition 1311 ) -> base.Requirement: 1312 """ 1313 Returns the `Requirement` for accessing a specific transition at 1314 a specific decision. For transitions which don't have 1315 requirements, returns a `ReqNothing` instance. 1316 """ 1317 dID = self.resolveDecision(decision) 1318 dest = self.destination(dID, transition) 1319 1320 info = cast( 1321 TransitionProperties, 1322 self.edges[dID, dest, transition] # type:ignore 1323 ) 1324 1325 return info.get('requirement', base.ReqNothing()) 1326 1327 def setTransitionRequirement( 1328 self, 1329 decision: base.AnyDecisionSpecifier, 1330 transition: base.Transition, 1331 requirement: Optional[base.Requirement] 1332 ) -> None: 1333 """ 1334 Sets the `Requirement` for accessing a specific transition at 1335 a specific decision. Raises a `KeyError` if the decision or 1336 transition does not exist. 1337 1338 Deletes the requirement if `None` is given as the requirement. 1339 1340 Use `parsing.ParseFormat.parseRequirement` first if you have a 1341 requirement in string format. 1342 1343 Does not raise an error if deletion is requested for a 1344 non-existent requirement, and silently overwrites any previous 1345 requirement. 1346 """ 1347 dID = self.resolveDecision(decision) 1348 1349 dest = self.destination(dID, transition) 1350 1351 info = cast( 1352 TransitionProperties, 1353 self.edges[dID, dest, transition] # type:ignore 1354 ) 1355 1356 if requirement is None: 1357 try: 1358 del info['requirement'] 1359 except KeyError: 1360 pass 1361 else: 1362 if not isinstance(requirement, base.Requirement): 1363 raise TypeError( 1364 f"Invalid requirement type: {type(requirement)}" 1365 ) 1366 1367 info['requirement'] = requirement 1368 1369 def getConsequence( 1370 self, 1371 decision: base.AnyDecisionSpecifier, 1372 transition: base.Transition 1373 ) -> base.Consequence: 1374 """ 1375 Retrieves the consequence of a transition. 1376 1377 A `KeyError` is raised if the specified decision/transition 1378 combination doesn't exist. 1379 """ 1380 dID = self.resolveDecision(decision) 1381 1382 dest = self.destination(dID, transition) 1383 1384 info = cast( 1385 TransitionProperties, 1386 self.edges[dID, dest, transition] # type:ignore 1387 ) 1388 1389 return info.get('consequence', []) 1390 1391 def addConsequence( 1392 self, 1393 decision: base.AnyDecisionSpecifier, 1394 transition: base.Transition, 1395 consequence: base.Consequence 1396 ) -> Tuple[int, int]: 1397 """ 1398 Adds the given `Consequence` to the consequence list for the 1399 specified transition, extending that list at the end. Note that 1400 this does NOT make a copy of the consequence, so it should not 1401 be used to copy consequences from one transition to another 1402 without making a deep copy first. 1403 1404 A `MissingDecisionError` or a `MissingTransitionError` is raised 1405 if the specified decision/transition combination doesn't exist. 1406 1407 Returns a pair of integers indicating the minimum and maximum 1408 depth-first-traversal-indices of the added consequence part(s). 1409 The outer consequence list itself (index 0) is not counted. 1410 1411 >>> d = DecisionGraph() 1412 >>> d.addDecision('A') 1413 0 1414 >>> d.addDecision('B') 1415 1 1416 >>> d.addTransition('A', 'fwd', 'B', 'rev') 1417 >>> d.addConsequence('A', 'fwd', [base.effect(gain='sword')]) 1418 (1, 1) 1419 >>> d.addConsequence('B', 'rev', [base.effect(lose='sword')]) 1420 (1, 1) 1421 >>> ef = d.getConsequence('A', 'fwd') 1422 >>> er = d.getConsequence('B', 'rev') 1423 >>> ef == [base.effect(gain='sword')] 1424 True 1425 >>> er == [base.effect(lose='sword')] 1426 True 1427 >>> d.addConsequence('A', 'fwd', [base.effect(deactivate=True)]) 1428 (2, 2) 1429 >>> ef = d.getConsequence('A', 'fwd') 1430 >>> ef == [base.effect(gain='sword'), base.effect(deactivate=True)] 1431 True 1432 >>> d.addConsequence( 1433 ... 'A', 1434 ... 'fwd', # adding to consequence with 3 parts already 1435 ... [ # outer list not counted because it merges 1436 ... base.challenge( # 1 part 1437 ... None, 1438 ... 0, 1439 ... [base.effect(gain=('flowers', 3))], # 2 parts 1440 ... [base.effect(gain=('flowers', 1))] # 2 parts 1441 ... ) 1442 ... ] 1443 ... ) # note indices below are inclusive; indices are 3, 4, 5, 6, 7 1444 (3, 7) 1445 """ 1446 dID = self.resolveDecision(decision) 1447 1448 dest = self.destination(dID, transition) 1449 1450 info = cast( 1451 TransitionProperties, 1452 self.edges[dID, dest, transition] # type:ignore 1453 ) 1454 1455 existing = info.setdefault('consequence', []) 1456 startIndex = base.countParts(existing) 1457 existing.extend(consequence) 1458 endIndex = base.countParts(existing) - 1 1459 return (startIndex, endIndex) 1460 1461 def setConsequence( 1462 self, 1463 decision: base.AnyDecisionSpecifier, 1464 transition: base.Transition, 1465 consequence: base.Consequence 1466 ) -> None: 1467 """ 1468 Replaces the transition consequence for the given transition at 1469 the given decision. Any previous consequence is discarded. See 1470 `Consequence` for the structure of these. Note that this does 1471 NOT make a copy of the consequence, do that first to avoid 1472 effect-entanglement if you're copying a consequence. 1473 1474 A `MissingDecisionError` or a `MissingTransitionError` is raised 1475 if the specified decision/transition combination doesn't exist. 1476 """ 1477 dID = self.resolveDecision(decision) 1478 1479 dest = self.destination(dID, transition) 1480 1481 info = cast( 1482 TransitionProperties, 1483 self.edges[dID, dest, transition] # type:ignore 1484 ) 1485 1486 info['consequence'] = consequence 1487 1488 def addEquivalence( 1489 self, 1490 requirement: base.Requirement, 1491 capabilityOrMechanismState: Union[ 1492 base.Capability, 1493 Tuple[base.MechanismID, base.MechanismState] 1494 ] 1495 ) -> None: 1496 """ 1497 Adds the given requirement as an equivalence for the given 1498 capability or the given mechanism state. Note that having a 1499 capability via an equivalence does not count as actually having 1500 that capability; it only counts for the purpose of satisfying 1501 `Requirement`s. 1502 1503 Note also that because a mechanism-based requirement looks up 1504 the specific mechanism locally based on a name, an equivalence 1505 defined in one location may affect mechanism requirements in 1506 other locations unless the mechanism name in the requirement is 1507 zone-qualified to be specific. But in such situations the base 1508 mechanism would have caused issues in any case. 1509 """ 1510 self.equivalences.setdefault( 1511 capabilityOrMechanismState, 1512 set() 1513 ).add(requirement) 1514 1515 def removeEquivalence( 1516 self, 1517 requirement: base.Requirement, 1518 capabilityOrMechanismState: Union[ 1519 base.Capability, 1520 Tuple[base.MechanismID, base.MechanismState] 1521 ] 1522 ) -> None: 1523 """ 1524 Removes an equivalence. Raises a `KeyError` if no such 1525 equivalence existed. 1526 """ 1527 self.equivalences[capabilityOrMechanismState].remove(requirement) 1528 1529 def hasAnyEquivalents( 1530 self, 1531 capabilityOrMechanismState: Union[ 1532 base.Capability, 1533 Tuple[base.MechanismID, base.MechanismState] 1534 ] 1535 ) -> bool: 1536 """ 1537 Returns `True` if the given capability or mechanism state has at 1538 least one equivalence. 1539 """ 1540 return capabilityOrMechanismState in self.equivalences 1541 1542 def allEquivalents( 1543 self, 1544 capabilityOrMechanismState: Union[ 1545 base.Capability, 1546 Tuple[base.MechanismID, base.MechanismState] 1547 ] 1548 ) -> Set[base.Requirement]: 1549 """ 1550 Returns the set of equivalences for the given capability. This is 1551 a live set which may be modified (it's probably better to use 1552 `addEquivalence` and `removeEquivalence` instead...). 1553 """ 1554 return self.equivalences.setdefault( 1555 capabilityOrMechanismState, 1556 set() 1557 ) 1558 1559 def reversionType(self, name: str, equivalentTo: Set[str]) -> None: 1560 """ 1561 Specifies a new reversion type, so that when used in a reversion 1562 aspects set with a colon before the name, all items in the 1563 `equivalentTo` value will be added to that set. These may 1564 include other custom reversion type names (with the colon) but 1565 take care not to create an equivalence loop which would result 1566 in a crash. 1567 1568 If you re-use the same name, it will override the old equivalence 1569 for that name. 1570 """ 1571 self.reversionTypes[name] = equivalentTo 1572 1573 def addAction( 1574 self, 1575 decision: base.AnyDecisionSpecifier, 1576 action: base.Transition, 1577 requires: Optional[base.Requirement] = None, 1578 consequence: Optional[base.Consequence] = None, 1579 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 1580 annotations: Optional[List[base.Annotation]] = None, 1581 ) -> None: 1582 """ 1583 Adds the given action as a possibility at the given decision. An 1584 action is just a self-edge, which can have requirements like any 1585 edge, and which can have consequences like any edge. 1586 The optional arguments are given to `setTransitionRequirement` 1587 and `setConsequence`; see those functions for descriptions 1588 of what they mean. 1589 1590 Raises a `KeyError` if a transition with the given name already 1591 exists at the given decision. 1592 """ 1593 if tags is None: 1594 tags = {} 1595 if annotations is None: 1596 annotations = [] 1597 1598 dID = self.resolveDecision(decision) 1599 1600 self.add_edge( 1601 dID, 1602 dID, 1603 key=action, 1604 tags=tags, 1605 annotations=annotations 1606 ) 1607 self.setTransitionRequirement(dID, action, requires) 1608 if consequence is not None: 1609 self.setConsequence(dID, action, consequence) 1610 1611 def tagDecision( 1612 self, 1613 decision: base.AnyDecisionSpecifier, 1614 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 1615 tagValue: Union[ 1616 base.TagValue, 1617 type[base.NoTagValue] 1618 ] = base.NoTagValue 1619 ) -> None: 1620 """ 1621 Adds a tag (or many tags from a dictionary of tags) to a 1622 decision, using `1` as the value if no value is provided. It's 1623 a `ValueError` to provide a value when a dictionary of tags is 1624 provided to set multiple tags at once. 1625 1626 Note that certain tags have special meanings: 1627 1628 - 'unconfirmed' is used for decisions that represent unconfirmed 1629 parts of the graph (this is separate from the 'unknown' 1630 and/or 'hypothesized' exploration statuses, which are only 1631 tracked in a `DiscreteExploration`, not in a `DecisionGraph`). 1632 Various methods require this tag and many also add or remove 1633 it. 1634 """ 1635 if isinstance(tagOrTags, base.Tag): 1636 if tagValue is base.NoTagValue: 1637 tagValue = 1 1638 1639 # Not sure why this cast is necessary given the `if` above... 1640 tagValue = cast(base.TagValue, tagValue) 1641 1642 tagOrTags = {tagOrTags: tagValue} 1643 1644 elif tagValue is not base.NoTagValue: 1645 raise ValueError( 1646 "Provided a dictionary to update multiple tags, but" 1647 " also a tag value." 1648 ) 1649 1650 dID = self.resolveDecision(decision) 1651 1652 tagsAlready = self.nodes[dID].setdefault('tags', {}) 1653 tagsAlready.update(tagOrTags) 1654 1655 def untagDecision( 1656 self, 1657 decision: base.AnyDecisionSpecifier, 1658 tag: base.Tag 1659 ) -> Union[base.TagValue, type[base.NoTagValue]]: 1660 """ 1661 Removes a tag from a decision. Returns the tag's old value if 1662 the tag was present and got removed, or `NoTagValue` if the tag 1663 wasn't present. 1664 """ 1665 dID = self.resolveDecision(decision) 1666 1667 target = self.nodes[dID]['tags'] 1668 try: 1669 return target.pop(tag) 1670 except KeyError: 1671 return base.NoTagValue 1672 1673 def decisionTags( 1674 self, 1675 decision: base.AnyDecisionSpecifier 1676 ) -> Dict[base.Tag, base.TagValue]: 1677 """ 1678 Returns the dictionary of tags for a decision. Edits to the 1679 returned value will be applied to the graph. 1680 """ 1681 dID = self.resolveDecision(decision) 1682 1683 return self.nodes[dID]['tags'] 1684 1685 def annotateDecision( 1686 self, 1687 decision: base.AnyDecisionSpecifier, 1688 annotationOrAnnotations: Union[ 1689 base.Annotation, 1690 Sequence[base.Annotation] 1691 ] 1692 ) -> None: 1693 """ 1694 Adds an annotation to a decision's annotations list. 1695 """ 1696 dID = self.resolveDecision(decision) 1697 1698 if isinstance(annotationOrAnnotations, base.Annotation): 1699 annotationOrAnnotations = [annotationOrAnnotations] 1700 self.nodes[dID]['annotations'].extend(annotationOrAnnotations) 1701 1702 def decisionAnnotations( 1703 self, 1704 decision: base.AnyDecisionSpecifier 1705 ) -> List[base.Annotation]: 1706 """ 1707 Returns the list of annotations for the specified decision. 1708 Modifying the list affects the graph. 1709 """ 1710 dID = self.resolveDecision(decision) 1711 1712 return self.nodes[dID]['annotations'] 1713 1714 def tagTransition( 1715 self, 1716 decision: base.AnyDecisionSpecifier, 1717 transition: base.Transition, 1718 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 1719 tagValue: Union[ 1720 base.TagValue, 1721 type[base.NoTagValue] 1722 ] = base.NoTagValue 1723 ) -> None: 1724 """ 1725 Adds a tag (or each tag from a dictionary) to a transition 1726 coming out of a specific decision. `1` will be used as the 1727 default value if a single tag is supplied; supplying a tag value 1728 when providing a dictionary of multiple tags to update is a 1729 `ValueError`. 1730 1731 Note that certain transition tags have special meanings: 1732 - 'trigger' causes any actions (but not normal transitions) that 1733 it applies to to be automatically triggered when 1734 `advanceSituation` is called and the decision they're 1735 attached to is active in the new situation (as long as the 1736 action's requirements are met). This happens once per 1737 situation; use 'wait' steps to re-apply triggers. 1738 """ 1739 dID = self.resolveDecision(decision) 1740 1741 dest = self.destination(dID, transition) 1742 if isinstance(tagOrTags, base.Tag): 1743 if tagValue is base.NoTagValue: 1744 tagValue = 1 1745 1746 # Not sure why this is necessary given the `if` above... 1747 tagValue = cast(base.TagValue, tagValue) 1748 1749 tagOrTags = {tagOrTags: tagValue} 1750 elif tagValue is not base.NoTagValue: 1751 raise ValueError( 1752 "Provided a dictionary to update multiple tags, but" 1753 " also a tag value." 1754 ) 1755 1756 info = cast( 1757 TransitionProperties, 1758 self.edges[dID, dest, transition] # type:ignore 1759 ) 1760 1761 info.setdefault('tags', {}).update(tagOrTags) 1762 1763 def untagTransition( 1764 self, 1765 decision: base.AnyDecisionSpecifier, 1766 transition: base.Transition, 1767 tagOrTags: Union[base.Tag, Set[base.Tag]] 1768 ) -> None: 1769 """ 1770 Removes a tag (or each tag in a set) from a transition coming out 1771 of a specific decision. Raises a `KeyError` if (one of) the 1772 specified tag(s) is not currently applied to the specified 1773 transition. 1774 """ 1775 dID = self.resolveDecision(decision) 1776 1777 dest = self.destination(dID, transition) 1778 if isinstance(tagOrTags, base.Tag): 1779 tagOrTags = {tagOrTags} 1780 1781 info = cast( 1782 TransitionProperties, 1783 self.edges[dID, dest, transition] # type:ignore 1784 ) 1785 tagsAlready = info.setdefault('tags', {}) 1786 1787 for tag in tagOrTags: 1788 tagsAlready.pop(tag) 1789 1790 def transitionTags( 1791 self, 1792 decision: base.AnyDecisionSpecifier, 1793 transition: base.Transition 1794 ) -> Dict[base.Tag, base.TagValue]: 1795 """ 1796 Returns the dictionary of tags for a transition. Edits to the 1797 returned dictionary will be applied to the graph. 1798 """ 1799 dID = self.resolveDecision(decision) 1800 1801 dest = self.destination(dID, transition) 1802 info = cast( 1803 TransitionProperties, 1804 self.edges[dID, dest, transition] # type:ignore 1805 ) 1806 return info.setdefault('tags', {}) 1807 1808 def annotateTransition( 1809 self, 1810 decision: base.AnyDecisionSpecifier, 1811 transition: base.Transition, 1812 annotations: Union[base.Annotation, Sequence[base.Annotation]] 1813 ) -> None: 1814 """ 1815 Adds an annotation (or a sequence of annotations) to a 1816 transition's annotations list. 1817 """ 1818 dID = self.resolveDecision(decision) 1819 1820 dest = self.destination(dID, transition) 1821 if isinstance(annotations, base.Annotation): 1822 annotations = [annotations] 1823 info = cast( 1824 TransitionProperties, 1825 self.edges[dID, dest, transition] # type:ignore 1826 ) 1827 info['annotations'].extend(annotations) 1828 1829 def transitionAnnotations( 1830 self, 1831 decision: base.AnyDecisionSpecifier, 1832 transition: base.Transition 1833 ) -> List[base.Annotation]: 1834 """ 1835 Returns the annotation list for a specific transition at a 1836 specific decision. Editing the list affects the graph. 1837 """ 1838 dID = self.resolveDecision(decision) 1839 1840 dest = self.destination(dID, transition) 1841 info = cast( 1842 TransitionProperties, 1843 self.edges[dID, dest, transition] # type:ignore 1844 ) 1845 return info['annotations'] 1846 1847 def annotateZone( 1848 self, 1849 zone: base.Zone, 1850 annotations: Union[base.Annotation, Sequence[base.Annotation]] 1851 ) -> None: 1852 """ 1853 Adds an annotation (or many annotations from a sequence) to a 1854 zone. 1855 1856 Raises a `MissingZoneError` if the specified zone does not exist. 1857 """ 1858 if zone not in self.zones: 1859 raise MissingZoneError( 1860 f"Can't add annotation(s) to zone {zone!r} because that" 1861 f" zone doesn't exist yet." 1862 ) 1863 1864 if isinstance(annotations, base.Annotation): 1865 annotations = [ annotations ] 1866 1867 self.zones[zone].annotations.extend(annotations) 1868 1869 def zoneAnnotations(self, zone: base.Zone) -> List[base.Annotation]: 1870 """ 1871 Returns the list of annotations for the specified zone (empty if 1872 none have been added yet). 1873 """ 1874 return self.zones[zone].annotations 1875 1876 def tagZone( 1877 self, 1878 zone: base.Zone, 1879 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 1880 tagValue: Union[ 1881 base.TagValue, 1882 type[base.NoTagValue] 1883 ] = base.NoTagValue 1884 ) -> None: 1885 """ 1886 Adds a tag (or many tags from a dictionary of tags) to a 1887 zone, using `1` as the value if no value is provided. It's 1888 a `ValueError` to provide a value when a dictionary of tags is 1889 provided to set multiple tags at once. 1890 1891 Raises a `MissingZoneError` if the specified zone does not exist. 1892 """ 1893 if zone not in self.zones: 1894 raise MissingZoneError( 1895 f"Can't add tag(s) to zone {zone!r} because that zone" 1896 f" doesn't exist yet." 1897 ) 1898 1899 if isinstance(tagOrTags, base.Tag): 1900 if tagValue is base.NoTagValue: 1901 tagValue = 1 1902 1903 # Not sure why this cast is necessary given the `if` above... 1904 tagValue = cast(base.TagValue, tagValue) 1905 1906 tagOrTags = {tagOrTags: tagValue} 1907 1908 elif tagValue is not base.NoTagValue: 1909 raise ValueError( 1910 "Provided a dictionary to update multiple tags, but" 1911 " also a tag value." 1912 ) 1913 1914 tagsAlready = self.zones[zone].tags 1915 tagsAlready.update(tagOrTags) 1916 1917 def untagZone( 1918 self, 1919 zone: base.Zone, 1920 tag: base.Tag 1921 ) -> Union[base.TagValue, type[base.NoTagValue]]: 1922 """ 1923 Removes a tag from a zone. Returns the tag's old value if the 1924 tag was present and got removed, or `NoTagValue` if the tag 1925 wasn't present. 1926 1927 Raises a `MissingZoneError` if the specified zone does not exist. 1928 """ 1929 if zone not in self.zones: 1930 raise MissingZoneError( 1931 f"Can't remove tag {tag!r} from zone {zone!r} because" 1932 f" that zone doesn't exist yet." 1933 ) 1934 target = self.zones[zone].tags 1935 try: 1936 return target.pop(tag) 1937 except KeyError: 1938 return base.NoTagValue 1939 1940 def zoneTags( 1941 self, 1942 zone: base.Zone 1943 ) -> Dict[base.Tag, base.TagValue]: 1944 """ 1945 Returns the dictionary of tags for a zone. Edits to the returned 1946 value will be applied to the graph. Returns an empty tags 1947 dictionary if called on a zone that didn't have any tags 1948 previously, but raises a `MissingZoneError` if attempting to get 1949 tags for a zone which does not exist. 1950 1951 For example: 1952 1953 >>> g = DecisionGraph() 1954 >>> g.addDecision('A') 1955 0 1956 >>> g.addDecision('B') 1957 1 1958 >>> g.createZone('Zone') 1959 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1960 annotations=[]) 1961 >>> g.tagZone('Zone', 'color', 'blue') 1962 >>> g.tagZone( 1963 ... 'Zone', 1964 ... {'shape': 'square', 'color': 'red', 'sound': 'loud'} 1965 ... ) 1966 >>> g.untagZone('Zone', 'sound') 1967 'loud' 1968 >>> g.zoneTags('Zone') 1969 {'color': 'red', 'shape': 'square'} 1970 """ 1971 if zone in self.zones: 1972 return self.zones[zone].tags 1973 else: 1974 raise MissingZoneError( 1975 f"Tags for zone {zone!r} don't exist because that" 1976 f" zone has not been created yet." 1977 ) 1978 1979 def createZone(self, zone: base.Zone, level: int = 0) -> base.ZoneInfo: 1980 """ 1981 Creates an empty zone with the given name at the given level 1982 (default 0). Raises a `ZoneCollisionError` if that zone name is 1983 already in use (at any level), including if it's in use by a 1984 decision. 1985 1986 Raises an `InvalidLevelError` if the level value is less than 0. 1987 1988 Returns the `ZoneInfo` for the new blank zone. 1989 1990 For example: 1991 1992 >>> d = DecisionGraph() 1993 >>> d.createZone('Z', 0) 1994 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1995 annotations=[]) 1996 >>> d.getZoneInfo('Z') 1997 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1998 annotations=[]) 1999 >>> d.createZone('Z2', 0) 2000 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2001 annotations=[]) 2002 >>> d.createZone('Z3', -1) # level -1 is not valid (must be >= 0) 2003 Traceback (most recent call last): 2004 ... 2005 exploration.core.InvalidLevelError... 2006 >>> d.createZone('Z2') # Name Z2 is already in use 2007 Traceback (most recent call last): 2008 ... 2009 exploration.core.ZoneCollisionError... 2010 """ 2011 if level < 0: 2012 raise InvalidLevelError( 2013 "Cannot create a zone with a negative level." 2014 ) 2015 if zone in self.zones: 2016 raise ZoneCollisionError(f"Zone {zone!r} already exists.") 2017 if zone in self: 2018 raise ZoneCollisionError( 2019 f"A decision named {zone!r} already exists, so a zone" 2020 f" with that name cannot be created." 2021 ) 2022 info: base.ZoneInfo = base.ZoneInfo( 2023 level=level, 2024 parents=set(), 2025 contents=set(), 2026 tags={}, 2027 annotations=[] 2028 ) 2029 self.zones[zone] = info 2030 return info 2031 2032 def getZoneInfo(self, zone: base.Zone) -> Optional[base.ZoneInfo]: 2033 """ 2034 Returns the `ZoneInfo` (level, parents, and contents) for the 2035 specified zone, or `None` if that zone does not exist. 2036 2037 For example: 2038 2039 >>> d = DecisionGraph() 2040 >>> d.createZone('Z', 0) 2041 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2042 annotations=[]) 2043 >>> d.getZoneInfo('Z') 2044 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2045 annotations=[]) 2046 >>> d.createZone('Z2', 0) 2047 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2048 annotations=[]) 2049 >>> d.getZoneInfo('Z2') 2050 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2051 annotations=[]) 2052 """ 2053 return self.zones.get(zone) 2054 2055 def deleteZone(self, zone: base.Zone) -> base.ZoneInfo: 2056 """ 2057 Deletes the specified zone, returning a `ZoneInfo` object with 2058 the information on the level, parents, and contents of that zone. 2059 2060 Raises a `MissingZoneError` if the zone in question does not 2061 exist. 2062 2063 For example: 2064 2065 >>> d = DecisionGraph() 2066 >>> d.createZone('Z', 0) 2067 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2068 annotations=[]) 2069 >>> d.getZoneInfo('Z') 2070 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2071 annotations=[]) 2072 >>> d.deleteZone('Z') 2073 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2074 annotations=[]) 2075 >>> d.getZoneInfo('Z') is None # no info any more 2076 True 2077 >>> d.deleteZone('Z') # can't re-delete 2078 Traceback (most recent call last): 2079 ... 2080 exploration.core.MissingZoneError... 2081 """ 2082 info = self.getZoneInfo(zone) 2083 if info is None: 2084 raise MissingZoneError( 2085 f"Cannot delete zone {zone!r}: it does not exist." 2086 ) 2087 for sub in info.contents: 2088 if 'zones' in self.nodes[sub]: 2089 try: 2090 self.nodes[sub]['zones'].remove(zone) 2091 except KeyError: 2092 pass 2093 del self.zones[zone] 2094 return info 2095 2096 def addDecisionToZone( 2097 self, 2098 decision: base.AnyDecisionSpecifier, 2099 zone: base.Zone 2100 ) -> None: 2101 """ 2102 Adds a decision directly to a zone. Should normally only be used 2103 with level-0 zones. Raises a `MissingZoneError` if the specified 2104 zone did not already exist. 2105 2106 For example: 2107 2108 >>> d = DecisionGraph() 2109 >>> d.addDecision('A') 2110 0 2111 >>> d.addDecision('B') 2112 1 2113 >>> d.addDecision('C') 2114 2 2115 >>> d.createZone('Z', 0) 2116 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2117 annotations=[]) 2118 >>> d.addDecisionToZone('A', 'Z') 2119 >>> d.getZoneInfo('Z') 2120 ZoneInfo(level=0, parents=set(), contents={0}, tags={},\ 2121 annotations=[]) 2122 >>> d.addDecisionToZone('B', 'Z') 2123 >>> d.getZoneInfo('Z') 2124 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2125 annotations=[]) 2126 """ 2127 dID = self.resolveDecision(decision) 2128 2129 if zone not in self.zones: 2130 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2131 2132 self.zones[zone].contents.add(dID) 2133 self.nodes[dID].setdefault('zones', set()).add(zone) 2134 2135 def removeDecisionFromZone( 2136 self, 2137 decision: base.AnyDecisionSpecifier, 2138 zone: base.Zone 2139 ) -> bool: 2140 """ 2141 Removes a decision from a zone if it had been in it, returning 2142 True if that decision had been in that zone, and False if it was 2143 not in that zone, including if that zone didn't exist. 2144 2145 Note that this only removes a decision from direct zone 2146 membership. If the decision is a member of one or more zones 2147 which are (directly or indirectly) sub-zones of the target zone, 2148 the decision will remain in those zones, and will still be 2149 indirectly part of the target zone afterwards. 2150 2151 Examples: 2152 2153 >>> g = DecisionGraph() 2154 >>> g.addDecision('A') 2155 0 2156 >>> g.addDecision('B') 2157 1 2158 >>> g.createZone('level0', 0) 2159 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2160 annotations=[]) 2161 >>> g.createZone('level1', 1) 2162 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2163 annotations=[]) 2164 >>> g.createZone('level2', 2) 2165 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2166 annotations=[]) 2167 >>> g.createZone('level3', 3) 2168 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2169 annotations=[]) 2170 >>> g.addDecisionToZone('A', 'level0') 2171 >>> g.addDecisionToZone('B', 'level0') 2172 >>> g.addZoneToZone('level0', 'level1') 2173 >>> g.addZoneToZone('level1', 'level2') 2174 >>> g.addZoneToZone('level2', 'level3') 2175 >>> g.addDecisionToZone('B', 'level2') # Direct w/ skips 2176 >>> g.removeDecisionFromZone('A', 'level1') 2177 False 2178 >>> g.zoneParents(0) 2179 {'level0'} 2180 >>> g.removeDecisionFromZone('A', 'level0') 2181 True 2182 >>> g.zoneParents(0) 2183 set() 2184 >>> g.removeDecisionFromZone('A', 'level0') 2185 False 2186 >>> g.removeDecisionFromZone('B', 'level0') 2187 True 2188 >>> g.zoneParents(1) 2189 {'level2'} 2190 >>> g.removeDecisionFromZone('B', 'level0') 2191 False 2192 >>> g.removeDecisionFromZone('B', 'level2') 2193 True 2194 >>> g.zoneParents(1) 2195 set() 2196 """ 2197 dID = self.resolveDecision(decision) 2198 2199 if zone not in self.zones: 2200 return False 2201 2202 info = self.zones[zone] 2203 if dID not in info.contents: 2204 return False 2205 else: 2206 info.contents.remove(dID) 2207 try: 2208 self.nodes[dID]['zones'].remove(zone) 2209 except KeyError: 2210 pass 2211 return True 2212 2213 def addZoneToZone( 2214 self, 2215 addIt: base.Zone, 2216 addTo: base.Zone 2217 ) -> None: 2218 """ 2219 Adds a zone to another zone. The `addIt` one must be at a 2220 strictly lower level than the `addTo` zone, or an 2221 `InvalidLevelError` will be raised. 2222 2223 If the zone to be added didn't already exist, it is created at 2224 one level below the target zone. Similarly, if the zone being 2225 added to didn't already exist, it is created at one level above 2226 the target zone. If neither existed, a `MissingZoneError` will 2227 be raised. 2228 2229 For example: 2230 2231 >>> d = DecisionGraph() 2232 >>> d.addDecision('A') 2233 0 2234 >>> d.addDecision('B') 2235 1 2236 >>> d.addDecision('C') 2237 2 2238 >>> d.createZone('Z', 0) 2239 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2240 annotations=[]) 2241 >>> d.addDecisionToZone('A', 'Z') 2242 >>> d.addDecisionToZone('B', 'Z') 2243 >>> d.getZoneInfo('Z') 2244 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2245 annotations=[]) 2246 >>> d.createZone('Z2', 0) 2247 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2248 annotations=[]) 2249 >>> d.addDecisionToZone('B', 'Z2') 2250 >>> d.addDecisionToZone('C', 'Z2') 2251 >>> d.getZoneInfo('Z2') 2252 ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\ 2253 annotations=[]) 2254 >>> d.createZone('l1Z', 1) 2255 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2256 annotations=[]) 2257 >>> d.createZone('l2Z', 2) 2258 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2259 annotations=[]) 2260 >>> d.addZoneToZone('Z', 'l1Z') 2261 >>> d.getZoneInfo('Z') 2262 ZoneInfo(level=0, parents={'l1Z'}, contents={0, 1}, tags={},\ 2263 annotations=[]) 2264 >>> d.getZoneInfo('l1Z') 2265 ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\ 2266 annotations=[]) 2267 >>> d.addZoneToZone('l1Z', 'l2Z') 2268 >>> d.getZoneInfo('l1Z') 2269 ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\ 2270 annotations=[]) 2271 >>> d.getZoneInfo('l2Z') 2272 ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\ 2273 annotations=[]) 2274 >>> d.addZoneToZone('Z2', 'l2Z') 2275 >>> d.getZoneInfo('Z2') 2276 ZoneInfo(level=0, parents={'l2Z'}, contents={1, 2}, tags={},\ 2277 annotations=[]) 2278 >>> l2i = d.getZoneInfo('l2Z') 2279 >>> l2i.level 2280 2 2281 >>> l2i.parents 2282 set() 2283 >>> sorted(l2i.contents) 2284 ['Z2', 'l1Z'] 2285 >>> d.addZoneToZone('NZ', 'NZ2') 2286 Traceback (most recent call last): 2287 ... 2288 exploration.core.MissingZoneError... 2289 >>> d.addZoneToZone('Z', 'l1Z2') 2290 >>> zi = d.getZoneInfo('Z') 2291 >>> zi.level 2292 0 2293 >>> sorted(zi.parents) 2294 ['l1Z', 'l1Z2'] 2295 >>> sorted(zi.contents) 2296 [0, 1] 2297 >>> d.getZoneInfo('l1Z2') 2298 ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\ 2299 annotations=[]) 2300 >>> d.addZoneToZone('NZ', 'l1Z') 2301 >>> d.getZoneInfo('NZ') 2302 ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\ 2303 annotations=[]) 2304 >>> zi = d.getZoneInfo('l1Z') 2305 >>> zi.level 2306 1 2307 >>> zi.parents 2308 {'l2Z'} 2309 >>> sorted(zi.contents) 2310 ['NZ', 'Z'] 2311 """ 2312 # Create one or the other (but not both) if they're missing 2313 addInfo = self.getZoneInfo(addIt) 2314 toInfo = self.getZoneInfo(addTo) 2315 if addInfo is None and toInfo is None: 2316 raise MissingZoneError( 2317 f"Cannot add zone {addIt!r} to zone {addTo!r}: neither" 2318 f" exists already." 2319 ) 2320 2321 # Create missing addIt 2322 elif addInfo is None: 2323 toInfo = cast(base.ZoneInfo, toInfo) 2324 newLevel = toInfo.level - 1 2325 if newLevel < 0: 2326 raise InvalidLevelError( 2327 f"Zone {addTo!r} is at level {toInfo.level} and so" 2328 f" a new zone cannot be added underneath it." 2329 ) 2330 addInfo = self.createZone(addIt, newLevel) 2331 2332 # Create missing addTo 2333 elif toInfo is None: 2334 addInfo = cast(base.ZoneInfo, addInfo) 2335 newLevel = addInfo.level + 1 2336 if newLevel < 0: 2337 raise InvalidLevelError( 2338 f"Zone {addIt!r} is at level {addInfo.level} (!!!)" 2339 f" and so a new zone cannot be added above it." 2340 ) 2341 toInfo = self.createZone(addTo, newLevel) 2342 2343 # Now both addInfo and toInfo are defined 2344 if addInfo.level >= toInfo.level: 2345 raise InvalidLevelError( 2346 f"Cannot add zone {addIt!r} at level {addInfo.level}" 2347 f" to zone {addTo!r} at level {toInfo.level}: zones can" 2348 f" only contain zones of lower levels." 2349 ) 2350 2351 # Now both addInfo and toInfo are defined 2352 toInfo.contents.add(addIt) 2353 addInfo.parents.add(addTo) 2354 2355 def removeZoneFromZone( 2356 self, 2357 removeIt: base.Zone, 2358 removeFrom: base.Zone 2359 ) -> bool: 2360 """ 2361 Removes a zone from a zone if it had been in it, returning True 2362 if that zone had been in that zone, and False if it was not in 2363 that zone, including if either zone did not exist. 2364 2365 For example: 2366 2367 >>> d = DecisionGraph() 2368 >>> d.createZone('Z', 0) 2369 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2370 annotations=[]) 2371 >>> d.createZone('Z2', 0) 2372 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2373 annotations=[]) 2374 >>> d.createZone('l1Z', 1) 2375 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2376 annotations=[]) 2377 >>> d.createZone('l2Z', 2) 2378 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2379 annotations=[]) 2380 >>> d.addZoneToZone('Z', 'l1Z') 2381 >>> d.addZoneToZone('l1Z', 'l2Z') 2382 >>> d.getZoneInfo('Z') 2383 ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\ 2384 annotations=[]) 2385 >>> d.getZoneInfo('l1Z') 2386 ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\ 2387 annotations=[]) 2388 >>> d.getZoneInfo('l2Z') 2389 ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\ 2390 annotations=[]) 2391 >>> d.removeZoneFromZone('l1Z', 'l2Z') 2392 True 2393 >>> d.getZoneInfo('l1Z') 2394 ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\ 2395 annotations=[]) 2396 >>> d.getZoneInfo('l2Z') 2397 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2398 annotations=[]) 2399 >>> d.removeZoneFromZone('Z', 'l1Z') 2400 True 2401 >>> d.getZoneInfo('Z') 2402 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2403 annotations=[]) 2404 >>> d.getZoneInfo('l1Z') 2405 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2406 annotations=[]) 2407 >>> d.removeZoneFromZone('Z', 'l1Z') 2408 False 2409 >>> d.removeZoneFromZone('Z', 'madeup') 2410 False 2411 >>> d.removeZoneFromZone('nope', 'madeup') 2412 False 2413 >>> d.removeZoneFromZone('nope', 'l1Z') 2414 False 2415 """ 2416 remInfo = self.getZoneInfo(removeIt) 2417 fromInfo = self.getZoneInfo(removeFrom) 2418 2419 if remInfo is None or fromInfo is None: 2420 return False 2421 2422 if removeIt not in fromInfo.contents: 2423 return False 2424 2425 remInfo.parents.remove(removeFrom) 2426 fromInfo.contents.remove(removeIt) 2427 return True 2428 2429 def decisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]: 2430 """ 2431 Returns a set of all decisions included directly in the given 2432 zone, not counting decisions included via intermediate 2433 sub-zones (see `allDecisionsInZone` to include those). 2434 2435 Raises a `MissingZoneError` if the specified zone does not 2436 exist. 2437 2438 The returned set is a copy, not a live editable set. 2439 2440 For example: 2441 2442 >>> d = DecisionGraph() 2443 >>> d.addDecision('A') 2444 0 2445 >>> d.addDecision('B') 2446 1 2447 >>> d.addDecision('C') 2448 2 2449 >>> d.createZone('Z', 0) 2450 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2451 annotations=[]) 2452 >>> d.addDecisionToZone('A', 'Z') 2453 >>> d.addDecisionToZone('B', 'Z') 2454 >>> d.getZoneInfo('Z') 2455 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2456 annotations=[]) 2457 >>> d.decisionsInZone('Z') 2458 {0, 1} 2459 >>> d.createZone('Z2', 0) 2460 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2461 annotations=[]) 2462 >>> d.addDecisionToZone('B', 'Z2') 2463 >>> d.addDecisionToZone('C', 'Z2') 2464 >>> d.getZoneInfo('Z2') 2465 ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\ 2466 annotations=[]) 2467 >>> d.decisionsInZone('Z') 2468 {0, 1} 2469 >>> d.decisionsInZone('Z2') 2470 {1, 2} 2471 >>> d.createZone('l1Z', 1) 2472 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2473 annotations=[]) 2474 >>> d.addZoneToZone('Z', 'l1Z') 2475 >>> d.decisionsInZone('Z') 2476 {0, 1} 2477 >>> d.decisionsInZone('l1Z') 2478 set() 2479 >>> d.decisionsInZone('madeup') 2480 Traceback (most recent call last): 2481 ... 2482 exploration.core.MissingZoneError... 2483 >>> zDec = d.decisionsInZone('Z') 2484 >>> zDec.add(2) # won't affect the zone 2485 >>> zDec 2486 {0, 1, 2} 2487 >>> d.decisionsInZone('Z') 2488 {0, 1} 2489 """ 2490 info = self.getZoneInfo(zone) 2491 if info is None: 2492 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2493 2494 # Everything that's not a zone must be a decision 2495 return { 2496 item 2497 for item in info.contents 2498 if isinstance(item, base.DecisionID) 2499 } 2500 2501 def subZones(self, zone: base.Zone) -> Set[base.Zone]: 2502 """ 2503 Returns the set of all immediate sub-zones of the given zone. 2504 Will be an empty set if there are no sub-zones; raises a 2505 `MissingZoneError` if the specified zone does not exit. 2506 2507 The returned set is a copy, not a live editable set. 2508 2509 For example: 2510 2511 >>> d = DecisionGraph() 2512 >>> d.createZone('Z', 0) 2513 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2514 annotations=[]) 2515 >>> d.subZones('Z') 2516 set() 2517 >>> d.createZone('l1Z', 1) 2518 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2519 annotations=[]) 2520 >>> d.addZoneToZone('Z', 'l1Z') 2521 >>> d.subZones('Z') 2522 set() 2523 >>> d.subZones('l1Z') 2524 {'Z'} 2525 >>> s = d.subZones('l1Z') 2526 >>> s.add('Q') # doesn't affect the zone 2527 >>> sorted(s) 2528 ['Q', 'Z'] 2529 >>> d.subZones('l1Z') 2530 {'Z'} 2531 >>> d.subZones('madeup') 2532 Traceback (most recent call last): 2533 ... 2534 exploration.core.MissingZoneError... 2535 """ 2536 info = self.getZoneInfo(zone) 2537 if info is None: 2538 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2539 2540 # Sub-zones will appear in self.zones 2541 return { 2542 item 2543 for item in info.contents 2544 if isinstance(item, base.Zone) 2545 } 2546 2547 def allDecisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]: 2548 """ 2549 Returns a set containing all decisions in the given zone, 2550 including those included via sub-zones. 2551 2552 Raises a `MissingZoneError` if the specified zone does not 2553 exist.` 2554 2555 For example: 2556 2557 >>> d = DecisionGraph() 2558 >>> d.addDecision('A') 2559 0 2560 >>> d.addDecision('B') 2561 1 2562 >>> d.addDecision('C') 2563 2 2564 >>> d.createZone('Z', 0) 2565 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2566 annotations=[]) 2567 >>> d.addDecisionToZone('A', 'Z') 2568 >>> d.addDecisionToZone('B', 'Z') 2569 >>> d.getZoneInfo('Z') 2570 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2571 annotations=[]) 2572 >>> d.decisionsInZone('Z') 2573 {0, 1} 2574 >>> d.allDecisionsInZone('Z') 2575 {0, 1} 2576 >>> d.createZone('Z2', 0) 2577 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2578 annotations=[]) 2579 >>> d.addDecisionToZone('B', 'Z2') 2580 >>> d.addDecisionToZone('C', 'Z2') 2581 >>> d.getZoneInfo('Z2') 2582 ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\ 2583 annotations=[]) 2584 >>> d.decisionsInZone('Z') 2585 {0, 1} 2586 >>> d.decisionsInZone('Z2') 2587 {1, 2} 2588 >>> d.allDecisionsInZone('Z2') 2589 {1, 2} 2590 >>> d.createZone('l1Z', 1) 2591 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2592 annotations=[]) 2593 >>> d.createZone('l2Z', 2) 2594 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2595 annotations=[]) 2596 >>> d.addZoneToZone('Z', 'l1Z') 2597 >>> d.addZoneToZone('l1Z', 'l2Z') 2598 >>> d.addZoneToZone('Z2', 'l2Z') 2599 >>> d.decisionsInZone('Z') 2600 {0, 1} 2601 >>> d.decisionsInZone('Z2') 2602 {1, 2} 2603 >>> d.decisionsInZone('l1Z') 2604 set() 2605 >>> d.allDecisionsInZone('l1Z') 2606 {0, 1} 2607 >>> d.allDecisionsInZone('l2Z') 2608 {0, 1, 2} 2609 """ 2610 result: Set[base.DecisionID] = set() 2611 info = self.getZoneInfo(zone) 2612 if info is None: 2613 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2614 2615 for item in info.contents: 2616 if isinstance(item, base.Zone): 2617 # This can't be an error because of the condition above 2618 result |= self.allDecisionsInZone(item) 2619 else: # it's a decision 2620 result.add(item) 2621 2622 return result 2623 2624 def zoneHierarchyLevel(self, zone: base.Zone) -> int: 2625 """ 2626 Returns the hierarchy level of the given zone, as stored in its 2627 zone info. 2628 2629 By convention, level-0 zones contain decisions directly, and 2630 higher-level zones contain zones of lower levels. This 2631 convention is not enforced, and there could be exceptions to it. 2632 2633 Raises a `MissingZoneError` if the specified zone does not 2634 exist. 2635 2636 For example: 2637 2638 >>> d = DecisionGraph() 2639 >>> d.createZone('Z', 0) 2640 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2641 annotations=[]) 2642 >>> d.createZone('l1Z', 1) 2643 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2644 annotations=[]) 2645 >>> d.createZone('l5Z', 5) 2646 ZoneInfo(level=5, parents=set(), contents=set(), tags={},\ 2647 annotations=[]) 2648 >>> d.zoneHierarchyLevel('Z') 2649 0 2650 >>> d.zoneHierarchyLevel('l1Z') 2651 1 2652 >>> d.zoneHierarchyLevel('l5Z') 2653 5 2654 >>> d.zoneHierarchyLevel('madeup') 2655 Traceback (most recent call last): 2656 ... 2657 exploration.core.MissingZoneError... 2658 """ 2659 info = self.getZoneInfo(zone) 2660 if info is None: 2661 raise MissingZoneError(f"Zone {zone!r} dose not exist.") 2662 2663 return info.level 2664 2665 def zoneParents( 2666 self, 2667 zoneOrDecision: Union[base.Zone, base.DecisionID] 2668 ) -> Set[base.Zone]: 2669 """ 2670 Returns the set of all zones which directly contain the target 2671 zone or decision. 2672 2673 Raises a `MissingDecisionError` if the target is neither a valid 2674 zone nor a valid decision. 2675 2676 Returns a copy, not a live editable set. 2677 2678 Example: 2679 2680 >>> g = DecisionGraph() 2681 >>> g.addDecision('A') 2682 0 2683 >>> g.addDecision('B') 2684 1 2685 >>> g.createZone('level0', 0) 2686 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2687 annotations=[]) 2688 >>> g.createZone('level1', 1) 2689 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2690 annotations=[]) 2691 >>> g.createZone('level2', 2) 2692 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2693 annotations=[]) 2694 >>> g.createZone('level3', 3) 2695 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2696 annotations=[]) 2697 >>> g.addDecisionToZone('A', 'level0') 2698 >>> g.addDecisionToZone('B', 'level0') 2699 >>> g.addZoneToZone('level0', 'level1') 2700 >>> g.addZoneToZone('level1', 'level2') 2701 >>> g.addZoneToZone('level2', 'level3') 2702 >>> g.addDecisionToZone('B', 'level2') # Direct w/ skips 2703 >>> sorted(g.zoneParents(0)) 2704 ['level0'] 2705 >>> sorted(g.zoneParents(1)) 2706 ['level0', 'level2'] 2707 """ 2708 if zoneOrDecision in self.zones: 2709 zoneOrDecision = cast(base.Zone, zoneOrDecision) 2710 info = cast(base.ZoneInfo, self.getZoneInfo(zoneOrDecision)) 2711 return copy.copy(info.parents) 2712 elif zoneOrDecision in self: 2713 return self.nodes[zoneOrDecision].get('zones', set()) 2714 else: 2715 raise MissingDecisionError( 2716 f"Name {zoneOrDecision!r} is neither a valid zone nor a" 2717 f" valid decision." 2718 ) 2719 2720 def zoneAncestors( 2721 self, 2722 zoneOrDecision: Union[base.Zone, base.DecisionID], 2723 exclude: Set[base.Zone] = set(), 2724 atLevel: Optional[int] = None 2725 ) -> Set[base.Zone]: 2726 """ 2727 Returns the set of zones which contain the target zone or 2728 decision, either directly or indirectly. The target is not 2729 included in the set. 2730 2731 Any ones listed in the `exclude` set are also excluded, as are 2732 any of their ancestors which are not also ancestors of the 2733 target zone via another path of inclusion. 2734 2735 If `atLevel` is not `None`, then only zones at that hierarchy 2736 level will be included. 2737 2738 Raises a `MissingDecisionError` if the target is nether a valid 2739 zone nor a valid decision. 2740 2741 Example: 2742 2743 >>> g = DecisionGraph() 2744 >>> g.addDecision('A') 2745 0 2746 >>> g.addDecision('B') 2747 1 2748 >>> g.createZone('level0', 0) 2749 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2750 annotations=[]) 2751 >>> g.createZone('level1', 1) 2752 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2753 annotations=[]) 2754 >>> g.createZone('level2', 2) 2755 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2756 annotations=[]) 2757 >>> g.createZone('level3', 3) 2758 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2759 annotations=[]) 2760 >>> g.addDecisionToZone('A', 'level0') 2761 >>> g.addDecisionToZone('B', 'level0') 2762 >>> g.addZoneToZone('level0', 'level1') 2763 >>> g.addZoneToZone('level1', 'level2') 2764 >>> g.addZoneToZone('level2', 'level3') 2765 >>> g.addDecisionToZone('B', 'level2') # Direct w/ skips 2766 >>> sorted(g.zoneAncestors(0)) 2767 ['level0', 'level1', 'level2', 'level3'] 2768 >>> sorted(g.zoneAncestors(1)) 2769 ['level0', 'level1', 'level2', 'level3'] 2770 >>> sorted(g.zoneParents(0)) 2771 ['level0'] 2772 >>> sorted(g.zoneParents(1)) 2773 ['level0', 'level2'] 2774 >>> sorted(g.zoneAncestors(0, atLevel=2)) 2775 ['level2'] 2776 >>> sorted(g.zoneAncestors(0, exclude={'level2'})) 2777 ['level0', 'level1'] 2778 """ 2779 # Copy is important here! 2780 result = set(self.zoneParents(zoneOrDecision)) 2781 result -= exclude 2782 for parent in copy.copy(result): 2783 # Recursively dig up ancestors, but exclude 2784 # results-so-far to avoid re-enumerating when there are 2785 # multiple braided inclusion paths. 2786 result |= self.zoneAncestors(parent, result | exclude, atLevel) 2787 2788 if atLevel is not None: 2789 return {z for z in result if self.zoneHierarchyLevel(z) == atLevel} 2790 else: 2791 return result 2792 2793 def region( 2794 self, 2795 decision: base.DecisionID, 2796 useLevel: int=1 2797 ) -> Optional[base.Zone]: 2798 """ 2799 Returns the 'region' that this decision belongs to. 'Regions' 2800 are level-1 zones, but when a decision is in multiple level-1 2801 zones, its region counts as the smallest of those zones in terms 2802 of total decisions contained, breaking ties by the one with the 2803 alphabetically earlier name. 2804 2805 Always returns a single zone name string, unless the target 2806 decision is not in any level-1 zones, in which case it returns 2807 `None`. 2808 2809 If `useLevel` is specified, then zones of the specified level 2810 will be used instead of level-1 zones. 2811 2812 Example: 2813 2814 >>> g = DecisionGraph() 2815 >>> g.addDecision('A') 2816 0 2817 >>> g.addDecision('B') 2818 1 2819 >>> g.addDecision('C') 2820 2 2821 >>> g.addDecision('D') 2822 3 2823 >>> g.createZone('zoneX', 0) 2824 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2825 annotations=[]) 2826 >>> g.createZone('regionA', 1) 2827 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2828 annotations=[]) 2829 >>> g.createZone('zoneY', 0) 2830 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2831 annotations=[]) 2832 >>> g.createZone('regionB', 1) 2833 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2834 annotations=[]) 2835 >>> g.createZone('regionC', 1) 2836 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2837 annotations=[]) 2838 >>> g.createZone('quadrant', 2) 2839 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2840 annotations=[]) 2841 >>> g.addDecisionToZone('A', 'zoneX') 2842 >>> g.addDecisionToZone('B', 'zoneY') 2843 >>> # C is not in any level-1 zones 2844 >>> g.addDecisionToZone('D', 'zoneX') 2845 >>> g.addDecisionToZone('D', 'zoneY') # D is in both 2846 >>> g.addZoneToZone('zoneX', 'regionA') 2847 >>> g.addZoneToZone('zoneY', 'regionB') 2848 >>> g.addZoneToZone('zoneX', 'regionC') # includes both 2849 >>> g.addZoneToZone('zoneY', 'regionC') 2850 >>> g.addZoneToZone('regionA', 'quadrant') 2851 >>> g.addZoneToZone('regionB', 'quadrant') 2852 >>> g.addDecisionToZone('C', 'regionC') # Direct in level-2 2853 >>> sorted(g.allDecisionsInZone('zoneX')) 2854 [0, 3] 2855 >>> sorted(g.allDecisionsInZone('zoneY')) 2856 [1, 3] 2857 >>> sorted(g.allDecisionsInZone('regionA')) 2858 [0, 3] 2859 >>> sorted(g.allDecisionsInZone('regionB')) 2860 [1, 3] 2861 >>> sorted(g.allDecisionsInZone('regionC')) 2862 [0, 1, 2, 3] 2863 >>> sorted(g.allDecisionsInZone('quadrant')) 2864 [0, 1, 3] 2865 >>> g.region(0) # for A; region A is smaller than region C 2866 'regionA' 2867 >>> g.region(1) # for B; region B is also smaller than C 2868 'regionB' 2869 >>> g.region(2) # for C 2870 'regionC' 2871 >>> g.region(3) # for D; tie broken alphabetically 2872 'regionA' 2873 >>> g.region(0, useLevel=0) # for A at level 0 2874 'zoneX' 2875 >>> g.region(1, useLevel=0) # for B at level 0 2876 'zoneY' 2877 >>> g.region(2, useLevel=0) is None # for C at level 0 (none) 2878 True 2879 >>> g.region(3, useLevel=0) # for D at level 0; tie 2880 'zoneX' 2881 >>> g.region(0, useLevel=2) # for A at level 2 2882 'quadrant' 2883 >>> g.region(1, useLevel=2) # for B at level 2 2884 'quadrant' 2885 >>> g.region(2, useLevel=2) is None # for C at level 2 (none) 2886 True 2887 >>> g.region(3, useLevel=2) # for D at level 2 2888 'quadrant' 2889 """ 2890 relevant = self.zoneAncestors(decision, atLevel=useLevel) 2891 if len(relevant) == 0: 2892 return None 2893 elif len(relevant) == 1: 2894 for zone in relevant: 2895 return zone 2896 return None # not really necessary but keeps mypy happy 2897 else: 2898 # more than one zone ancestor at the relevant hierarchy 2899 # level: need to measure their sizes 2900 minSize = None 2901 candidates = [] 2902 for zone in relevant: 2903 size = len(self.allDecisionsInZone(zone)) 2904 if minSize is None or size < minSize: 2905 candidates = [zone] 2906 minSize = size 2907 elif size == minSize: 2908 candidates.append(zone) 2909 return min(candidates) 2910 2911 def zoneEdges(self, zone: base.Zone) -> Optional[ 2912 Tuple[ 2913 Set[Tuple[base.DecisionID, base.Transition]], 2914 Set[Tuple[base.DecisionID, base.Transition]] 2915 ] 2916 ]: 2917 """ 2918 Given a zone to look at, finds all of the transitions which go 2919 out of and into that zone, ignoring internal transitions between 2920 decisions in the zone. This includes all decisions in sub-zones. 2921 The return value is a pair of sets for outgoing and then 2922 incoming transitions, where each transition is specified as a 2923 (sourceID, transitionName) pair. 2924 2925 Returns `None` if the target zone isn't yet fully defined. 2926 2927 Note that this takes time proportional to *all* edges plus *all* 2928 nodes in the graph no matter how large or small the zone in 2929 question is. 2930 2931 >>> g = DecisionGraph() 2932 >>> g.addDecision('A') 2933 0 2934 >>> g.addDecision('B') 2935 1 2936 >>> g.addDecision('C') 2937 2 2938 >>> g.addDecision('D') 2939 3 2940 >>> g.addTransition('A', 'up', 'B', 'down') 2941 >>> g.addTransition('B', 'right', 'C', 'left') 2942 >>> g.addTransition('C', 'down', 'D', 'up') 2943 >>> g.addTransition('D', 'left', 'A', 'right') 2944 >>> g.addTransition('A', 'tunnel', 'C', 'tunnel') 2945 >>> g.createZone('Z', 0) 2946 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2947 annotations=[]) 2948 >>> g.createZone('ZZ', 1) 2949 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2950 annotations=[]) 2951 >>> g.addZoneToZone('Z', 'ZZ') 2952 >>> g.addDecisionToZone('A', 'Z') 2953 >>> g.addDecisionToZone('B', 'Z') 2954 >>> g.addDecisionToZone('D', 'ZZ') 2955 >>> outgoing, incoming = g.zoneEdges('Z') # TODO: Sort for testing 2956 >>> sorted(outgoing) 2957 [(0, 'right'), (0, 'tunnel'), (1, 'right')] 2958 >>> sorted(incoming) 2959 [(2, 'left'), (2, 'tunnel'), (3, 'left')] 2960 >>> outgoing, incoming = g.zoneEdges('ZZ') 2961 >>> sorted(outgoing) 2962 [(0, 'tunnel'), (1, 'right'), (3, 'up')] 2963 >>> sorted(incoming) 2964 [(2, 'down'), (2, 'left'), (2, 'tunnel')] 2965 >>> g.zoneEdges('madeup') is None 2966 True 2967 """ 2968 # Find the interior nodes 2969 try: 2970 interior = self.allDecisionsInZone(zone) 2971 except MissingZoneError: 2972 return None 2973 2974 # Set up our result 2975 results: Tuple[ 2976 Set[Tuple[base.DecisionID, base.Transition]], 2977 Set[Tuple[base.DecisionID, base.Transition]] 2978 ] = (set(), set()) 2979 2980 # Because finding incoming edges requires searching the entire 2981 # graph anyways, it's more efficient to just consider each edge 2982 # once. 2983 for fromDecision in self: 2984 fromThere = self[fromDecision] 2985 for toDecision in fromThere: 2986 for transition in fromThere[toDecision]: 2987 sourceIn = fromDecision in interior 2988 destIn = toDecision in interior 2989 if sourceIn and not destIn: 2990 results[0].add((fromDecision, transition)) 2991 elif destIn and not sourceIn: 2992 results[1].add((fromDecision, transition)) 2993 2994 return results 2995 2996 def replaceZonesInHierarchy( 2997 self, 2998 target: base.AnyDecisionSpecifier, 2999 zone: base.Zone, 3000 level: int 3001 ) -> None: 3002 """ 3003 This method replaces one or more zones which contain the 3004 specified `target` decision with a specific zone, at a specific 3005 level in the zone hierarchy (see `zoneHierarchyLevel`). If the 3006 named zone doesn't yet exist, it will be created. 3007 3008 To do this, it looks at all zones which contain the target 3009 decision directly or indirectly (see `zoneAncestors`) and which 3010 are at the specified level. 3011 3012 - Any direct children of those zones which are ancestors of the 3013 target decision are removed from those zones and placed into 3014 the new zone instead, regardless of their levels. Indirect 3015 children are not affected (except perhaps indirectly via 3016 their parents' ancestors changing). 3017 - The new zone is placed into every direct parent of those 3018 zones, regardless of their levels (those parents are by 3019 definition all ancestors of the target decision). 3020 - If there were no zones at the target level, every zone at the 3021 next level down which is an ancestor of the target decision 3022 (or just that decision if the level is 0) is placed into the 3023 new zone as a direct child (and is removed from any previous 3024 parents it had). In this case, the new zone will also be 3025 added as a sub-zone to every ancestor of the target decision 3026 at the level above the specified level, if there are any. 3027 * In this case, if there are no zones at the level below the 3028 specified level, the highest level of zones smaller than 3029 that is treated as the level below, down to targeting 3030 the decision itself. 3031 * Similarly, if there are no zones at the level above the 3032 specified level but there are zones at a higher level, 3033 the new zone will be added to each of the zones in the 3034 lowest level above the target level that has zones in it. 3035 3036 A `MissingDecisionError` will be raised if the specified 3037 decision is not valid, or if the decision is left as default but 3038 there is no current decision in the exploration. 3039 3040 An `InvalidLevelError` will be raised if the level is less than 3041 zero. 3042 3043 Example: 3044 3045 >>> g = DecisionGraph() 3046 >>> g.addDecision('decision') 3047 0 3048 >>> g.addDecision('alternate') 3049 1 3050 >>> g.createZone('zone0', 0) 3051 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 3052 annotations=[]) 3053 >>> g.createZone('zone1', 1) 3054 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 3055 annotations=[]) 3056 >>> g.createZone('zone2.1', 2) 3057 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 3058 annotations=[]) 3059 >>> g.createZone('zone2.2', 2) 3060 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 3061 annotations=[]) 3062 >>> g.createZone('zone3', 3) 3063 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 3064 annotations=[]) 3065 >>> g.addDecisionToZone('decision', 'zone0') 3066 >>> g.addDecisionToZone('alternate', 'zone0') 3067 >>> g.addZoneToZone('zone0', 'zone1') 3068 >>> g.addZoneToZone('zone1', 'zone2.1') 3069 >>> g.addZoneToZone('zone1', 'zone2.2') 3070 >>> g.addZoneToZone('zone2.1', 'zone3') 3071 >>> g.addZoneToZone('zone2.2', 'zone3') 3072 >>> g.zoneHierarchyLevel('zone0') 3073 0 3074 >>> g.zoneHierarchyLevel('zone1') 3075 1 3076 >>> g.zoneHierarchyLevel('zone2.1') 3077 2 3078 >>> g.zoneHierarchyLevel('zone2.2') 3079 2 3080 >>> g.zoneHierarchyLevel('zone3') 3081 3 3082 >>> sorted(g.decisionsInZone('zone0')) 3083 [0, 1] 3084 >>> sorted(g.zoneAncestors('zone0')) 3085 ['zone1', 'zone2.1', 'zone2.2', 'zone3'] 3086 >>> g.subZones('zone1') 3087 {'zone0'} 3088 >>> g.zoneParents('zone0') 3089 {'zone1'} 3090 >>> g.replaceZonesInHierarchy('decision', 'new0', 0) 3091 >>> g.zoneParents('zone0') 3092 {'zone1'} 3093 >>> g.zoneParents('new0') 3094 {'zone1'} 3095 >>> sorted(g.zoneAncestors('zone0')) 3096 ['zone1', 'zone2.1', 'zone2.2', 'zone3'] 3097 >>> sorted(g.zoneAncestors('new0')) 3098 ['zone1', 'zone2.1', 'zone2.2', 'zone3'] 3099 >>> g.decisionsInZone('zone0') 3100 {1} 3101 >>> g.decisionsInZone('new0') 3102 {0} 3103 >>> sorted(g.subZones('zone1')) 3104 ['new0', 'zone0'] 3105 >>> g.zoneParents('new0') 3106 {'zone1'} 3107 >>> g.replaceZonesInHierarchy('decision', 'new1', 1) 3108 >>> sorted(g.zoneAncestors(0)) 3109 ['new0', 'new1', 'zone2.1', 'zone2.2', 'zone3'] 3110 >>> g.subZones('zone1') 3111 {'zone0'} 3112 >>> g.subZones('new1') 3113 {'new0'} 3114 >>> g.zoneParents('new0') 3115 {'new1'} 3116 >>> sorted(g.zoneParents('zone1')) 3117 ['zone2.1', 'zone2.2'] 3118 >>> sorted(g.zoneParents('new1')) 3119 ['zone2.1', 'zone2.2'] 3120 >>> g.zoneParents('zone2.1') 3121 {'zone3'} 3122 >>> g.zoneParents('zone2.2') 3123 {'zone3'} 3124 >>> sorted(g.subZones('zone2.1')) 3125 ['new1', 'zone1'] 3126 >>> sorted(g.subZones('zone2.2')) 3127 ['new1', 'zone1'] 3128 >>> sorted(g.allDecisionsInZone('zone2.1')) 3129 [0, 1] 3130 >>> sorted(g.allDecisionsInZone('zone2.2')) 3131 [0, 1] 3132 >>> g.replaceZonesInHierarchy('decision', 'new2', 2) 3133 >>> g.zoneParents('zone2.1') 3134 {'zone3'} 3135 >>> g.zoneParents('zone2.2') 3136 {'zone3'} 3137 >>> g.subZones('zone2.1') 3138 {'zone1'} 3139 >>> g.subZones('zone2.2') 3140 {'zone1'} 3141 >>> g.subZones('new2') 3142 {'new1'} 3143 >>> g.zoneParents('new2') 3144 {'zone3'} 3145 >>> g.allDecisionsInZone('zone2.1') 3146 {1} 3147 >>> g.allDecisionsInZone('zone2.2') 3148 {1} 3149 >>> g.allDecisionsInZone('new2') 3150 {0} 3151 >>> sorted(g.subZones('zone3')) 3152 ['new2', 'zone2.1', 'zone2.2'] 3153 >>> g.zoneParents('zone3') 3154 set() 3155 >>> sorted(g.allDecisionsInZone('zone3')) 3156 [0, 1] 3157 >>> g.replaceZonesInHierarchy('decision', 'new3', 3) 3158 >>> sorted(g.subZones('zone3')) 3159 ['zone2.1', 'zone2.2'] 3160 >>> g.subZones('new3') 3161 {'new2'} 3162 >>> g.zoneParents('zone3') 3163 set() 3164 >>> g.zoneParents('new3') 3165 set() 3166 >>> g.allDecisionsInZone('zone3') 3167 {1} 3168 >>> g.allDecisionsInZone('new3') 3169 {0} 3170 >>> g.replaceZonesInHierarchy('decision', 'new4', 5) 3171 >>> g.subZones('new4') 3172 {'new3'} 3173 >>> g.zoneHierarchyLevel('new4') 3174 5 3175 3176 Another example of level collapse when trying to replace a zone 3177 at a level above : 3178 3179 >>> g = DecisionGraph() 3180 >>> g.addDecision('A') 3181 0 3182 >>> g.addDecision('B') 3183 1 3184 >>> g.createZone('level0', 0) 3185 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 3186 annotations=[]) 3187 >>> g.createZone('level1', 1) 3188 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 3189 annotations=[]) 3190 >>> g.createZone('level2', 2) 3191 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 3192 annotations=[]) 3193 >>> g.createZone('level3', 3) 3194 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 3195 annotations=[]) 3196 >>> g.addDecisionToZone('B', 'level0') 3197 >>> g.addZoneToZone('level0', 'level1') 3198 >>> g.addZoneToZone('level1', 'level2') 3199 >>> g.addZoneToZone('level2', 'level3') 3200 >>> g.addDecisionToZone('A', 'level3') # missing some zone levels 3201 >>> g.zoneHierarchyLevel('level3') 3202 3 3203 >>> g.replaceZonesInHierarchy('A', 'newFirst', 1) 3204 >>> g.zoneHierarchyLevel('newFirst') 3205 1 3206 >>> g.decisionsInZone('newFirst') 3207 {0} 3208 >>> g.decisionsInZone('level3') 3209 set() 3210 >>> sorted(g.allDecisionsInZone('level3')) 3211 [0, 1] 3212 >>> g.subZones('newFirst') 3213 set() 3214 >>> sorted(g.subZones('level3')) 3215 ['level2', 'newFirst'] 3216 >>> g.zoneParents('newFirst') 3217 {'level3'} 3218 >>> g.replaceZonesInHierarchy('A', 'newSecond', 2) 3219 >>> g.zoneHierarchyLevel('newSecond') 3220 2 3221 >>> g.decisionsInZone('newSecond') 3222 set() 3223 >>> g.allDecisionsInZone('newSecond') 3224 {0} 3225 >>> g.subZones('newSecond') 3226 {'newFirst'} 3227 >>> g.zoneParents('newSecond') 3228 {'level3'} 3229 >>> g.zoneParents('newFirst') 3230 {'newSecond'} 3231 >>> sorted(g.subZones('level3')) 3232 ['level2', 'newSecond'] 3233 """ 3234 tID = self.resolveDecision(target) 3235 3236 if level < 0: 3237 raise InvalidLevelError( 3238 f"Target level must be positive (got {level})." 3239 ) 3240 3241 info = self.getZoneInfo(zone) 3242 if info is None: 3243 info = self.createZone(zone, level) 3244 elif level != info.level: 3245 raise InvalidLevelError( 3246 f"Target level ({level}) does not match the level of" 3247 f" the target zone ({zone!r} at level {info.level})." 3248 ) 3249 3250 # Collect both parents & ancestors 3251 parents = self.zoneParents(tID) 3252 ancestors = set(self.zoneAncestors(tID)) 3253 3254 # Map from levels to sets of zones from the ancestors pool 3255 levelMap: Dict[int, Set[base.Zone]] = {} 3256 highest = -1 3257 for ancestor in ancestors: 3258 ancestorLevel = self.zoneHierarchyLevel(ancestor) 3259 levelMap.setdefault(ancestorLevel, set()).add(ancestor) 3260 if ancestorLevel > highest: 3261 highest = ancestorLevel 3262 3263 # Figure out if we have target zones to replace or not 3264 reparentDecision = False 3265 if level in levelMap: 3266 # If there are zones at the target level, 3267 targetZones = levelMap[level] 3268 3269 above = set() 3270 below = set() 3271 3272 for replaced in targetZones: 3273 above |= self.zoneParents(replaced) 3274 below |= self.subZones(replaced) 3275 if replaced in parents: 3276 reparentDecision = True 3277 3278 # Only ancestors should be reparented 3279 below &= ancestors 3280 3281 else: 3282 # Find levels w/ zones in them above + below 3283 levelBelow = level - 1 3284 levelAbove = level + 1 3285 below = levelMap.get(levelBelow, set()) 3286 above = levelMap.get(levelAbove, set()) 3287 3288 while len(below) == 0 and levelBelow > 0: 3289 levelBelow -= 1 3290 below = levelMap.get(levelBelow, set()) 3291 3292 if len(below) == 0: 3293 reparentDecision = True 3294 3295 while len(above) == 0 and levelAbove < highest: 3296 levelAbove += 1 3297 above = levelMap.get(levelAbove, set()) 3298 3299 # Handle re-parenting zones below 3300 for under in below: 3301 for parent in self.zoneParents(under): 3302 if parent in ancestors: 3303 self.removeZoneFromZone(under, parent) 3304 self.addZoneToZone(under, zone) 3305 3306 # Add this zone to each parent 3307 for parent in above: 3308 self.addZoneToZone(zone, parent) 3309 3310 # Re-parent the decision itself if necessary 3311 if reparentDecision: 3312 # (using set() here to avoid size-change-during-iteration) 3313 for parent in set(parents): 3314 self.removeDecisionFromZone(tID, parent) 3315 self.addDecisionToZone(tID, zone) 3316 3317 def getReciprocal( 3318 self, 3319 decision: base.AnyDecisionSpecifier, 3320 transition: base.Transition 3321 ) -> Optional[base.Transition]: 3322 """ 3323 Returns the reciprocal edge for the specified transition from the 3324 specified decision (see `setReciprocal`). Returns 3325 `None` if no reciprocal has been established for that 3326 transition, or if that decision or transition does not exist. 3327 """ 3328 dID = self.resolveDecision(decision) 3329 3330 dest = self.getDestination(dID, transition) 3331 if dest is not None: 3332 info = cast( 3333 TransitionProperties, 3334 self.edges[dID, dest, transition] # type:ignore 3335 ) 3336 recip = info.get("reciprocal") 3337 if recip is not None and not isinstance(recip, base.Transition): 3338 raise ValueError(f"Invalid reciprocal value: {repr(recip)}") 3339 return recip 3340 else: 3341 return None 3342 3343 def setReciprocal( 3344 self, 3345 decision: base.AnyDecisionSpecifier, 3346 transition: base.Transition, 3347 reciprocal: Optional[base.Transition], 3348 setBoth: bool = True, 3349 cleanup: bool = True 3350 ) -> None: 3351 """ 3352 Sets the 'reciprocal' transition for a particular transition from 3353 a particular decision, and removes the reciprocal property from 3354 any old reciprocal transition. 3355 3356 Raises a `MissingDecisionError` or a `MissingTransitionError` if 3357 the specified decision or transition does not exist. 3358 3359 Raises an `InvalidDestinationError` if the reciprocal transition 3360 does not exist, or if it does exist but does not lead back to 3361 the decision the transition came from. 3362 3363 If `setBoth` is True (the default) then the transition which is 3364 being identified as a reciprocal will also have its reciprocal 3365 property set, pointing back to the primary transition being 3366 modified, and any old reciprocal of that transition will have its 3367 reciprocal set to None. If you want to create a situation with 3368 non-exclusive reciprocals, use `setBoth=False`. 3369 3370 If `cleanup` is True (the default) then abandoned reciprocal 3371 transitions (for both edges if `setBoth` was true) have their 3372 reciprocal properties removed. Set `cleanup` to false if you want 3373 to retain them, although this will result in non-exclusive 3374 reciprocal relationships. 3375 3376 If the `reciprocal` value is None, this deletes the reciprocal 3377 value entirely, and if `setBoth` is true, it does this for the 3378 previous reciprocal edge as well. No error is raised in this case 3379 when there was not already a reciprocal to delete. 3380 3381 Note that one should remove a reciprocal relationship before 3382 redirecting either edge of the pair in a way that gives it a new 3383 reciprocal, since otherwise, a later attempt to remove the 3384 reciprocal with `setBoth` set to True (the default) will end up 3385 deleting the reciprocal information from the other edge that was 3386 already modified. There is no way to reliably detect and avoid 3387 this, because two different decisions could (and often do in 3388 practice) have transitions with identical names, meaning that the 3389 reciprocal value will still be the same, but it will indicate a 3390 different edge in virtue of the destination of the edge changing. 3391 3392 ## Example 3393 3394 >>> g = DecisionGraph() 3395 >>> g.addDecision('G') 3396 0 3397 >>> g.addDecision('H') 3398 1 3399 >>> g.addDecision('I') 3400 2 3401 >>> g.addTransition('G', 'up', 'H', 'down') 3402 >>> g.addTransition('G', 'next', 'H', 'prev') 3403 >>> g.addTransition('H', 'next', 'I', 'prev') 3404 >>> g.addTransition('H', 'return', 'G') 3405 >>> g.setReciprocal('G', 'up', 'next') # Error w/ destinations 3406 Traceback (most recent call last): 3407 ... 3408 exploration.core.InvalidDestinationError... 3409 >>> g.setReciprocal('G', 'up', 'none') # Doesn't exist 3410 Traceback (most recent call last): 3411 ... 3412 exploration.core.MissingTransitionError... 3413 >>> g.getReciprocal('G', 'up') 3414 'down' 3415 >>> g.getReciprocal('H', 'down') 3416 'up' 3417 >>> g.getReciprocal('H', 'return') is None 3418 True 3419 >>> g.setReciprocal('G', 'up', 'return') 3420 >>> g.getReciprocal('G', 'up') 3421 'return' 3422 >>> g.getReciprocal('H', 'down') is None 3423 True 3424 >>> g.getReciprocal('H', 'return') 3425 'up' 3426 >>> g.setReciprocal('H', 'return', None) # remove the reciprocal 3427 >>> g.getReciprocal('G', 'up') is None 3428 True 3429 >>> g.getReciprocal('H', 'down') is None 3430 True 3431 >>> g.getReciprocal('H', 'return') is None 3432 True 3433 >>> g.setReciprocal('G', 'up', 'down', setBoth=False) # one-way 3434 >>> g.getReciprocal('G', 'up') 3435 'down' 3436 >>> g.getReciprocal('H', 'down') is None 3437 True 3438 >>> g.getReciprocal('H', 'return') is None 3439 True 3440 >>> g.setReciprocal('H', 'return', 'up', setBoth=False) # non-sym 3441 >>> g.getReciprocal('G', 'up') 3442 'down' 3443 >>> g.getReciprocal('H', 'down') is None 3444 True 3445 >>> g.getReciprocal('H', 'return') 3446 'up' 3447 >>> g.setReciprocal('H', 'down', 'up') # setBoth not needed 3448 >>> g.getReciprocal('G', 'up') 3449 'down' 3450 >>> g.getReciprocal('H', 'down') 3451 'up' 3452 >>> g.getReciprocal('H', 'return') # unchanged 3453 'up' 3454 >>> g.setReciprocal('G', 'up', 'return', cleanup=False) # no cleanup 3455 >>> g.getReciprocal('G', 'up') 3456 'return' 3457 >>> g.getReciprocal('H', 'down') 3458 'up' 3459 >>> g.getReciprocal('H', 'return') # unchanged 3460 'up' 3461 >>> # Cleanup only applies to reciprocal if setBoth is true 3462 >>> g.setReciprocal('H', 'down', 'up', setBoth=False) 3463 >>> g.getReciprocal('G', 'up') 3464 'return' 3465 >>> g.getReciprocal('H', 'down') 3466 'up' 3467 >>> g.getReciprocal('H', 'return') # not cleaned up w/out setBoth 3468 'up' 3469 >>> g.setReciprocal('H', 'down', 'up') # with cleanup and setBoth 3470 >>> g.getReciprocal('G', 'up') 3471 'down' 3472 >>> g.getReciprocal('H', 'down') 3473 'up' 3474 >>> g.getReciprocal('H', 'return') is None # cleaned up 3475 True 3476 """ 3477 dID = self.resolveDecision(decision) 3478 3479 dest = self.destination(dID, transition) # possible KeyError 3480 if reciprocal is None: 3481 rDest = None 3482 else: 3483 rDest = self.getDestination(dest, reciprocal) 3484 3485 # Set or delete reciprocal property 3486 if reciprocal is None: 3487 # Delete the property 3488 info = self.edges[dID, dest, transition] # type:ignore 3489 3490 old = info.pop('reciprocal') 3491 if setBoth: 3492 rDest = self.getDestination(dest, old) 3493 if rDest != dID: 3494 raise RuntimeError( 3495 f"Invalid reciprocal {old!r} for transition" 3496 f" {transition!r} from {self.identityOf(dID)}:" 3497 f" destination is {rDest}." 3498 ) 3499 rInfo = self.edges[dest, dID, old] # type:ignore 3500 if 'reciprocal' in rInfo: 3501 del rInfo['reciprocal'] 3502 else: 3503 # Set the property, checking for errors first 3504 if rDest is None: 3505 raise MissingTransitionError( 3506 f"Reciprocal transition {reciprocal!r} for" 3507 f" transition {transition!r} from decision" 3508 f" {self.identityOf(dID)} does not exist at" 3509 f" decision {self.identityOf(dest)}" 3510 ) 3511 3512 if rDest != dID: 3513 raise InvalidDestinationError( 3514 f"Reciprocal transition {reciprocal!r} from" 3515 f" decision {self.identityOf(dest)} does not lead" 3516 f" back to decision {self.identityOf(dID)}." 3517 ) 3518 3519 eProps = self.edges[dID, dest, transition] # type:ignore [index] 3520 abandoned = eProps.get('reciprocal') 3521 eProps['reciprocal'] = reciprocal 3522 if cleanup and abandoned not in (None, reciprocal): 3523 aProps = self.edges[dest, dID, abandoned] # type:ignore 3524 if 'reciprocal' in aProps: 3525 del aProps['reciprocal'] 3526 3527 if setBoth: 3528 rProps = self.edges[dest, dID, reciprocal] # type:ignore 3529 revAbandoned = rProps.get('reciprocal') 3530 rProps['reciprocal'] = transition 3531 # Sever old reciprocal relationship 3532 if cleanup and revAbandoned not in (None, transition): 3533 raProps = self.edges[ 3534 dID, # type:ignore 3535 dest, 3536 revAbandoned 3537 ] 3538 del raProps['reciprocal'] 3539 3540 def getReciprocalPair( 3541 self, 3542 decision: base.AnyDecisionSpecifier, 3543 transition: base.Transition 3544 ) -> Optional[Tuple[base.DecisionID, base.Transition]]: 3545 """ 3546 Returns a tuple containing both the destination decision ID and 3547 the transition at that decision which is the reciprocal of the 3548 specified destination & transition. Returns `None` if no 3549 reciprocal has been established for that transition, or if that 3550 decision or transition does not exist. 3551 3552 >>> g = DecisionGraph() 3553 >>> g.addDecision('A') 3554 0 3555 >>> g.addDecision('B') 3556 1 3557 >>> g.addDecision('C') 3558 2 3559 >>> g.addTransition('A', 'up', 'B', 'down') 3560 >>> g.addTransition('B', 'right', 'C', 'left') 3561 >>> g.addTransition('A', 'oneway', 'C') 3562 >>> g.getReciprocalPair('A', 'up') 3563 (1, 'down') 3564 >>> g.getReciprocalPair('B', 'down') 3565 (0, 'up') 3566 >>> g.getReciprocalPair('B', 'right') 3567 (2, 'left') 3568 >>> g.getReciprocalPair('C', 'left') 3569 (1, 'right') 3570 >>> g.getReciprocalPair('C', 'up') is None 3571 True 3572 >>> g.getReciprocalPair('Q', 'up') is None 3573 True 3574 >>> g.getReciprocalPair('A', 'tunnel') is None 3575 True 3576 """ 3577 try: 3578 dID = self.resolveDecision(decision) 3579 except MissingDecisionError: 3580 return None 3581 3582 reciprocal = self.getReciprocal(dID, transition) 3583 if reciprocal is None: 3584 return None 3585 else: 3586 destination = self.getDestination(dID, transition) 3587 if destination is None: 3588 return None 3589 else: 3590 return (destination, reciprocal) 3591 3592 def addDecision( 3593 self, 3594 name: base.DecisionName, 3595 domain: Optional[base.Domain] = None, 3596 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 3597 annotations: Optional[List[base.Annotation]] = None 3598 ) -> base.DecisionID: 3599 """ 3600 Adds a decision to the graph, without any transitions yet. Each 3601 decision will be assigned an ID so name collisions are allowed, 3602 but it's usually best to keep names unique at least within each 3603 zone. If no domain is provided, the `DEFAULT_DOMAIN` will be 3604 used for the decision's domain. A dictionary of tags and/or a 3605 list of annotations (strings in both cases) may be provided. 3606 3607 Returns the newly-assigned `DecisionID` for the decision it 3608 created. 3609 3610 Emits a `DecisionCollisionWarning` if a decision with the 3611 provided name already exists and the `WARN_OF_NAME_COLLISIONS` 3612 global variable is set to `True`. 3613 """ 3614 # Defaults 3615 if domain is None: 3616 domain = base.DEFAULT_DOMAIN 3617 if tags is None: 3618 tags = {} 3619 if annotations is None: 3620 annotations = [] 3621 3622 # Error checking 3623 if name in self.nameLookup and WARN_OF_NAME_COLLISIONS: 3624 warnings.warn( 3625 ( 3626 f"Adding decision {name!r}: Another decision with" 3627 f" that name already exists." 3628 ), 3629 DecisionCollisionWarning 3630 ) 3631 3632 dID = self._assignID() 3633 3634 # Add the decision 3635 self.add_node( 3636 dID, 3637 name=name, 3638 domain=domain, 3639 tags=tags, 3640 annotations=annotations 3641 ) 3642 #TODO: Elide tags/annotations if they're empty? 3643 3644 # Track it in our `nameLookup` dictionary 3645 self.nameLookup.setdefault(name, []).append(dID) 3646 3647 return dID 3648 3649 def addIdentifiedDecision( 3650 self, 3651 dID: base.DecisionID, 3652 name: base.DecisionName, 3653 domain: Optional[base.Domain] = None, 3654 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 3655 annotations: Optional[List[base.Annotation]] = None 3656 ) -> None: 3657 """ 3658 Adds a new decision to the graph using a specific decision ID, 3659 rather than automatically assigning a new decision ID like 3660 `addDecision` does. Otherwise works like `addDecision`. 3661 3662 Raises a `MechanismCollisionError` if the specified decision ID 3663 is already in use. 3664 """ 3665 # Defaults 3666 if domain is None: 3667 domain = base.DEFAULT_DOMAIN 3668 if tags is None: 3669 tags = {} 3670 if annotations is None: 3671 annotations = [] 3672 3673 # Error checking 3674 if dID in self.nodes: 3675 raise MechanismCollisionError( 3676 f"Cannot add a node with id {dID} and name {name!r}:" 3677 f" that ID is already used by node {self.identityOf(dID)}" 3678 ) 3679 3680 if name in self.nameLookup and WARN_OF_NAME_COLLISIONS: 3681 warnings.warn( 3682 ( 3683 f"Adding decision {name!r}: Another decision with" 3684 f" that name already exists." 3685 ), 3686 DecisionCollisionWarning 3687 ) 3688 3689 # Add the decision 3690 self.add_node( 3691 dID, 3692 name=name, 3693 domain=domain, 3694 tags=tags, 3695 annotations=annotations 3696 ) 3697 #TODO: Elide tags/annotations if they're empty? 3698 3699 # Track it in our `nameLookup` dictionary 3700 self.nameLookup.setdefault(name, []).append(dID) 3701 3702 def addTransition( 3703 self, 3704 fromDecision: base.AnyDecisionSpecifier, 3705 name: base.Transition, 3706 toDecision: base.AnyDecisionSpecifier, 3707 reciprocal: Optional[base.Transition] = None, 3708 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 3709 annotations: Optional[List[base.Annotation]] = None, 3710 revTags: Optional[Dict[base.Tag, base.TagValue]] = None, 3711 revAnnotations: Optional[List[base.Annotation]] = None, 3712 requires: Optional[base.Requirement] = None, 3713 consequence: Optional[base.Consequence] = None, 3714 revRequires: Optional[base.Requirement] = None, 3715 revConsequece: Optional[base.Consequence] = None 3716 ) -> None: 3717 """ 3718 Adds a transition connecting two decisions. A specifier for each 3719 decision is required, as is a name for the transition. If a 3720 `reciprocal` is provided, a reciprocal edge will be added in the 3721 opposite direction using that name; by default only the specified 3722 edge is added. A `TransitionCollisionError` will be raised if the 3723 `reciprocal` matches the name of an existing edge at the 3724 destination decision. 3725 3726 Both decisions must already exist, or a `MissingDecisionError` 3727 will be raised. 3728 3729 A dictionary of tags and/or a list of annotations may be 3730 provided. Tags and/or annotations for the reverse edge may also 3731 be specified if one is being added. 3732 3733 The `requires`, `consequence`, `revRequires`, and `revConsequece` 3734 arguments specify requirements and/or consequences of the new 3735 outgoing and reciprocal edges. 3736 """ 3737 # Defaults 3738 if tags is None: 3739 tags = {} 3740 if annotations is None: 3741 annotations = [] 3742 if revTags is None: 3743 revTags = {} 3744 if revAnnotations is None: 3745 revAnnotations = [] 3746 3747 # Error checking 3748 fromID = self.resolveDecision(fromDecision) 3749 toID = self.resolveDecision(toDecision) 3750 3751 # Note: have to check this first so we don't add the forward edge 3752 # and then error out after a side effect! 3753 if ( 3754 reciprocal is not None 3755 and self.getDestination(toDecision, reciprocal) is not None 3756 ): 3757 raise TransitionCollisionError( 3758 f"Cannot add a transition from" 3759 f" {self.identityOf(fromDecision)} to" 3760 f" {self.identityOf(toDecision)} with reciprocal edge" 3761 f" {reciprocal!r}: {reciprocal!r} is already used as an" 3762 f" edge name at {self.identityOf(toDecision)}." 3763 ) 3764 3765 # Add the edge 3766 self.add_edge( 3767 fromID, 3768 toID, 3769 key=name, 3770 tags=tags, 3771 annotations=annotations 3772 ) 3773 self.setTransitionRequirement(fromDecision, name, requires) 3774 if consequence is not None: 3775 self.setConsequence(fromDecision, name, consequence) 3776 if reciprocal is not None: 3777 # Add the reciprocal edge 3778 self.add_edge( 3779 toID, 3780 fromID, 3781 key=reciprocal, 3782 tags=revTags, 3783 annotations=revAnnotations 3784 ) 3785 self.setReciprocal(fromID, name, reciprocal) 3786 self.setTransitionRequirement( 3787 toDecision, 3788 reciprocal, 3789 revRequires 3790 ) 3791 if revConsequece is not None: 3792 self.setConsequence(toDecision, reciprocal, revConsequece) 3793 3794 def removeTransition( 3795 self, 3796 fromDecision: base.AnyDecisionSpecifier, 3797 transition: base.Transition, 3798 removeReciprocal=False 3799 ) -> Union[ 3800 TransitionProperties, 3801 Tuple[TransitionProperties, TransitionProperties] 3802 ]: 3803 """ 3804 Removes a transition. If `removeReciprocal` is true (False is the 3805 default) any reciprocal transition will also be removed (but no 3806 error will occur if there wasn't a reciprocal). 3807 3808 For each removed transition, *every* transition that targeted 3809 that transition as its reciprocal will have its reciprocal set to 3810 `None`, to avoid leaving any invalid reciprocal values. 3811 3812 Raises a `KeyError` if either the target decision or the target 3813 transition does not exist. 3814 3815 Returns a transition properties dictionary with the properties 3816 of the removed transition, or if `removeReciprocal` is true, 3817 returns a pair of such dictionaries for the target transition 3818 and its reciprocal. 3819 3820 ## Example 3821 3822 >>> g = DecisionGraph() 3823 >>> g.addDecision('A') 3824 0 3825 >>> g.addDecision('B') 3826 1 3827 >>> g.addTransition('A', 'up', 'B', 'down', tags={'wide'}) 3828 >>> g.addTransition('A', 'in', 'B', 'out') # we won't touch this 3829 >>> g.addTransition('A', 'next', 'B') 3830 >>> g.setReciprocal('A', 'next', 'down', setBoth=False) 3831 >>> p = g.removeTransition('A', 'up') 3832 >>> p['tags'] 3833 {'wide'} 3834 >>> g.destinationsFrom('A') 3835 {'in': 1, 'next': 1} 3836 >>> g.destinationsFrom('B') 3837 {'down': 0, 'out': 0} 3838 >>> g.getReciprocal('B', 'down') is None 3839 True 3840 >>> g.getReciprocal('A', 'next') # Asymmetrical left over 3841 'down' 3842 >>> g.getReciprocal('A', 'in') # not affected 3843 'out' 3844 >>> g.getReciprocal('B', 'out') # not affected 3845 'in' 3846 >>> # Now with removeReciprocal set to True 3847 >>> g.addTransition('A', 'up', 'B') # add this back in 3848 >>> g.setReciprocal('A', 'up', 'down') # sets both 3849 >>> p = g.removeTransition('A', 'up', removeReciprocal=True) 3850 >>> g.destinationsFrom('A') 3851 {'in': 1, 'next': 1} 3852 >>> g.destinationsFrom('B') 3853 {'out': 0} 3854 >>> g.getReciprocal('A', 'next') is None 3855 True 3856 >>> g.getReciprocal('A', 'in') # not affected 3857 'out' 3858 >>> g.getReciprocal('B', 'out') # not affected 3859 'in' 3860 >>> g.removeTransition('A', 'none') 3861 Traceback (most recent call last): 3862 ... 3863 exploration.core.MissingTransitionError... 3864 >>> g.removeTransition('Z', 'nope') 3865 Traceback (most recent call last): 3866 ... 3867 exploration.core.MissingDecisionError... 3868 """ 3869 # Resolve target ID 3870 fromID = self.resolveDecision(fromDecision) 3871 3872 # raises if either is missing: 3873 destination = self.destination(fromID, transition) 3874 reciprocal = self.getReciprocal(fromID, transition) 3875 3876 # Get dictionaries of parallel & antiparallel edges to be 3877 # checked for invalid reciprocals after removing edges 3878 # Note: these will update live as we remove edges 3879 allAntiparallel = self[destination][fromID] 3880 allParallel = self[fromID][destination] 3881 3882 # Remove the target edge 3883 fProps = self.getTransitionProperties(fromID, transition) 3884 self.remove_edge(fromID, destination, transition) 3885 3886 # Clean up any dangling reciprocal values 3887 for tProps in allAntiparallel.values(): 3888 if tProps.get('reciprocal') == transition: 3889 del tProps['reciprocal'] 3890 3891 # Remove the reciprocal if requested 3892 if removeReciprocal and reciprocal is not None: 3893 rProps = self.getTransitionProperties(destination, reciprocal) 3894 self.remove_edge(destination, fromID, reciprocal) 3895 3896 # Clean up any dangling reciprocal values 3897 for tProps in allParallel.values(): 3898 if tProps.get('reciprocal') == reciprocal: 3899 del tProps['reciprocal'] 3900 3901 return (fProps, rProps) 3902 else: 3903 return fProps 3904 3905 def addMechanism( 3906 self, 3907 name: base.MechanismName, 3908 where: Optional[base.AnyDecisionSpecifier] = None 3909 ) -> base.MechanismID: 3910 """ 3911 Creates a new mechanism with the given name at the specified 3912 decision, returning its assigned ID. If `where` is `None`, it 3913 creates a global mechanism. Raises a `MechanismCollisionError` 3914 if a mechanism with the same name already exists at a specified 3915 decision (or already exists as a global mechanism). 3916 3917 Note that if the decision is deleted, the mechanism will be as 3918 well. 3919 3920 Since `MechanismState`s are not tracked by `DecisionGraph`s but 3921 instead are part of a `State`, the mechanism won't be in any 3922 particular state, which means it will be treated as being in the 3923 `base.DEFAULT_MECHANISM_STATE`. 3924 """ 3925 if where is None: 3926 mechs = self.globalMechanisms 3927 dID = None 3928 else: 3929 dID = self.resolveDecision(where) 3930 mechs = self.nodes[dID].setdefault('mechanisms', {}) 3931 3932 if name in mechs: 3933 if dID is None: 3934 raise MechanismCollisionError( 3935 f"A global mechanism named {name!r} already exists." 3936 ) 3937 else: 3938 raise MechanismCollisionError( 3939 f"A mechanism named {name!r} already exists at" 3940 f" decision {self.identityOf(dID)}." 3941 ) 3942 3943 mID = self._assignMechanismID() 3944 mechs[name] = mID 3945 self.mechanisms[mID] = (dID, name) 3946 return mID 3947 3948 def mechanismsAt( 3949 self, 3950 decision: base.AnyDecisionSpecifier 3951 ) -> Dict[base.MechanismName, base.MechanismID]: 3952 """ 3953 Returns a dictionary mapping mechanism names to their IDs for 3954 all mechanisms at the specified decision. 3955 """ 3956 dID = self.resolveDecision(decision) 3957 3958 return self.nodes[dID]['mechanisms'] 3959 3960 def mechanismDetails( 3961 self, 3962 mID: base.MechanismID 3963 ) -> Optional[Tuple[Optional[base.DecisionID], base.MechanismName]]: 3964 """ 3965 Returns a tuple containing the decision ID and mechanism name 3966 for the specified mechanism. Returns `None` if there is no 3967 mechanism with that ID. For global mechanisms, `None` is used in 3968 place of a decision ID. 3969 """ 3970 return self.mechanisms.get(mID) 3971 3972 def deleteMechanism(self, mID: base.MechanismID) -> None: 3973 """ 3974 Deletes the specified mechanism. 3975 """ 3976 name, dID = self.mechanisms.pop(mID) 3977 3978 del self.nodes[dID]['mechanisms'][name] 3979 3980 def localLookup( 3981 self, 3982 startFrom: Union[ 3983 base.AnyDecisionSpecifier, 3984 Collection[base.AnyDecisionSpecifier] 3985 ], 3986 findAmong: Callable[ 3987 ['DecisionGraph', Union[Set[base.DecisionID], str]], 3988 Optional[LookupResult] 3989 ], 3990 fallbackLayerName: Optional[str] = "fallback", 3991 fallbackToAllDecisions: bool = True 3992 ) -> Optional[LookupResult]: 3993 """ 3994 Looks up some kind of result in the graph by starting from a 3995 base set of decisions and widening the search iteratively based 3996 on zones. This first searches for result(s) in the set of 3997 decisions given, then in the set of all decisions which are in 3998 level-0 zones containing those decisions, then in level-1 zones, 3999 etc. When it runs out of relevant zones, it will check all 4000 decisions which are in any domain that a decision from the 4001 initial search set is in, and then if `fallbackLayerName` is a 4002 string, it will provide that string instead of a set of decision 4003 IDs to the `findAmong` function as the next layer to search. 4004 After the `fallbackLayerName` is used, if 4005 `fallbackToAllDecisions` is `True` (the default) a final search 4006 will be run on all decisions in the graph. The provided 4007 `findAmong` function is called on each successive decision ID 4008 set, until it generates a non-`None` result. We stop and return 4009 that non-`None` result as soon as one is generated. But if none 4010 of the decision sets consulted generate non-`None` results, then 4011 the entire result will be `None`. 4012 """ 4013 # Normalize starting decisions to a set 4014 if isinstance(startFrom, (int, str, base.DecisionSpecifier)): 4015 startFrom = set([startFrom]) 4016 4017 # Resolve decision IDs; convert to list 4018 searchArea: Union[Set[base.DecisionID], str] = set( 4019 self.resolveDecision(spec) for spec in startFrom 4020 ) 4021 4022 # Find all ancestor zones & all relevant domains 4023 allAncestors = set() 4024 relevantDomains = set() 4025 for startingDecision in searchArea: 4026 allAncestors |= self.zoneAncestors(startingDecision) 4027 relevantDomains.add(self.domainFor(startingDecision)) 4028 4029 # Build layers dictionary 4030 ancestorLayers: Dict[int, Set[base.Zone]] = {} 4031 for zone in allAncestors: 4032 info = self.getZoneInfo(zone) 4033 assert info is not None 4034 level = info.level 4035 ancestorLayers.setdefault(level, set()).add(zone) 4036 4037 searchLayers: LookupLayersList = ( 4038 cast(LookupLayersList, [None]) 4039 + cast(LookupLayersList, sorted(ancestorLayers.keys())) 4040 + cast(LookupLayersList, ["domains"]) 4041 ) 4042 if fallbackLayerName is not None: 4043 searchLayers.append("fallback") 4044 4045 if fallbackToAllDecisions: 4046 searchLayers.append("all") 4047 4048 # Continue our search through zone layers 4049 for layer in searchLayers: 4050 # Update search area on subsequent iterations 4051 if layer == "domains": 4052 searchArea = set() 4053 for relevant in relevantDomains: 4054 searchArea |= self.allDecisionsInDomain(relevant) 4055 elif layer == "fallback": 4056 assert fallbackLayerName is not None 4057 searchArea = fallbackLayerName 4058 elif layer == "all": 4059 searchArea = set(self.nodes) 4060 elif layer is not None: 4061 layer = cast(int, layer) # must be an integer 4062 searchZones = ancestorLayers[layer] 4063 searchArea = set() 4064 for zone in searchZones: 4065 searchArea |= self.allDecisionsInZone(zone) 4066 # else it's the first iteration and we use the starting 4067 # searchArea 4068 4069 searchResult: Optional[LookupResult] = findAmong( 4070 self, 4071 searchArea 4072 ) 4073 4074 if searchResult is not None: 4075 return searchResult 4076 4077 # Didn't find any non-None results. 4078 return None 4079 4080 @staticmethod 4081 def uniqueMechanismFinder(name: base.MechanismName) -> Callable[ 4082 ['DecisionGraph', Union[Set[base.DecisionID], str]], 4083 Optional[base.MechanismID] 4084 ]: 4085 """ 4086 Returns a search function that looks for the given mechanism ID, 4087 suitable for use with `localLookup`. The finder will raise a 4088 `MechanismCollisionError` if it finds more than one mechanism 4089 with the specified name at the same level of the search. 4090 """ 4091 def namedMechanismFinder( 4092 graph: 'DecisionGraph', 4093 searchIn: Union[Set[base.DecisionID], str] 4094 ) -> Optional[base.MechanismID]: 4095 """ 4096 Generated finder function for `localLookup` to find a unique 4097 mechanism by name. 4098 """ 4099 candidates: List[base.DecisionID] = [] 4100 4101 if searchIn == "fallback": 4102 if name in graph.globalMechanisms: 4103 candidates = [graph.globalMechanisms[name]] 4104 4105 else: 4106 assert isinstance(searchIn, set) 4107 for dID in searchIn: 4108 mechs = graph.nodes[dID].get('mechanisms', {}) 4109 if name in mechs: 4110 candidates.append(mechs[name]) 4111 4112 if len(candidates) > 1: 4113 raise MechanismCollisionError( 4114 f"There are {len(candidates)} mechanisms named {name!r}" 4115 f" in the search area ({len(searchIn)} decisions(s))." 4116 ) 4117 elif len(candidates) == 1: 4118 return candidates[0] 4119 else: 4120 return None 4121 4122 return namedMechanismFinder 4123 4124 def lookupMechanism( 4125 self, 4126 startFrom: Union[ 4127 base.AnyDecisionSpecifier, 4128 Collection[base.AnyDecisionSpecifier] 4129 ], 4130 name: base.MechanismName 4131 ) -> base.MechanismID: 4132 """ 4133 Looks up the mechanism with the given name 'closest' to the 4134 given decision or set of decisions. First it looks for a 4135 mechanism with that name that's at one of those decisions. Then 4136 it starts looking in level-0 zones which contain any of them, 4137 then in level-1 zones, and so on. If it finds two mechanisms 4138 with the target name during the same search pass, it raises a 4139 `MechanismCollisionError`, but if it finds one it returns it. 4140 Raises a `MissingMechanismError` if there is no mechanisms with 4141 that name among global mechanisms (searched after the last 4142 applicable level of zones) or anywhere in the graph (which is the 4143 final level of search after checking global mechanisms). 4144 4145 For example: 4146 4147 >>> d = DecisionGraph() 4148 >>> d.addDecision('A') 4149 0 4150 >>> d.addDecision('B') 4151 1 4152 >>> d.addDecision('C') 4153 2 4154 >>> d.addDecision('D') 4155 3 4156 >>> d.addDecision('E') 4157 4 4158 >>> d.addMechanism('switch', 'A') 4159 0 4160 >>> d.addMechanism('switch', 'B') 4161 1 4162 >>> d.addMechanism('switch', 'C') 4163 2 4164 >>> d.addMechanism('lever', 'D') 4165 3 4166 >>> d.addMechanism('lever', None) # global 4167 4 4168 >>> d.createZone('Z1', 0) 4169 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 4170 annotations=[]) 4171 >>> d.createZone('Z2', 0) 4172 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 4173 annotations=[]) 4174 >>> d.createZone('Zup', 1) 4175 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 4176 annotations=[]) 4177 >>> d.addDecisionToZone('A', 'Z1') 4178 >>> d.addDecisionToZone('B', 'Z1') 4179 >>> d.addDecisionToZone('C', 'Z2') 4180 >>> d.addDecisionToZone('D', 'Z2') 4181 >>> d.addDecisionToZone('E', 'Z1') 4182 >>> d.addZoneToZone('Z1', 'Zup') 4183 >>> d.addZoneToZone('Z2', 'Zup') 4184 >>> d.lookupMechanism(set(), 'switch') # 3x among all decisions 4185 Traceback (most recent call last): 4186 ... 4187 exploration.core.MechanismCollisionError... 4188 >>> d.lookupMechanism(set(), 'lever') # 1x global > 1x all 4189 4 4190 >>> d.lookupMechanism({'D'}, 'lever') # local 4191 3 4192 >>> d.lookupMechanism({'A'}, 'lever') # found at D via Zup 4193 3 4194 >>> d.lookupMechanism({'A', 'D'}, 'lever') # local again 4195 3 4196 >>> d.lookupMechanism({'A'}, 'switch') # local 4197 0 4198 >>> d.lookupMechanism({'B'}, 'switch') # local 4199 1 4200 >>> d.lookupMechanism({'C'}, 'switch') # local 4201 2 4202 >>> d.lookupMechanism({'A', 'B'}, 'switch') # ambiguous 4203 Traceback (most recent call last): 4204 ... 4205 exploration.core.MechanismCollisionError... 4206 >>> d.lookupMechanism({'A', 'B', 'C'}, 'switch') # ambiguous 4207 Traceback (most recent call last): 4208 ... 4209 exploration.core.MechanismCollisionError... 4210 >>> d.lookupMechanism({'B', 'D'}, 'switch') # not ambiguous 4211 1 4212 >>> d.lookupMechanism({'E', 'D'}, 'switch') # ambiguous at L0 zone 4213 Traceback (most recent call last): 4214 ... 4215 exploration.core.MechanismCollisionError... 4216 >>> d.lookupMechanism({'E'}, 'switch') # ambiguous at L0 zone 4217 Traceback (most recent call last): 4218 ... 4219 exploration.core.MechanismCollisionError... 4220 >>> d.lookupMechanism({'D'}, 'switch') # found at L0 zone 4221 2 4222 """ 4223 result = self.localLookup( 4224 startFrom, 4225 DecisionGraph.uniqueMechanismFinder(name) 4226 ) 4227 if result is None: 4228 raise MissingMechanismError( 4229 f"No mechanism named {name!r}" 4230 ) 4231 else: 4232 return result 4233 4234 def resolveMechanism( 4235 self, 4236 specifier: base.AnyMechanismSpecifier, 4237 startFrom: Union[ 4238 None, 4239 base.AnyDecisionSpecifier, 4240 Collection[base.AnyDecisionSpecifier] 4241 ] = None 4242 ) -> base.MechanismID: 4243 """ 4244 Works like `lookupMechanism`, except it accepts a 4245 `base.AnyMechanismSpecifier` which may have position information 4246 baked in, and so the `startFrom` information is optional. If 4247 position information isn't specified in the mechanism specifier 4248 and startFrom is not provided, the mechanism is searched for at 4249 the global scope and then in the entire graph. On the other 4250 hand, if the specifier includes any position information, the 4251 startFrom value provided here will be ignored. 4252 """ 4253 if isinstance(specifier, base.MechanismID): 4254 return specifier 4255 4256 elif isinstance(specifier, base.MechanismName): 4257 if startFrom is None: 4258 startFrom = set() 4259 return self.lookupMechanism(startFrom, specifier) 4260 4261 elif isinstance(specifier, tuple) and len(specifier) == 4: 4262 domain, zone, decision, mechanism = specifier 4263 if domain is None and zone is None and decision is None: 4264 if startFrom is None: 4265 startFrom = set() 4266 return self.lookupMechanism(startFrom, mechanism) 4267 4268 elif decision is not None: 4269 startFrom = { 4270 self.resolveDecision( 4271 base.DecisionSpecifier(domain, zone, decision) 4272 ) 4273 } 4274 return self.lookupMechanism(startFrom, mechanism) 4275 4276 else: # decision is None but domain and/or zone aren't 4277 startFrom = set() 4278 if zone is not None: 4279 baseStart = self.allDecisionsInZone(zone) 4280 else: 4281 baseStart = set(self) 4282 4283 if domain is None: 4284 startFrom = baseStart 4285 else: 4286 for dID in baseStart: 4287 if self.domainFor(dID) == domain: 4288 startFrom.add(dID) 4289 return self.lookupMechanism(startFrom, mechanism) 4290 4291 else: 4292 raise TypeError( 4293 f"Invalid mechanism specifier: {repr(specifier)}" 4294 f"\n(Must be a mechanism ID, mechanism name, or" 4295 f" mechanism specifier tuple)" 4296 ) 4297 4298 def walkConsequenceMechanisms( 4299 self, 4300 consequence: base.Consequence, 4301 searchFrom: Set[base.DecisionID] 4302 ) -> Generator[base.MechanismID, None, None]: 4303 """ 4304 Yields each requirement in the given `base.Consequence`, 4305 including those in `base.Condition`s, `base.ConditionalSkill`s 4306 within `base.Challenge`s, and those set or toggled by 4307 `base.Effect`s. The `searchFrom` argument specifies where to 4308 start searching for mechanisms, since requirements include them 4309 by name, not by ID. 4310 """ 4311 for part in base.walkParts(consequence): 4312 if isinstance(part, dict): 4313 if 'skills' in part: # a Challenge 4314 for cSkill in part['skills'].walk(): 4315 if isinstance(cSkill, base.ConditionalSkill): 4316 yield from self.walkRequirementMechanisms( 4317 cSkill.requirement, 4318 searchFrom 4319 ) 4320 elif 'condition' in part: # a Condition 4321 yield from self.walkRequirementMechanisms( 4322 part['condition'], 4323 searchFrom 4324 ) 4325 elif 'value' in part: # an Effect 4326 val = part['value'] 4327 if part['type'] == 'set': 4328 if ( 4329 isinstance(val, tuple) 4330 and len(val) == 2 4331 and isinstance(val[1], base.State) 4332 ): 4333 yield from self.walkRequirementMechanisms( 4334 base.ReqMechanism(val[0], val[1]), 4335 searchFrom 4336 ) 4337 elif part['type'] == 'toggle': 4338 if isinstance(val, tuple): 4339 assert len(val) == 2 4340 yield from self.walkRequirementMechanisms( 4341 base.ReqMechanism(val[0], '_'), 4342 # state part is ignored here 4343 searchFrom 4344 ) 4345 4346 def walkRequirementMechanisms( 4347 self, 4348 req: base.Requirement, 4349 searchFrom: Set[base.DecisionID] 4350 ) -> Generator[base.MechanismID, None, None]: 4351 """ 4352 Given a requirement, yields any mechanisms mentioned in that 4353 requirement, in depth-first traversal order. 4354 """ 4355 for part in req.walk(): 4356 if isinstance(part, base.ReqMechanism): 4357 mech = part.mechanism 4358 yield self.resolveMechanism( 4359 mech, 4360 startFrom=searchFrom 4361 ) 4362 4363 def addUnexploredEdge( 4364 self, 4365 fromDecision: base.AnyDecisionSpecifier, 4366 name: base.Transition, 4367 destinationName: Optional[base.DecisionName] = None, 4368 reciprocal: Optional[base.Transition] = 'return', 4369 toDomain: Optional[base.Domain] = None, 4370 placeInZone: Optional[base.Zone] = None, 4371 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 4372 annotations: Optional[List[base.Annotation]] = None, 4373 revTags: Optional[Dict[base.Tag, base.TagValue]] = None, 4374 revAnnotations: Optional[List[base.Annotation]] = None, 4375 requires: Optional[base.Requirement] = None, 4376 consequence: Optional[base.Consequence] = None, 4377 revRequires: Optional[base.Requirement] = None, 4378 revConsequece: Optional[base.Consequence] = None 4379 ) -> base.DecisionID: 4380 """ 4381 Adds a transition connecting to a new decision named `'_u.-n-'` 4382 where '-n-' is the number of unknown decisions (named or not) 4383 that have ever been created in this graph (or using the 4384 specified destination name if one is provided). This represents 4385 a transition to an unknown destination. The destination node 4386 gets tagged 'unconfirmed'. 4387 4388 This also adds a reciprocal transition in the reverse direction, 4389 unless `reciprocal` is set to `None`. The reciprocal will use 4390 the provided name (default is 'return'). The new decision will 4391 be in the same domain as the decision it's connected to, unless 4392 `toDecision` is specified, in which case it will be in that 4393 domain. 4394 4395 The new decision will not be placed into any zones, unless 4396 `placeInZone` is specified, in which case it will be placed into 4397 that zone. If that zone needs to be created, it will be created 4398 at level 0; in that case that zone will be added to any 4399 grandparent zones of the decision we're branching off of. If 4400 `placeInZone` is set to `base.DefaultZone`, then the new 4401 decision will be placed into each parent zone of the decision 4402 we're branching off of, as long as the new decision is in the 4403 same domain as the decision we're branching from (otherwise only 4404 an explicit `placeInZone` would apply). 4405 4406 The ID of the decision that was created is returned. 4407 4408 A `MissingDecisionError` will be raised if the starting decision 4409 does not exist, a `TransitionCollisionError` will be raised if 4410 it exists but already has a transition with the given name, and a 4411 `DecisionCollisionWarning` will be issued if a decision with the 4412 specified destination name already exists (won't happen when 4413 using an automatic name). 4414 4415 Lists of tags and/or annotations (strings in both cases) may be 4416 provided. These may also be provided for the reciprocal edge. 4417 4418 Similarly, requirements and/or consequences for either edge may 4419 be provided. 4420 4421 ## Example 4422 4423 >>> g = DecisionGraph() 4424 >>> g.addDecision('A') 4425 0 4426 >>> g.addUnexploredEdge('A', 'up') 4427 1 4428 >>> g.nameFor(1) 4429 '_u.0' 4430 >>> g.decisionTags(1) 4431 {'unconfirmed': 1} 4432 >>> g.addUnexploredEdge('A', 'right', 'B') 4433 2 4434 >>> g.nameFor(2) 4435 'B' 4436 >>> g.decisionTags(2) 4437 {'unconfirmed': 1} 4438 >>> g.addUnexploredEdge('A', 'down', None, 'up') 4439 3 4440 >>> g.nameFor(3) 4441 '_u.2' 4442 >>> g.addUnexploredEdge( 4443 ... '_u.0', 4444 ... 'beyond', 4445 ... toDomain='otherDomain', 4446 ... tags={'fast':1}, 4447 ... revTags={'slow':1}, 4448 ... annotations=['comment'], 4449 ... revAnnotations=['one', 'two'], 4450 ... requires=base.ReqCapability('dash'), 4451 ... revRequires=base.ReqCapability('super dash'), 4452 ... consequence=[base.effect(gain='super dash')], 4453 ... revConsequece=[base.effect(lose='super dash')] 4454 ... ) 4455 4 4456 >>> g.nameFor(4) 4457 '_u.3' 4458 >>> g.domainFor(4) 4459 'otherDomain' 4460 >>> g.transitionTags('_u.0', 'beyond') 4461 {'fast': 1} 4462 >>> g.transitionAnnotations('_u.0', 'beyond') 4463 ['comment'] 4464 >>> g.getTransitionRequirement('_u.0', 'beyond') 4465 ReqCapability('dash') 4466 >>> e = g.getConsequence('_u.0', 'beyond') 4467 >>> e == [base.effect(gain='super dash')] 4468 True 4469 >>> g.transitionTags('_u.3', 'return') 4470 {'slow': 1} 4471 >>> g.transitionAnnotations('_u.3', 'return') 4472 ['one', 'two'] 4473 >>> g.getTransitionRequirement('_u.3', 'return') 4474 ReqCapability('super dash') 4475 >>> e = g.getConsequence('_u.3', 'return') 4476 >>> e == [base.effect(lose='super dash')] 4477 True 4478 """ 4479 # Defaults 4480 if tags is None: 4481 tags = {} 4482 if annotations is None: 4483 annotations = [] 4484 if revTags is None: 4485 revTags = {} 4486 if revAnnotations is None: 4487 revAnnotations = [] 4488 4489 # Resolve ID 4490 fromID = self.resolveDecision(fromDecision) 4491 if toDomain is None: 4492 toDomain = self.domainFor(fromID) 4493 4494 if name in self.destinationsFrom(fromID): 4495 raise TransitionCollisionError( 4496 f"Cannot add a new edge {name!r}:" 4497 f" {self.identityOf(fromDecision)} already has an" 4498 f" outgoing edge with that name." 4499 ) 4500 4501 if destinationName in self.nameLookup and WARN_OF_NAME_COLLISIONS: 4502 warnings.warn( 4503 ( 4504 f"Cannot add a new unexplored node" 4505 f" {destinationName!r}: A decision with that name" 4506 f" already exists.\n(Leave destinationName as None" 4507 f" to use an automatic name.)" 4508 ), 4509 DecisionCollisionWarning 4510 ) 4511 4512 # Create the new unexplored decision and add the edge 4513 if destinationName is None: 4514 toName = '_u.' + str(self.unknownCount) 4515 else: 4516 toName = destinationName 4517 self.unknownCount += 1 4518 newID = self.addDecision(toName, domain=toDomain) 4519 self.addTransition( 4520 fromID, 4521 name, 4522 newID, 4523 tags=tags, 4524 annotations=annotations 4525 ) 4526 self.setTransitionRequirement(fromID, name, requires) 4527 if consequence is not None: 4528 self.setConsequence(fromID, name, consequence) 4529 4530 # Add it to a zone if requested 4531 if ( 4532 placeInZone == base.DefaultZone 4533 and toDomain == self.domainFor(fromID) 4534 ): 4535 # Add to each parent of the from decision 4536 for parent in self.zoneParents(fromID): 4537 self.addDecisionToZone(newID, parent) 4538 elif placeInZone is not None: 4539 # Otherwise add it to one specific zone, creating that zone 4540 # at level 0 if necessary 4541 assert isinstance(placeInZone, base.Zone) 4542 if self.getZoneInfo(placeInZone) is None: 4543 self.createZone(placeInZone, 0) 4544 # Add new zone to each grandparent of the from decision 4545 for parent in self.zoneParents(fromID): 4546 for grandparent in self.zoneParents(parent): 4547 self.addZoneToZone(placeInZone, grandparent) 4548 self.addDecisionToZone(newID, placeInZone) 4549 4550 # Create the reciprocal edge 4551 if reciprocal is not None: 4552 self.addTransition( 4553 newID, 4554 reciprocal, 4555 fromID, 4556 tags=revTags, 4557 annotations=revAnnotations 4558 ) 4559 self.setTransitionRequirement(newID, reciprocal, revRequires) 4560 if revConsequece is not None: 4561 self.setConsequence(newID, reciprocal, revConsequece) 4562 # Set as a reciprocal 4563 self.setReciprocal(fromID, name, reciprocal) 4564 4565 # Tag the destination as 'unconfirmed' 4566 self.tagDecision(newID, 'unconfirmed') 4567 4568 # Return ID of new destination 4569 return newID 4570 4571 def retargetTransition( 4572 self, 4573 fromDecision: base.AnyDecisionSpecifier, 4574 transition: base.Transition, 4575 newDestination: base.AnyDecisionSpecifier, 4576 swapReciprocal=True, 4577 errorOnNameColision=True 4578 ) -> Optional[base.Transition]: 4579 """ 4580 Given a particular decision and a transition at that decision, 4581 changes that transition so that it goes to the specified new 4582 destination instead of wherever it was connected to before. If 4583 the new destination is the same as the old one, no changes are 4584 made. 4585 4586 If `swapReciprocal` is set to True (the default) then any 4587 reciprocal edge at the old destination will be deleted, and a 4588 new reciprocal edge from the new destination with equivalent 4589 properties to the original reciprocal will be created, pointing 4590 to the origin of the specified transition. If `swapReciprocal` 4591 is set to False, then the reciprocal relationship with any old 4592 reciprocal edge will be removed, but the old reciprocal edge 4593 will not be changed. 4594 4595 Note that if `errorOnNameColision` is True (the default), then 4596 if the reciprocal transition has the same name as a transition 4597 which already exists at the new destination node, a 4598 `TransitionCollisionError` will be thrown. However, if it is set 4599 to False, the reciprocal transition will be renamed with a suffix 4600 to avoid any possible name collisions. Either way, the name of 4601 the reciprocal transition (possibly just changed) will be 4602 returned, or None if there was no reciprocal transition. 4603 4604 ## Example 4605 4606 >>> g = DecisionGraph() 4607 >>> for fr, to, nm in [ 4608 ... ('A', 'B', 'up'), 4609 ... ('A', 'B', 'up2'), 4610 ... ('B', 'A', 'down'), 4611 ... ('B', 'B', 'self'), 4612 ... ('B', 'C', 'next'), 4613 ... ('C', 'B', 'prev') 4614 ... ]: 4615 ... if g.getDecision(fr) is None: 4616 ... g.addDecision(fr) 4617 ... if g.getDecision(to) is None: 4618 ... g.addDecision(to) 4619 ... g.addTransition(fr, nm, to) 4620 0 4621 1 4622 2 4623 >>> g.setReciprocal('A', 'up', 'down') 4624 >>> g.setReciprocal('B', 'next', 'prev') 4625 >>> g.destination('A', 'up') 4626 1 4627 >>> g.destination('B', 'down') 4628 0 4629 >>> g.retargetTransition('A', 'up', 'C') 4630 'down' 4631 >>> g.destination('A', 'up') 4632 2 4633 >>> g.getDestination('B', 'down') is None 4634 True 4635 >>> g.destination('C', 'down') 4636 0 4637 >>> g.addTransition('A', 'next', 'B') 4638 >>> g.addTransition('B', 'prev', 'A') 4639 >>> g.setReciprocal('A', 'next', 'prev') 4640 >>> # Can't swap a reciprocal in a way that would collide names 4641 >>> g.getReciprocal('C', 'prev') 4642 'next' 4643 >>> g.retargetTransition('C', 'prev', 'A') 4644 Traceback (most recent call last): 4645 ... 4646 exploration.core.TransitionCollisionError... 4647 >>> g.retargetTransition('C', 'prev', 'A', swapReciprocal=False) 4648 'next' 4649 >>> g.destination('C', 'prev') 4650 0 4651 >>> g.destination('A', 'next') # not changed 4652 1 4653 >>> # Reciprocal relationship is severed: 4654 >>> g.getReciprocal('C', 'prev') is None 4655 True 4656 >>> g.getReciprocal('B', 'next') is None 4657 True 4658 >>> # Swap back so we can do another demo 4659 >>> g.retargetTransition('C', 'prev', 'B', swapReciprocal=False) 4660 >>> # Note return value was None here because there was no reciprocal 4661 >>> g.setReciprocal('C', 'prev', 'next') 4662 >>> # Swap reciprocal by renaming it 4663 >>> g.retargetTransition('C', 'prev', 'A', errorOnNameColision=False) 4664 'next.1' 4665 >>> g.getReciprocal('C', 'prev') 4666 'next.1' 4667 >>> g.destination('C', 'prev') 4668 0 4669 >>> g.destination('A', 'next.1') 4670 2 4671 >>> g.destination('A', 'next') 4672 1 4673 >>> # Note names are the same but these are from different nodes 4674 >>> g.getReciprocal('A', 'next') 4675 'prev' 4676 >>> g.getReciprocal('A', 'next.1') 4677 'prev' 4678 """ 4679 fromID = self.resolveDecision(fromDecision) 4680 newDestID = self.resolveDecision(newDestination) 4681 4682 # Figure out the old destination of the transition we're swapping 4683 oldDestID = self.destination(fromID, transition) 4684 reciprocal = self.getReciprocal(fromID, transition) 4685 4686 # If thew new destination is the same, we don't do anything! 4687 if oldDestID == newDestID: 4688 return reciprocal 4689 4690 # First figure out reciprocal business so we can error out 4691 # without making changes if we need to 4692 if swapReciprocal and reciprocal is not None: 4693 reciprocal = self.rebaseTransition( 4694 oldDestID, 4695 reciprocal, 4696 newDestID, 4697 swapReciprocal=False, 4698 errorOnNameColision=errorOnNameColision 4699 ) 4700 4701 # Handle the forward transition... 4702 # Find the transition properties 4703 tProps = self.getTransitionProperties(fromID, transition) 4704 4705 # Delete the edge 4706 self.removeEdgeByKey(fromID, transition) 4707 4708 # Add the new edge 4709 self.addTransition(fromID, transition, newDestID) 4710 4711 # Reapply the transition properties 4712 self.setTransitionProperties(fromID, transition, **tProps) 4713 4714 # Handle the reciprocal transition if there is one... 4715 if reciprocal is not None: 4716 if not swapReciprocal: 4717 # Then sever the relationship, but only if that edge 4718 # still exists (we might be in the middle of a rebase) 4719 check = self.getDestination(oldDestID, reciprocal) 4720 if check is not None: 4721 self.setReciprocal( 4722 oldDestID, 4723 reciprocal, 4724 None, 4725 setBoth=False # Other transition was deleted already 4726 ) 4727 else: 4728 # Establish new reciprocal relationship 4729 self.setReciprocal( 4730 fromID, 4731 transition, 4732 reciprocal 4733 ) 4734 4735 return reciprocal 4736 4737 def rebaseTransition( 4738 self, 4739 fromDecision: base.AnyDecisionSpecifier, 4740 transition: base.Transition, 4741 newBase: base.AnyDecisionSpecifier, 4742 swapReciprocal=True, 4743 errorOnNameColision=True 4744 ) -> base.Transition: 4745 """ 4746 Given a particular destination and a transition at that 4747 destination, changes that transition's origin to a new base 4748 decision. If the new source is the same as the old one, no 4749 changes are made. 4750 4751 If `swapReciprocal` is set to True (the default) then any 4752 reciprocal edge at the destination will be retargeted to point 4753 to the new source so that it can remain a reciprocal. If 4754 `swapReciprocal` is set to False, then the reciprocal 4755 relationship with any old reciprocal edge will be removed, but 4756 the old reciprocal edge will not be otherwise changed. 4757 4758 Note that if `errorOnNameColision` is True (the default), then 4759 if the transition has the same name as a transition which 4760 already exists at the new source node, a 4761 `TransitionCollisionError` will be raised. However, if it is set 4762 to False, the transition will be renamed with a suffix to avoid 4763 any possible name collisions. Either way, the (possibly new) name 4764 of the transition that was rebased will be returned. 4765 4766 ## Example 4767 4768 >>> g = DecisionGraph() 4769 >>> for fr, to, nm in [ 4770 ... ('A', 'B', 'up'), 4771 ... ('A', 'B', 'up2'), 4772 ... ('B', 'A', 'down'), 4773 ... ('B', 'B', 'self'), 4774 ... ('B', 'C', 'next'), 4775 ... ('C', 'B', 'prev') 4776 ... ]: 4777 ... if g.getDecision(fr) is None: 4778 ... g.addDecision(fr) 4779 ... if g.getDecision(to) is None: 4780 ... g.addDecision(to) 4781 ... g.addTransition(fr, nm, to) 4782 0 4783 1 4784 2 4785 >>> g.setReciprocal('A', 'up', 'down') 4786 >>> g.setReciprocal('B', 'next', 'prev') 4787 >>> g.destination('A', 'up') 4788 1 4789 >>> g.destination('B', 'down') 4790 0 4791 >>> g.rebaseTransition('B', 'down', 'C') 4792 'down' 4793 >>> g.destination('A', 'up') 4794 2 4795 >>> g.getDestination('B', 'down') is None 4796 True 4797 >>> g.destination('C', 'down') 4798 0 4799 >>> g.addTransition('A', 'next', 'B') 4800 >>> g.addTransition('B', 'prev', 'A') 4801 >>> g.setReciprocal('A', 'next', 'prev') 4802 >>> # Can't rebase in a way that would collide names 4803 >>> g.rebaseTransition('B', 'next', 'A') 4804 Traceback (most recent call last): 4805 ... 4806 exploration.core.TransitionCollisionError... 4807 >>> g.rebaseTransition('B', 'next', 'A', errorOnNameColision=False) 4808 'next.1' 4809 >>> g.destination('C', 'prev') 4810 0 4811 >>> g.destination('A', 'next') # not changed 4812 1 4813 >>> # Collision is avoided by renaming 4814 >>> g.destination('A', 'next.1') 4815 2 4816 >>> # Swap without reciprocal 4817 >>> g.getReciprocal('A', 'next.1') 4818 'prev' 4819 >>> g.getReciprocal('C', 'prev') 4820 'next.1' 4821 >>> g.rebaseTransition('A', 'next.1', 'B', swapReciprocal=False) 4822 'next.1' 4823 >>> g.getReciprocal('C', 'prev') is None 4824 True 4825 >>> g.destination('C', 'prev') 4826 0 4827 >>> g.getDestination('A', 'next.1') is None 4828 True 4829 >>> g.destination('A', 'next') 4830 1 4831 >>> g.destination('B', 'next.1') 4832 2 4833 >>> g.getReciprocal('B', 'next.1') is None 4834 True 4835 >>> # Rebase in a way that creates a self-edge 4836 >>> g.rebaseTransition('A', 'next', 'B') 4837 'next' 4838 >>> g.getDestination('A', 'next') is None 4839 True 4840 >>> g.destination('B', 'next') 4841 1 4842 >>> g.destination('B', 'prev') # swapped as a reciprocal 4843 1 4844 >>> g.getReciprocal('B', 'next') # still reciprocals 4845 'prev' 4846 >>> g.getReciprocal('B', 'prev') 4847 'next' 4848 >>> # And rebasing of a self-edge also works 4849 >>> g.rebaseTransition('B', 'prev', 'A') 4850 'prev' 4851 >>> g.destination('A', 'prev') 4852 1 4853 >>> g.destination('B', 'next') 4854 0 4855 >>> g.getReciprocal('B', 'next') # still reciprocals 4856 'prev' 4857 >>> g.getReciprocal('A', 'prev') 4858 'next' 4859 >>> # We've effectively reversed this edge/reciprocal pair 4860 >>> # by rebasing twice 4861 """ 4862 fromID = self.resolveDecision(fromDecision) 4863 newBaseID = self.resolveDecision(newBase) 4864 4865 # If thew new base is the same, we don't do anything! 4866 if newBaseID == fromID: 4867 return transition 4868 4869 # First figure out reciprocal business so we can swap it later 4870 # without making changes if we need to 4871 destination = self.destination(fromID, transition) 4872 reciprocal = self.getReciprocal(fromID, transition) 4873 # Check for an already-deleted reciprocal 4874 if ( 4875 reciprocal is not None 4876 and self.getDestination(destination, reciprocal) is None 4877 ): 4878 reciprocal = None 4879 4880 # Handle the base swap... 4881 # Find the transition properties 4882 tProps = self.getTransitionProperties(fromID, transition) 4883 4884 # Check for a collision 4885 targetDestinations = self.destinationsFrom(newBaseID) 4886 if transition in targetDestinations: 4887 if errorOnNameColision: 4888 raise TransitionCollisionError( 4889 f"Cannot rebase transition {transition!r} from" 4890 f" {self.identityOf(fromDecision)}: it would be a" 4891 f" duplicate transition name at the new base" 4892 f" decision {self.identityOf(newBase)}." 4893 ) 4894 else: 4895 # Figure out a good fresh name 4896 newName = utils.uniqueName( 4897 transition, 4898 targetDestinations 4899 ) 4900 else: 4901 newName = transition 4902 4903 # Delete the edge 4904 self.removeEdgeByKey(fromID, transition) 4905 4906 # Add the new edge 4907 self.addTransition(newBaseID, newName, destination) 4908 4909 # Reapply the transition properties 4910 self.setTransitionProperties(newBaseID, newName, **tProps) 4911 4912 # Handle the reciprocal transition if there is one... 4913 if reciprocal is not None: 4914 if not swapReciprocal: 4915 # Then sever the relationship 4916 self.setReciprocal( 4917 destination, 4918 reciprocal, 4919 None, 4920 setBoth=False # Other transition was deleted already 4921 ) 4922 else: 4923 # Otherwise swap the reciprocal edge 4924 self.retargetTransition( 4925 destination, 4926 reciprocal, 4927 newBaseID, 4928 swapReciprocal=False 4929 ) 4930 4931 # And establish a new reciprocal relationship 4932 self.setReciprocal( 4933 newBaseID, 4934 newName, 4935 reciprocal 4936 ) 4937 4938 # Return the new name in case it was changed 4939 return newName 4940 4941 # TODO: zone merging! 4942 4943 # TODO: Double-check that exploration vars get updated when this is 4944 # called! 4945 def mergeDecisions( 4946 self, 4947 merge: base.AnyDecisionSpecifier, 4948 mergeInto: base.AnyDecisionSpecifier, 4949 errorOnNameColision=True 4950 ) -> Dict[base.Transition, base.Transition]: 4951 """ 4952 Merges two decisions, deleting the first after transferring all 4953 of its incoming and outgoing edges to target the second one, 4954 whose name is retained. The second decision will be added to any 4955 zones that the first decision was a member of. If either decision 4956 does not exist, a `MissingDecisionError` will be raised. If 4957 `merge` and `mergeInto` are the same, then nothing will be 4958 changed. 4959 4960 Unless `errorOnNameColision` is set to False, a 4961 `TransitionCollisionError` will be raised if the two decisions 4962 have outgoing transitions with the same name. If 4963 `errorOnNameColision` is set to False, then such edges will be 4964 renamed using a suffix to avoid name collisions, with edges 4965 connected to the second decision retaining their original names 4966 and edges that were connected to the first decision getting 4967 renamed. 4968 4969 Any mechanisms located at the first decision will be moved to the 4970 merged decision. 4971 4972 The tags and annotations of the merged decision are added to the 4973 tags and annotations of the merge target. If there are shared 4974 tags, the values from the merge target will override those of 4975 the merged decision. If this is undesired behavior, clear/edit 4976 the tags/annotations of the merged decision before the merge. 4977 4978 The 'unconfirmed' tag is treated specially: if both decisions have 4979 it it will be retained, but otherwise it will be dropped even if 4980 one of the situations had it before. 4981 4982 The domain of the second decision is retained. 4983 4984 Returns a dictionary mapping each original transition name to 4985 its new name in cases where transitions get renamed; this will 4986 be empty when no re-naming occurs, including when 4987 `errorOnNameColision` is True. If there were any transitions 4988 connecting the nodes that were merged, these become self-edges 4989 of the merged node (and may be renamed if necessary). 4990 Note that all renamed transitions were originally based on the 4991 first (merged) node, since transitions of the second (merge 4992 target) node are not renamed. 4993 4994 ## Example 4995 4996 >>> g = DecisionGraph() 4997 >>> for fr, to, nm in [ 4998 ... ('A', 'B', 'up'), 4999 ... ('A', 'B', 'up2'), 5000 ... ('B', 'A', 'down'), 5001 ... ('B', 'B', 'self'), 5002 ... ('B', 'C', 'next'), 5003 ... ('C', 'B', 'prev'), 5004 ... ('A', 'C', 'right') 5005 ... ]: 5006 ... if g.getDecision(fr) is None: 5007 ... g.addDecision(fr) 5008 ... if g.getDecision(to) is None: 5009 ... g.addDecision(to) 5010 ... g.addTransition(fr, nm, to) 5011 0 5012 1 5013 2 5014 >>> g.getDestination('A', 'up') 5015 1 5016 >>> g.getDestination('B', 'down') 5017 0 5018 >>> sorted(g) 5019 [0, 1, 2] 5020 >>> g.setReciprocal('A', 'up', 'down') 5021 >>> g.setReciprocal('B', 'next', 'prev') 5022 >>> g.mergeDecisions('C', 'B') 5023 {} 5024 >>> g.destinationsFrom('A') 5025 {'up': 1, 'up2': 1, 'right': 1} 5026 >>> g.destinationsFrom('B') 5027 {'down': 0, 'self': 1, 'prev': 1, 'next': 1} 5028 >>> 'C' in g 5029 False 5030 >>> g.mergeDecisions('A', 'A') # does nothing 5031 {} 5032 >>> # Can't merge non-existent decision 5033 >>> g.mergeDecisions('A', 'Z') 5034 Traceback (most recent call last): 5035 ... 5036 exploration.core.MissingDecisionError... 5037 >>> g.mergeDecisions('Z', 'A') 5038 Traceback (most recent call last): 5039 ... 5040 exploration.core.MissingDecisionError... 5041 >>> # Can't merge decisions w/ shared edge names 5042 >>> g.addDecision('D') 5043 3 5044 >>> g.addTransition('D', 'next', 'A') 5045 >>> g.addTransition('A', 'prev', 'D') 5046 >>> g.setReciprocal('D', 'next', 'prev') 5047 >>> g.mergeDecisions('D', 'B') # both have a 'next' transition 5048 Traceback (most recent call last): 5049 ... 5050 exploration.core.TransitionCollisionError... 5051 >>> # Auto-rename colliding edges 5052 >>> g.mergeDecisions('D', 'B', errorOnNameColision=False) 5053 {'next': 'next.1'} 5054 >>> g.destination('B', 'next') # merge target unchanged 5055 1 5056 >>> g.destination('B', 'next.1') # merged decision name changed 5057 0 5058 >>> g.destination('B', 'prev') # name unchanged (no collision) 5059 1 5060 >>> g.getReciprocal('B', 'next') # unchanged (from B) 5061 'prev' 5062 >>> g.getReciprocal('B', 'next.1') # from A 5063 'prev' 5064 >>> g.getReciprocal('A', 'prev') # from B 5065 'next.1' 5066 5067 ## Folding four nodes into a 2-node loop 5068 5069 >>> g = DecisionGraph() 5070 >>> g.addDecision('X') 5071 0 5072 >>> g.addDecision('Y') 5073 1 5074 >>> g.addTransition('X', 'next', 'Y', 'prev') 5075 >>> g.addDecision('preX') 5076 2 5077 >>> g.addDecision('postY') 5078 3 5079 >>> g.addTransition('preX', 'next', 'X', 'prev') 5080 >>> g.addTransition('Y', 'next', 'postY', 'prev') 5081 >>> g.mergeDecisions('preX', 'Y', errorOnNameColision=False) 5082 {'next': 'next.1'} 5083 >>> g.destinationsFrom('X') 5084 {'next': 1, 'prev': 1} 5085 >>> g.destinationsFrom('Y') 5086 {'prev': 0, 'next': 3, 'next.1': 0} 5087 >>> 2 in g 5088 False 5089 >>> g.destinationsFrom('postY') 5090 {'prev': 1} 5091 >>> g.mergeDecisions('postY', 'X', errorOnNameColision=False) 5092 {'prev': 'prev.1'} 5093 >>> g.destinationsFrom('X') 5094 {'next': 1, 'prev': 1, 'prev.1': 1} 5095 >>> g.destinationsFrom('Y') # order 'cause of 'next' re-target 5096 {'prev': 0, 'next.1': 0, 'next': 0} 5097 >>> 2 in g 5098 False 5099 >>> 3 in g 5100 False 5101 >>> # Reciprocals are tangled... 5102 >>> g.getReciprocal(0, 'prev') 5103 'next.1' 5104 >>> g.getReciprocal(0, 'prev.1') 5105 'next' 5106 >>> g.getReciprocal(1, 'next') 5107 'prev.1' 5108 >>> g.getReciprocal(1, 'next.1') 5109 'prev' 5110 >>> # Note: one merge cannot handle both extra transitions 5111 >>> # because their reciprocals are crossed (e.g., prev.1 <-> next) 5112 >>> # (It would merge both edges but the result would retain 5113 >>> # 'next.1' instead of retaining 'next'.) 5114 >>> g.mergeTransitions('X', 'prev.1', 'prev', mergeReciprocal=False) 5115 >>> g.mergeTransitions('Y', 'next.1', 'next', mergeReciprocal=True) 5116 >>> g.destinationsFrom('X') 5117 {'next': 1, 'prev': 1} 5118 >>> g.destinationsFrom('Y') 5119 {'prev': 0, 'next': 0} 5120 >>> # Reciprocals were salvaged in second merger 5121 >>> g.getReciprocal('X', 'prev') 5122 'next' 5123 >>> g.getReciprocal('Y', 'next') 5124 'prev' 5125 5126 ## Merging with tags/requirements/annotations/consequences 5127 5128 >>> g = DecisionGraph() 5129 >>> g.addDecision('X') 5130 0 5131 >>> g.addDecision('Y') 5132 1 5133 >>> g.addDecision('Z') 5134 2 5135 >>> g.addTransition('X', 'next', 'Y', 'prev') 5136 >>> g.addTransition('X', 'down', 'Z', 'up') 5137 >>> g.tagDecision('X', 'tag0', 1) 5138 >>> g.tagDecision('Y', 'tag1', 10) 5139 >>> g.tagDecision('Y', 'unconfirmed') 5140 >>> g.tagDecision('Z', 'tag1', 20) 5141 >>> g.tagDecision('Z', 'tag2', 30) 5142 >>> g.tagTransition('X', 'next', 'ttag1', 11) 5143 >>> g.tagTransition('Y', 'prev', 'ttag2', 22) 5144 >>> g.tagTransition('X', 'down', 'ttag3', 33) 5145 >>> g.tagTransition('Z', 'up', 'ttag4', 44) 5146 >>> g.annotateDecision('Y', 'annotation 1') 5147 >>> g.annotateDecision('Z', 'annotation 2') 5148 >>> g.annotateDecision('Z', 'annotation 3') 5149 >>> g.annotateTransition('Y', 'prev', 'trans annotation 1') 5150 >>> g.annotateTransition('Y', 'prev', 'trans annotation 2') 5151 >>> g.annotateTransition('Z', 'up', 'trans annotation 3') 5152 >>> g.setTransitionRequirement( 5153 ... 'X', 5154 ... 'next', 5155 ... base.ReqCapability('power') 5156 ... ) 5157 >>> g.setTransitionRequirement( 5158 ... 'Y', 5159 ... 'prev', 5160 ... base.ReqTokens('token', 1) 5161 ... ) 5162 >>> g.setTransitionRequirement( 5163 ... 'X', 5164 ... 'down', 5165 ... base.ReqCapability('power2') 5166 ... ) 5167 >>> g.setTransitionRequirement( 5168 ... 'Z', 5169 ... 'up', 5170 ... base.ReqTokens('token2', 2) 5171 ... ) 5172 >>> g.setConsequence( 5173 ... 'Y', 5174 ... 'prev', 5175 ... [base.effect(gain="power2")] 5176 ... ) 5177 >>> g.mergeDecisions('Y', 'Z') 5178 {} 5179 >>> g.destination('X', 'next') 5180 2 5181 >>> g.destination('X', 'down') 5182 2 5183 >>> g.destination('Z', 'prev') 5184 0 5185 >>> g.destination('Z', 'up') 5186 0 5187 >>> g.decisionTags('X') 5188 {'tag0': 1} 5189 >>> g.decisionTags('Z') # note that 'unconfirmed' is removed 5190 {'tag1': 20, 'tag2': 30} 5191 >>> g.transitionTags('X', 'next') 5192 {'ttag1': 11} 5193 >>> g.transitionTags('X', 'down') 5194 {'ttag3': 33} 5195 >>> g.transitionTags('Z', 'prev') 5196 {'ttag2': 22} 5197 >>> g.transitionTags('Z', 'up') 5198 {'ttag4': 44} 5199 >>> g.decisionAnnotations('Z') 5200 ['annotation 2', 'annotation 3', 'annotation 1'] 5201 >>> g.transitionAnnotations('Z', 'prev') 5202 ['trans annotation 1', 'trans annotation 2'] 5203 >>> g.transitionAnnotations('Z', 'up') 5204 ['trans annotation 3'] 5205 >>> g.getTransitionRequirement('X', 'next') 5206 ReqCapability('power') 5207 >>> g.getTransitionRequirement('Z', 'prev') 5208 ReqTokens('token', 1) 5209 >>> g.getTransitionRequirement('X', 'down') 5210 ReqCapability('power2') 5211 >>> g.getTransitionRequirement('Z', 'up') 5212 ReqTokens('token2', 2) 5213 >>> g.getConsequence('Z', 'prev') == [ 5214 ... { 5215 ... 'type': 'gain', 5216 ... 'applyTo': 'active', 5217 ... 'value': 'power2', 5218 ... 'charges': None, 5219 ... 'delay': None, 5220 ... 'hidden': False 5221 ... } 5222 ... ] 5223 True 5224 5225 ## Merging into node without tags 5226 5227 >>> g = DecisionGraph() 5228 >>> g.addDecision('X') 5229 0 5230 >>> g.addDecision('Y') 5231 1 5232 >>> g.tagDecision('Y', 'unconfirmed') # special handling 5233 >>> g.tagDecision('Y', 'tag', 'value') 5234 >>> g.mergeDecisions('Y', 'X') 5235 {} 5236 >>> g.decisionTags('X') 5237 {'tag': 'value'} 5238 >>> 0 in g # Second argument remains 5239 True 5240 >>> 1 in g # First argument is deleted 5241 False 5242 """ 5243 # Resolve IDs 5244 mergeID = self.resolveDecision(merge) 5245 mergeIntoID = self.resolveDecision(mergeInto) 5246 5247 # Create our result as an empty dictionary 5248 result: Dict[base.Transition, base.Transition] = {} 5249 5250 # Short-circuit if the two decisions are the same 5251 if mergeID == mergeIntoID: 5252 return result 5253 5254 # MissingDecisionErrors from here if either doesn't exist 5255 allNewOutgoing = set(self.destinationsFrom(mergeID)) 5256 allOldOutgoing = set(self.destinationsFrom(mergeIntoID)) 5257 # Find colliding transition names 5258 collisions = allNewOutgoing & allOldOutgoing 5259 if len(collisions) > 0 and errorOnNameColision: 5260 raise TransitionCollisionError( 5261 f"Cannot merge decision {self.identityOf(merge)} into" 5262 f" decision {self.identityOf(mergeInto)}: the decisions" 5263 f" share {len(collisions)} transition names:" 5264 f" {collisions}\n(Note that errorOnNameColision was set" 5265 f" to True, set it to False to allow the operation by" 5266 f" renaming half of those transitions.)" 5267 ) 5268 5269 # Record zones that will have to change after the merge 5270 zoneParents = self.zoneParents(mergeID) 5271 5272 # First, swap all incoming edges, along with their reciprocals 5273 # This will include self-edges, which will be retargeted and 5274 # whose reciprocals will be rebased in the process, leading to 5275 # the possibility of a missing edge during the loop 5276 for source, incoming in self.allEdgesTo(mergeID): 5277 # Skip this edge if it was already swapped away because it's 5278 # a self-loop with a reciprocal whose reciprocal was 5279 # processed earlier in the loop 5280 if incoming not in self.destinationsFrom(source): 5281 continue 5282 5283 # Find corresponding outgoing edge 5284 outgoing = self.getReciprocal(source, incoming) 5285 5286 # Swap both edges to new destination 5287 newOutgoing = self.retargetTransition( 5288 source, 5289 incoming, 5290 mergeIntoID, 5291 swapReciprocal=True, 5292 errorOnNameColision=False # collisions were detected above 5293 ) 5294 # Add to our result if the name of the reciprocal was 5295 # changed 5296 if ( 5297 outgoing is not None 5298 and newOutgoing is not None 5299 and outgoing != newOutgoing 5300 ): 5301 result[outgoing] = newOutgoing 5302 5303 # Next, swap any remaining outgoing edges (which didn't have 5304 # reciprocals, or they'd already be swapped, unless they were 5305 # self-edges previously). Note that in this loop, there can't be 5306 # any self-edges remaining, although there might be connections 5307 # between the merging nodes that need to become self-edges 5308 # because they used to be a self-edge that was half-retargeted 5309 # by the previous loop. 5310 # Note: a copy is used here to avoid iterating over a changing 5311 # dictionary 5312 for stillOutgoing in copy.copy(self.destinationsFrom(mergeID)): 5313 newOutgoing = self.rebaseTransition( 5314 mergeID, 5315 stillOutgoing, 5316 mergeIntoID, 5317 swapReciprocal=True, 5318 errorOnNameColision=False # collisions were detected above 5319 ) 5320 if stillOutgoing != newOutgoing: 5321 result[stillOutgoing] = newOutgoing 5322 5323 # At this point, there shouldn't be any remaining incoming or 5324 # outgoing edges! 5325 assert self.degree(mergeID) == 0 5326 5327 # Merge tags & annotations 5328 # Note that these operations affect the underlying graph 5329 destTags = self.decisionTags(mergeIntoID) 5330 destUnvisited = 'unconfirmed' in destTags 5331 sourceTags = self.decisionTags(mergeID) 5332 sourceUnvisited = 'unconfirmed' in sourceTags 5333 # Copy over only new tags, leaving existing tags alone 5334 for key in sourceTags: 5335 if key not in destTags: 5336 destTags[key] = sourceTags[key] 5337 5338 if int(destUnvisited) + int(sourceUnvisited) == 1: 5339 del destTags['unconfirmed'] 5340 5341 self.decisionAnnotations(mergeIntoID).extend( 5342 self.decisionAnnotations(mergeID) 5343 ) 5344 5345 # Transfer zones 5346 for zone in zoneParents: 5347 self.addDecisionToZone(mergeIntoID, zone) 5348 5349 # Delete the old node 5350 self.removeDecision(mergeID) 5351 5352 return result 5353 5354 def removeDecision(self, decision: base.AnyDecisionSpecifier) -> None: 5355 """ 5356 Deletes the specified decision from the graph, updating 5357 attendant structures like zones. Note that the ID of the deleted 5358 node will NOT be reused, unless it's specifically provided to 5359 `addIdentifiedDecision`. 5360 5361 For example: 5362 5363 >>> dg = DecisionGraph() 5364 >>> dg.addDecision('A') 5365 0 5366 >>> dg.addDecision('B') 5367 1 5368 >>> list(dg) 5369 [0, 1] 5370 >>> 1 in dg 5371 True 5372 >>> 'B' in dg.nameLookup 5373 True 5374 >>> dg.removeDecision('B') 5375 >>> 1 in dg 5376 False 5377 >>> list(dg) 5378 [0] 5379 >>> 'B' in dg.nameLookup 5380 False 5381 >>> dg.addDecision('C') # doesn't re-use ID 5382 2 5383 """ 5384 dID = self.resolveDecision(decision) 5385 5386 # Remove the target from all zones: 5387 for zone in self.zones: 5388 self.removeDecisionFromZone(dID, zone) 5389 5390 # Remove the node but record the current name 5391 name = self.nodes[dID]['name'] 5392 self.remove_node(dID) 5393 5394 # Clean up the nameLookup entry 5395 luInfo = self.nameLookup[name] 5396 luInfo.remove(dID) 5397 if len(luInfo) == 0: 5398 self.nameLookup.pop(name) 5399 5400 # TODO: Clean up edges? 5401 5402 def renameDecision( 5403 self, 5404 decision: base.AnyDecisionSpecifier, 5405 newName: base.DecisionName 5406 ): 5407 """ 5408 Renames a decision. The decision retains its old ID. 5409 5410 Generates a `DecisionCollisionWarning` if a decision using the new 5411 name already exists and `WARN_OF_NAME_COLLISIONS` is enabled. 5412 5413 Example: 5414 5415 >>> g = DecisionGraph() 5416 >>> g.addDecision('one') 5417 0 5418 >>> g.addDecision('three') 5419 1 5420 >>> g.addTransition('one', '>', 'three') 5421 >>> g.addTransition('three', '<', 'one') 5422 >>> g.tagDecision('three', 'hi') 5423 >>> g.annotateDecision('three', 'note') 5424 >>> g.destination('one', '>') 5425 1 5426 >>> g.destination('three', '<') 5427 0 5428 >>> g.renameDecision('three', 'two') 5429 >>> g.resolveDecision('one') 5430 0 5431 >>> g.resolveDecision('two') 5432 1 5433 >>> g.resolveDecision('three') 5434 Traceback (most recent call last): 5435 ... 5436 exploration.core.MissingDecisionError... 5437 >>> g.destination('one', '>') 5438 1 5439 >>> g.nameFor(1) 5440 'two' 5441 >>> g.getDecision('three') is None 5442 True 5443 >>> g.destination('two', '<') 5444 0 5445 >>> g.decisionTags('two') 5446 {'hi': 1} 5447 >>> g.decisionAnnotations('two') 5448 ['note'] 5449 """ 5450 dID = self.resolveDecision(decision) 5451 5452 if newName in self.nameLookup and WARN_OF_NAME_COLLISIONS: 5453 warnings.warn( 5454 ( 5455 f"Can't rename {self.identityOf(decision)} as" 5456 f" {newName!r} because a decision with that name" 5457 f" already exists." 5458 ), 5459 DecisionCollisionWarning 5460 ) 5461 5462 # Update name in node 5463 oldName = self.nodes[dID]['name'] 5464 self.nodes[dID]['name'] = newName 5465 5466 # Update nameLookup entries 5467 oldNL = self.nameLookup[oldName] 5468 oldNL.remove(dID) 5469 if len(oldNL) == 0: 5470 self.nameLookup.pop(oldName) 5471 self.nameLookup.setdefault(newName, []).append(dID) 5472 5473 def mergeTransitions( 5474 self, 5475 fromDecision: base.AnyDecisionSpecifier, 5476 merge: base.Transition, 5477 mergeInto: base.Transition, 5478 mergeReciprocal=True 5479 ) -> None: 5480 """ 5481 Given a decision and two transitions that start at that decision, 5482 merges the first transition into the second transition, combining 5483 their transition properties (using `mergeProperties`) and 5484 deleting the first transition. By default any reciprocal of the 5485 first transition is also merged into the reciprocal of the 5486 second, although you can set `mergeReciprocal` to `False` to 5487 disable this in which case the old reciprocal will lose its 5488 reciprocal relationship, even if the transition that was merged 5489 into does not have a reciprocal. 5490 5491 If the two names provided are the same, nothing will happen. 5492 5493 If the two transitions do not share the same destination, they 5494 cannot be merged, and an `InvalidDestinationError` will result. 5495 Use `retargetTransition` beforehand to ensure that they do if you 5496 want to merge transitions with different destinations. 5497 5498 A `MissingDecisionError` or `MissingTransitionError` will result 5499 if the decision or either transition does not exist. 5500 5501 If merging reciprocal properties was requested and the first 5502 transition does not have a reciprocal, then no reciprocal 5503 properties change. However, if the second transition does not 5504 have a reciprocal and the first does, the first transition's 5505 reciprocal will be set to the reciprocal of the second 5506 transition, and that transition will not be deleted as usual. 5507 5508 ## Example 5509 5510 >>> g = DecisionGraph() 5511 >>> g.addDecision('A') 5512 0 5513 >>> g.addDecision('B') 5514 1 5515 >>> g.addTransition('A', 'up', 'B') 5516 >>> g.addTransition('B', 'down', 'A') 5517 >>> g.setReciprocal('A', 'up', 'down') 5518 >>> # Merging a transition with no reciprocal 5519 >>> g.addTransition('A', 'up2', 'B') 5520 >>> g.mergeTransitions('A', 'up2', 'up') 5521 >>> g.getDestination('A', 'up2') is None 5522 True 5523 >>> g.getDestination('A', 'up') 5524 1 5525 >>> # Merging a transition with a reciprocal & tags 5526 >>> g.addTransition('A', 'up2', 'B') 5527 >>> g.addTransition('B', 'down2', 'A') 5528 >>> g.setReciprocal('A', 'up2', 'down2') 5529 >>> g.tagTransition('A', 'up2', 'one') 5530 >>> g.tagTransition('B', 'down2', 'two') 5531 >>> g.mergeTransitions('B', 'down2', 'down') 5532 >>> g.getDestination('A', 'up2') is None 5533 True 5534 >>> g.getDestination('A', 'up') 5535 1 5536 >>> g.getDestination('B', 'down2') is None 5537 True 5538 >>> g.getDestination('B', 'down') 5539 0 5540 >>> # Merging requirements uses ReqAll (i.e., 'and' logic) 5541 >>> g.addTransition('A', 'up2', 'B') 5542 >>> g.setTransitionProperties( 5543 ... 'A', 5544 ... 'up2', 5545 ... requirement=base.ReqCapability('dash') 5546 ... ) 5547 >>> g.setTransitionProperties('A', 'up', 5548 ... requirement=base.ReqCapability('slide')) 5549 >>> g.mergeTransitions('A', 'up2', 'up') 5550 >>> g.getDestination('A', 'up2') is None 5551 True 5552 >>> repr(g.getTransitionRequirement('A', 'up')) 5553 "ReqAll([ReqCapability('dash'), ReqCapability('slide')])" 5554 >>> # Errors if destinations differ, or if something is missing 5555 >>> g.mergeTransitions('A', 'down', 'up') 5556 Traceback (most recent call last): 5557 ... 5558 exploration.core.MissingTransitionError... 5559 >>> g.mergeTransitions('Z', 'one', 'two') 5560 Traceback (most recent call last): 5561 ... 5562 exploration.core.MissingDecisionError... 5563 >>> g.addDecision('C') 5564 2 5565 >>> g.addTransition('A', 'down', 'C') 5566 >>> g.mergeTransitions('A', 'down', 'up') 5567 Traceback (most recent call last): 5568 ... 5569 exploration.core.InvalidDestinationError... 5570 >>> # Merging a reciprocal onto an edge that doesn't have one 5571 >>> g.addTransition('A', 'down2', 'C') 5572 >>> g.addTransition('C', 'up2', 'A') 5573 >>> g.setReciprocal('A', 'down2', 'up2') 5574 >>> g.tagTransition('C', 'up2', 'narrow') 5575 >>> g.getReciprocal('A', 'down') is None 5576 True 5577 >>> g.mergeTransitions('A', 'down2', 'down') 5578 >>> g.getDestination('A', 'down2') is None 5579 True 5580 >>> g.getDestination('A', 'down') 5581 2 5582 >>> g.getDestination('C', 'up2') 5583 0 5584 >>> g.getReciprocal('A', 'down') 5585 'up2' 5586 >>> g.getReciprocal('C', 'up2') 5587 'down' 5588 >>> g.transitionTags('C', 'up2') 5589 {'narrow': 1} 5590 >>> # Merging without a reciprocal 5591 >>> g.addTransition('C', 'up', 'A') 5592 >>> g.mergeTransitions('C', 'up2', 'up', mergeReciprocal=False) 5593 >>> g.getDestination('C', 'up2') is None 5594 True 5595 >>> g.getDestination('C', 'up') 5596 0 5597 >>> g.transitionTags('C', 'up') # tag gets merged 5598 {'narrow': 1} 5599 >>> g.getDestination('A', 'down') 5600 2 5601 >>> g.getReciprocal('A', 'down') is None 5602 True 5603 >>> g.getReciprocal('C', 'up') is None 5604 True 5605 >>> # Merging w/ normal reciprocals 5606 >>> g.addDecision('D') 5607 3 5608 >>> g.addDecision('E') 5609 4 5610 >>> g.addTransition('D', 'up', 'E', 'return') 5611 >>> g.addTransition('E', 'down', 'D') 5612 >>> g.mergeTransitions('E', 'return', 'down') 5613 >>> g.getDestination('D', 'up') 5614 4 5615 >>> g.getDestination('E', 'down') 5616 3 5617 >>> g.getDestination('E', 'return') is None 5618 True 5619 >>> g.getReciprocal('D', 'up') 5620 'down' 5621 >>> g.getReciprocal('E', 'down') 5622 'up' 5623 >>> # Merging w/ weird reciprocals 5624 >>> g.addTransition('E', 'return', 'D') 5625 >>> g.setReciprocal('E', 'return', 'up', setBoth=False) 5626 >>> g.getReciprocal('D', 'up') 5627 'down' 5628 >>> g.getReciprocal('E', 'down') 5629 'up' 5630 >>> g.getReciprocal('E', 'return') # shared 5631 'up' 5632 >>> g.mergeTransitions('E', 'return', 'down') 5633 >>> g.getDestination('D', 'up') 5634 4 5635 >>> g.getDestination('E', 'down') 5636 3 5637 >>> g.getDestination('E', 'return') is None 5638 True 5639 >>> g.getReciprocal('D', 'up') 5640 'down' 5641 >>> g.getReciprocal('E', 'down') 5642 'up' 5643 """ 5644 fromID = self.resolveDecision(fromDecision) 5645 5646 # Short-circuit in the no-op case 5647 if merge == mergeInto: 5648 return 5649 5650 # These lines will raise a MissingDecisionError or 5651 # MissingTransitionError if needed 5652 dest1 = self.destination(fromID, merge) 5653 dest2 = self.destination(fromID, mergeInto) 5654 5655 if dest1 != dest2: 5656 raise InvalidDestinationError( 5657 f"Cannot merge transition {merge!r} into transition" 5658 f" {mergeInto!r} from decision" 5659 f" {self.identityOf(fromDecision)} because their" 5660 f" destinations are different ({self.identityOf(dest1)}" 5661 f" and {self.identityOf(dest2)}).\nNote: you can use" 5662 f" `retargetTransition` to change the destination of a" 5663 f" transition." 5664 ) 5665 5666 # Find and the transition properties 5667 props1 = self.getTransitionProperties(fromID, merge) 5668 props2 = self.getTransitionProperties(fromID, mergeInto) 5669 merged = mergeProperties(props1, props2) 5670 # Note that this doesn't change the reciprocal: 5671 self.setTransitionProperties(fromID, mergeInto, **merged) 5672 5673 # Merge the reciprocal properties if requested 5674 # Get reciprocal to merge into 5675 reciprocal = self.getReciprocal(fromID, mergeInto) 5676 # Get reciprocal that needs cleaning up 5677 altReciprocal = self.getReciprocal(fromID, merge) 5678 # If the reciprocal to be merged actually already was the 5679 # reciprocal to merge into, there's nothing to do here 5680 if altReciprocal != reciprocal: 5681 if not mergeReciprocal: 5682 # In this case, we sever the reciprocal relationship if 5683 # there is a reciprocal 5684 if altReciprocal is not None: 5685 self.setReciprocal(dest1, altReciprocal, None) 5686 # By default setBoth takes care of the other half 5687 else: 5688 # In this case, we try to merge reciprocals 5689 # If altReciprocal is None, we don't need to do anything 5690 if altReciprocal is not None: 5691 # Was there already a reciprocal or not? 5692 if reciprocal is None: 5693 # altReciprocal becomes the new reciprocal and is 5694 # not deleted 5695 self.setReciprocal( 5696 fromID, 5697 mergeInto, 5698 altReciprocal 5699 ) 5700 else: 5701 # merge reciprocal properties 5702 props1 = self.getTransitionProperties( 5703 dest1, 5704 altReciprocal 5705 ) 5706 props2 = self.getTransitionProperties( 5707 dest2, 5708 reciprocal 5709 ) 5710 merged = mergeProperties(props1, props2) 5711 self.setTransitionProperties( 5712 dest1, 5713 reciprocal, 5714 **merged 5715 ) 5716 5717 # delete the old reciprocal transition 5718 self.remove_edge(dest1, fromID, altReciprocal) 5719 5720 # Delete the old transition (reciprocal deletion/severance is 5721 # handled above if necessary) 5722 self.remove_edge(fromID, dest1, merge) 5723 5724 def isConfirmed(self, decision: base.AnyDecisionSpecifier) -> bool: 5725 """ 5726 Returns `True` or `False` depending on whether or not the 5727 specified decision has been confirmed. Uses the presence or 5728 absence of the 'unconfirmed' tag to determine this. 5729 5730 Note: 'unconfirmed' is used instead of 'confirmed' so that large 5731 graphs with many confirmed nodes will be smaller when saved. 5732 """ 5733 dID = self.resolveDecision(decision) 5734 5735 return 'unconfirmed' not in self.nodes[dID]['tags'] 5736 5737 def replaceUnconfirmed( 5738 self, 5739 fromDecision: base.AnyDecisionSpecifier, 5740 transition: base.Transition, 5741 connectTo: Optional[base.AnyDecisionSpecifier] = None, 5742 reciprocal: Optional[base.Transition] = None, 5743 requirement: Optional[base.Requirement] = None, 5744 applyConsequence: Optional[base.Consequence] = None, 5745 placeInZone: Optional[base.Zone] = None, 5746 forceNew: bool = False, 5747 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 5748 annotations: Optional[List[base.Annotation]] = None, 5749 revRequires: Optional[base.Requirement] = None, 5750 revConsequence: Optional[base.Consequence] = None, 5751 revTags: Optional[Dict[base.Tag, base.TagValue]] = None, 5752 revAnnotations: Optional[List[base.Annotation]] = None, 5753 decisionTags: Optional[Dict[base.Tag, base.TagValue]] = None, 5754 decisionAnnotations: Optional[List[base.Annotation]] = None 5755 ) -> Tuple[ 5756 Dict[base.Transition, base.Transition], 5757 Dict[base.Transition, base.Transition] 5758 ]: 5759 """ 5760 Given a decision and an edge name in that decision, where the 5761 named edge leads to a decision with an unconfirmed exploration 5762 state (see `isConfirmed`), renames the unexplored decision on 5763 the other end of that edge using the given `connectTo` name, or 5764 if a decision using that name already exists, merges the 5765 unexplored decision into that decision. If `connectTo` is a 5766 `DecisionSpecifier` whose target doesn't exist, it will be 5767 treated as just a name, but if it's an ID and it doesn't exist, 5768 you'll get a `MissingDecisionError`. If a `reciprocal` is provided, 5769 a reciprocal edge will be added using that name connecting the 5770 `connectTo` decision back to the original decision. If this 5771 transition already exists, it must also point to a node which is 5772 also unexplored, and which will also be merged into the 5773 `fromDecision` node. 5774 5775 If `connectTo` is not given (or is set to `None` explicitly) 5776 then the name of the unexplored decision will not be changed, 5777 unless that name has the form `'_u.-n-'` where `-n-` is a positive 5778 integer (i.e., the form given to automatically-named unknown 5779 nodes). In that case, the name will be changed to `'_x.-n-'` using 5780 the same number, or a higher number if that name is already taken. 5781 5782 If the destination is being renamed or if the destination's 5783 exploration state counts as unexplored, the exploration state of 5784 the destination will be set to 'exploring'. 5785 5786 If a `placeInZone` is specified, the destination will be placed 5787 directly into that zone (even if it already existed and has zone 5788 information), and it will be removed from any other zones it had 5789 been a direct member of. If `placeInZone` is set to 5790 `base.DefaultZone`, then the destination will be placed into 5791 each zone which is a direct parent of the origin, but only if 5792 the destination is not an already-explored existing decision AND 5793 it is not already in any zones (in those cases no zone changes 5794 are made). This will also remove it from any previous zones it 5795 had been a part of. If `placeInZone` is left as `None` (the 5796 default) no zone changes are made. 5797 5798 If `placeInZone` is specified and that zone didn't already exist, 5799 it will be created as a new level-0 zone and will be added as a 5800 sub-zone of each zone that's a direct parent of any level-0 zone 5801 that the origin is a member of. 5802 5803 If `forceNew` is specified, then the destination will just be 5804 renamed, even if another decision with the same name already 5805 exists. It's an error to use `forceNew` with a decision ID as 5806 the destination. 5807 5808 Any additional edges pointing to or from the unknown node(s) 5809 being replaced will also be re-targeted at the now-discovered 5810 known destination(s) if necessary. These edges will retain their 5811 reciprocal names, or if this would cause a name clash, they will 5812 be renamed with a suffix (see `retargetTransition`). 5813 5814 The return value is a pair of dictionaries mapping old names to 5815 new ones that just includes the names which were changed. The 5816 first dictionary contains renamed transitions that are outgoing 5817 from the new destination node (which used to be outgoing from 5818 the unexplored node). The second dictionary contains renamed 5819 transitions that are outgoing from the source node (which used 5820 to be outgoing from the unexplored node attached to the 5821 reciprocal transition; if there was no reciprocal transition 5822 specified then this will always be an empty dictionary). 5823 5824 An `ExplorationStatusError` will be raised if the destination 5825 of the specified transition counts as visited (see 5826 `hasBeenVisited`). An `ExplorationStatusError` will also be 5827 raised if the `connectTo`'s `reciprocal` transition does not lead 5828 to an unconfirmed decision (it's okay if this second transition 5829 doesn't exist). A `TransitionCollisionError` will be raised if 5830 the unconfirmed destination decision already has an outgoing 5831 transition with the specified `reciprocal` which does not lead 5832 back to the `fromDecision`. 5833 5834 The transition properties (requirement, consequences, tags, 5835 and/or annotations) of the replaced transition will be copied 5836 over to the new transition. Transition properties from the 5837 reciprocal transition will also be copied for the newly created 5838 reciprocal edge. Properties for any additional edges to/from the 5839 unknown node will also be copied. 5840 5841 Also, any transition properties on existing forward or reciprocal 5842 edges from the destination node with the indicated reverse name 5843 will be merged with those from the target transition. Note that 5844 this merging process may introduce corruption of complex 5845 transition consequences. TODO: Fix that! 5846 5847 Any tags and annotations are added to copied tags/annotations, 5848 but specified requirements, and/or consequences will replace 5849 previous requirements/consequences, rather than being added to 5850 them. 5851 5852 ## Example 5853 5854 >>> g = DecisionGraph() 5855 >>> g.addDecision('A') 5856 0 5857 >>> g.addUnexploredEdge('A', 'up') 5858 1 5859 >>> g.destination('A', 'up') 5860 1 5861 >>> g.destination('_u.0', 'return') 5862 0 5863 >>> g.replaceUnconfirmed('A', 'up', 'B', 'down') 5864 ({}, {}) 5865 >>> g.destination('A', 'up') 5866 1 5867 >>> g.nameFor(1) 5868 'B' 5869 >>> g.destination('B', 'down') 5870 0 5871 >>> g.getDestination('B', 'return') is None 5872 True 5873 >>> '_u.0' in g.nameLookup 5874 False 5875 >>> g.getReciprocal('A', 'up') 5876 'down' 5877 >>> g.getReciprocal('B', 'down') 5878 'up' 5879 >>> # Two unexplored edges to the same node: 5880 >>> g.addDecision('C') 5881 2 5882 >>> g.addTransition('B', 'next', 'C') 5883 >>> g.addTransition('C', 'prev', 'B') 5884 >>> g.setReciprocal('B', 'next', 'prev') 5885 >>> g.addUnexploredEdge('A', 'next', 'D', 'prev') 5886 3 5887 >>> g.addTransition('C', 'down', 'D') 5888 >>> g.addTransition('D', 'up', 'C') 5889 >>> g.setReciprocal('C', 'down', 'up') 5890 >>> g.replaceUnconfirmed('C', 'down') 5891 ({}, {}) 5892 >>> g.destination('C', 'down') 5893 3 5894 >>> g.destination('A', 'next') 5895 3 5896 >>> g.destinationsFrom('D') 5897 {'prev': 0, 'up': 2} 5898 >>> g.decisionTags('D') 5899 {} 5900 >>> # An unexplored transition which turns out to connect to a 5901 >>> # known decision, with name collisions 5902 >>> g.addUnexploredEdge('D', 'next', reciprocal='prev') 5903 4 5904 >>> g.tagDecision('_u.2', 'wet') 5905 >>> g.addUnexploredEdge('B', 'next', reciprocal='prev') # edge taken 5906 Traceback (most recent call last): 5907 ... 5908 exploration.core.TransitionCollisionError... 5909 >>> g.addUnexploredEdge('A', 'prev', reciprocal='next') 5910 5 5911 >>> g.tagDecision('_u.3', 'dry') 5912 >>> # Add transitions that will collide when merged 5913 >>> g.addUnexploredEdge('_u.2', 'up') # collides with A/up 5914 6 5915 >>> g.addUnexploredEdge('_u.3', 'prev') # collides with D/prev 5916 7 5917 >>> g.getReciprocal('A', 'prev') 5918 'next' 5919 >>> g.replaceUnconfirmed('A', 'prev', 'D', 'next') # two gone 5920 ({'prev': 'prev.1'}, {'up': 'up.1'}) 5921 >>> g.destination('A', 'prev') 5922 3 5923 >>> g.destination('D', 'next') 5924 0 5925 >>> g.getReciprocal('A', 'prev') 5926 'next' 5927 >>> g.getReciprocal('D', 'next') 5928 'prev' 5929 >>> # Note that further unexplored structures are NOT merged 5930 >>> # even if they match against existing structures... 5931 >>> g.destination('A', 'up.1') 5932 6 5933 >>> g.destination('D', 'prev.1') 5934 7 5935 >>> '_u.2' in g.nameLookup 5936 False 5937 >>> '_u.3' in g.nameLookup 5938 False 5939 >>> g.decisionTags('D') # tags are merged 5940 {'dry': 1} 5941 >>> g.decisionTags('A') 5942 {'wet': 1} 5943 >>> # Auto-renaming an anonymous unexplored node 5944 >>> g.addUnexploredEdge('B', 'out') 5945 8 5946 >>> g.replaceUnconfirmed('B', 'out') 5947 ({}, {}) 5948 >>> '_u.6' in g 5949 False 5950 >>> g.destination('B', 'out') 5951 8 5952 >>> g.nameFor(8) 5953 '_x.6' 5954 >>> g.destination('_x.6', 'return') 5955 1 5956 >>> # Placing a node into a zone 5957 >>> g.addUnexploredEdge('B', 'through') 5958 9 5959 >>> g.getDecision('E') is None 5960 True 5961 >>> g.replaceUnconfirmed( 5962 ... 'B', 5963 ... 'through', 5964 ... 'E', 5965 ... 'back', 5966 ... placeInZone='Zone' 5967 ... ) 5968 ({}, {}) 5969 >>> g.getDecision('E') 5970 9 5971 >>> g.destination('B', 'through') 5972 9 5973 >>> g.destination('E', 'back') 5974 1 5975 >>> g.zoneParents(9) 5976 {'Zone'} 5977 >>> g.addUnexploredEdge('E', 'farther') 5978 10 5979 >>> g.replaceUnconfirmed( 5980 ... 'E', 5981 ... 'farther', 5982 ... 'F', 5983 ... 'closer', 5984 ... placeInZone=base.DefaultZone 5985 ... ) 5986 ({}, {}) 5987 >>> g.destination('E', 'farther') 5988 10 5989 >>> g.destination('F', 'closer') 5990 9 5991 >>> g.zoneParents(10) 5992 {'Zone'} 5993 >>> g.addUnexploredEdge('F', 'backwards', placeInZone='Enoz') 5994 11 5995 >>> g.replaceUnconfirmed( 5996 ... 'F', 5997 ... 'backwards', 5998 ... 'G', 5999 ... 'forwards', 6000 ... placeInZone=base.DefaultZone 6001 ... ) 6002 ({}, {}) 6003 >>> g.destination('F', 'backwards') 6004 11 6005 >>> g.destination('G', 'forwards') 6006 10 6007 >>> g.zoneParents(11) # not changed since it already had a zone 6008 {'Enoz'} 6009 >>> # TODO: forceNew example 6010 """ 6011 6012 # Defaults 6013 if tags is None: 6014 tags = {} 6015 if annotations is None: 6016 annotations = [] 6017 if revTags is None: 6018 revTags = {} 6019 if revAnnotations is None: 6020 revAnnotations = [] 6021 if decisionTags is None: 6022 decisionTags = {} 6023 if decisionAnnotations is None: 6024 decisionAnnotations = [] 6025 6026 # Resolve source 6027 fromID = self.resolveDecision(fromDecision) 6028 6029 # Figure out destination decision 6030 oldUnexplored = self.destination(fromID, transition) 6031 if self.isConfirmed(oldUnexplored): 6032 raise ExplorationStatusError( 6033 f"Transition {transition!r} from" 6034 f" {self.identityOf(fromDecision)} does not lead to an" 6035 f" unconfirmed decision (it leads to" 6036 f" {self.identityOf(oldUnexplored)} which is not tagged" 6037 f" 'unconfirmed')." 6038 ) 6039 6040 # Resolve destination 6041 newName: Optional[base.DecisionName] = None 6042 connectID: Optional[base.DecisionID] = None 6043 if forceNew: 6044 if isinstance(connectTo, base.DecisionID): 6045 raise TypeError( 6046 f"connectTo cannot be a decision ID when forceNew" 6047 f" is True. Got: {self.identityOf(connectTo)}" 6048 ) 6049 elif isinstance(connectTo, base.DecisionSpecifier): 6050 newName = connectTo.name 6051 elif isinstance(connectTo, base.DecisionName): 6052 newName = connectTo 6053 elif connectTo is None: 6054 oldName = self.nameFor(oldUnexplored) 6055 if ( 6056 oldName.startswith('_u.') 6057 and oldName[3:].isdigit() 6058 ): 6059 newName = utils.uniqueName('_x.' + oldName[3:], self) 6060 else: 6061 newName = oldName 6062 else: 6063 raise TypeError( 6064 f"Invalid connectTo value: {connectTo!r}" 6065 ) 6066 elif connectTo is not None: 6067 try: 6068 connectID = self.resolveDecision(connectTo) 6069 # leave newName as None 6070 except MissingDecisionError: 6071 if isinstance(connectTo, int): 6072 raise 6073 elif isinstance(connectTo, base.DecisionSpecifier): 6074 newName = connectTo.name 6075 # The domain & zone are ignored here 6076 else: # Must just be a string 6077 assert isinstance(connectTo, str) 6078 newName = connectTo 6079 else: 6080 # If connectTo name wasn't specified, use current name of 6081 # unknown node unless it's a default name 6082 oldName = self.nameFor(oldUnexplored) 6083 if ( 6084 oldName.startswith('_u.') 6085 and oldName[3:].isdigit() 6086 ): 6087 newName = utils.uniqueName('_x.' + oldName[3:], self) 6088 else: 6089 newName = oldName 6090 6091 # One or the other should be valid at this point 6092 assert connectID is not None or newName is not None 6093 6094 # Check that the old unknown doesn't have a reciprocal edge that 6095 # would collide with the specified return edge 6096 if reciprocal is not None: 6097 revFromUnknown = self.getDestination(oldUnexplored, reciprocal) 6098 if revFromUnknown not in (None, fromID): 6099 raise TransitionCollisionError( 6100 f"Transition {reciprocal!r} from" 6101 f" {self.identityOf(oldUnexplored)} exists and does" 6102 f" not lead back to {self.identityOf(fromDecision)}" 6103 f" (it leads to {self.identityOf(revFromUnknown)})." 6104 ) 6105 6106 # Remember old reciprocal edge for future merging in case 6107 # it's not reciprocal 6108 oldReciprocal = self.getReciprocal(fromID, transition) 6109 6110 # Apply any new tags or annotations, or create a new node 6111 needsZoneInfo = False 6112 if connectID is not None: 6113 # Before applying tags, check if we need to error out 6114 # because of a reciprocal edge that points to a known 6115 # destination: 6116 if reciprocal is not None: 6117 otherOldUnknown: Optional[ 6118 base.DecisionID 6119 ] = self.getDestination( 6120 connectID, 6121 reciprocal 6122 ) 6123 if ( 6124 otherOldUnknown is not None 6125 and self.isConfirmed(otherOldUnknown) 6126 ): 6127 raise ExplorationStatusError( 6128 f"Reciprocal transition {reciprocal!r} from" 6129 f" {self.identityOf(connectTo)} does not lead" 6130 f" to an unconfirmed decision (it leads to" 6131 f" {self.identityOf(otherOldUnknown)})." 6132 ) 6133 self.tagDecision(connectID, decisionTags) 6134 self.annotateDecision(connectID, decisionAnnotations) 6135 # Still needs zone info if the place we're connecting to was 6136 # unconfirmed up until now, since unconfirmed nodes don't 6137 # normally get zone info when they're created. 6138 if not self.isConfirmed(connectID): 6139 needsZoneInfo = True 6140 6141 # First, merge the old unknown with the connectTo node... 6142 destRenames = self.mergeDecisions( 6143 oldUnexplored, 6144 connectID, 6145 errorOnNameColision=False 6146 ) 6147 else: 6148 needsZoneInfo = True 6149 if len(self.zoneParents(oldUnexplored)) > 0: 6150 needsZoneInfo = False 6151 assert newName is not None 6152 self.renameDecision(oldUnexplored, newName) 6153 connectID = oldUnexplored 6154 # In this case there can't be an other old unknown 6155 otherOldUnknown = None 6156 destRenames = {} # empty 6157 6158 # Check for domain mismatch to stifle zone updates: 6159 fromDomain = self.domainFor(fromID) 6160 if connectID is None: 6161 destDomain = self.domainFor(oldUnexplored) 6162 else: 6163 destDomain = self.domainFor(connectID) 6164 6165 # Stifle zone updates if there's a mismatch 6166 if fromDomain != destDomain: 6167 needsZoneInfo = False 6168 6169 # Records renames that happen at the source (from node) 6170 sourceRenames = {} # empty for now 6171 6172 assert connectID is not None 6173 6174 # Apply the new zone if there is one 6175 if placeInZone is not None: 6176 if placeInZone == base.DefaultZone: 6177 # When using DefaultZone, changes are only made for new 6178 # destinations which don't already have any zones and 6179 # which are in the same domain as the departing node: 6180 # they get placed into each zone parent of the source 6181 # decision. 6182 if needsZoneInfo: 6183 # Remove destination from all current parents 6184 removeFrom = set(self.zoneParents(connectID)) # copy 6185 for parent in removeFrom: 6186 self.removeDecisionFromZone(connectID, parent) 6187 # Add it to parents of origin 6188 for parent in self.zoneParents(fromID): 6189 self.addDecisionToZone(connectID, parent) 6190 else: 6191 placeInZone = cast(base.Zone, placeInZone) 6192 # Create the zone if it doesn't already exist 6193 if self.getZoneInfo(placeInZone) is None: 6194 self.createZone(placeInZone, 0) 6195 # Add it to each grandparent of the from decision 6196 for parent in self.zoneParents(fromID): 6197 for grandparent in self.zoneParents(parent): 6198 self.addZoneToZone(placeInZone, grandparent) 6199 # Remove destination from all current parents 6200 for parent in set(self.zoneParents(connectID)): 6201 self.removeDecisionFromZone(connectID, parent) 6202 # Add it to the specified zone 6203 self.addDecisionToZone(connectID, placeInZone) 6204 6205 # Next, if there is a reciprocal name specified, we do more... 6206 if reciprocal is not None: 6207 # Figure out what kind of merging needs to happen 6208 if otherOldUnknown is None: 6209 if revFromUnknown is None: 6210 # Just create the desired reciprocal transition, which 6211 # we know does not already exist 6212 self.addTransition(connectID, reciprocal, fromID) 6213 otherOldReciprocal = None 6214 else: 6215 # Reciprocal exists, as revFromUnknown 6216 otherOldReciprocal = None 6217 else: 6218 otherOldReciprocal = self.getReciprocal( 6219 connectID, 6220 reciprocal 6221 ) 6222 # we need to merge otherOldUnknown into our fromDecision 6223 sourceRenames = self.mergeDecisions( 6224 otherOldUnknown, 6225 fromID, 6226 errorOnNameColision=False 6227 ) 6228 # Unvisited tag after merge only if both were 6229 6230 # No matter what happened we ensure the reciprocal 6231 # relationship is set up: 6232 self.setReciprocal(fromID, transition, reciprocal) 6233 6234 # Now we might need to merge some transitions: 6235 # - Any reciprocal of the target transition should be merged 6236 # with reciprocal (if it was already reciprocal, that's a 6237 # no-op). 6238 # - Any reciprocal of the reciprocal transition from the target 6239 # node (leading to otherOldUnknown) should be merged with 6240 # the target transition, even if it shared a name and was 6241 # renamed as a result. 6242 # - If reciprocal was renamed during the initial merge, those 6243 # transitions should be merged. 6244 6245 # Merge old reciprocal into reciprocal 6246 if oldReciprocal is not None: 6247 oldRev = destRenames.get(oldReciprocal, oldReciprocal) 6248 if self.getDestination(connectID, oldRev) is not None: 6249 # Note that we don't want to auto-merge the reciprocal, 6250 # which is the target transition 6251 self.mergeTransitions( 6252 connectID, 6253 oldRev, 6254 reciprocal, 6255 mergeReciprocal=False 6256 ) 6257 # Remove it from the renames map 6258 if oldReciprocal in destRenames: 6259 del destRenames[oldReciprocal] 6260 6261 # Merge reciprocal reciprocal from otherOldUnknown 6262 if otherOldReciprocal is not None: 6263 otherOldRev = sourceRenames.get( 6264 otherOldReciprocal, 6265 otherOldReciprocal 6266 ) 6267 # Note that the reciprocal is reciprocal, which we don't 6268 # need to merge 6269 self.mergeTransitions( 6270 fromID, 6271 otherOldRev, 6272 transition, 6273 mergeReciprocal=False 6274 ) 6275 # Remove it from the renames map 6276 if otherOldReciprocal in sourceRenames: 6277 del sourceRenames[otherOldReciprocal] 6278 6279 # Merge any renamed reciprocal onto reciprocal 6280 if reciprocal in destRenames: 6281 extraRev = destRenames[reciprocal] 6282 self.mergeTransitions( 6283 connectID, 6284 extraRev, 6285 reciprocal, 6286 mergeReciprocal=False 6287 ) 6288 # Remove it from the renames map 6289 del destRenames[reciprocal] 6290 6291 # Accumulate new tags & annotations for the transitions 6292 self.tagTransition(fromID, transition, tags) 6293 self.annotateTransition(fromID, transition, annotations) 6294 6295 if reciprocal is not None: 6296 self.tagTransition(connectID, reciprocal, revTags) 6297 self.annotateTransition(connectID, reciprocal, revAnnotations) 6298 6299 # Override copied requirement/consequences for the transitions 6300 if requirement is not None: 6301 self.setTransitionRequirement( 6302 fromID, 6303 transition, 6304 requirement 6305 ) 6306 if applyConsequence is not None: 6307 self.setConsequence( 6308 fromID, 6309 transition, 6310 applyConsequence 6311 ) 6312 6313 if reciprocal is not None: 6314 if revRequires is not None: 6315 self.setTransitionRequirement( 6316 connectID, 6317 reciprocal, 6318 revRequires 6319 ) 6320 if revConsequence is not None: 6321 self.setConsequence( 6322 connectID, 6323 reciprocal, 6324 revConsequence 6325 ) 6326 6327 # Remove 'unconfirmed' tag if it was present 6328 self.untagDecision(connectID, 'unconfirmed') 6329 6330 # Final checks 6331 assert self.getDestination(fromDecision, transition) == connectID 6332 useConnect: base.AnyDecisionSpecifier 6333 useRev: Optional[str] 6334 if connectTo is None: 6335 useConnect = connectID 6336 else: 6337 useConnect = connectTo 6338 if reciprocal is None: 6339 useRev = self.getReciprocal(fromDecision, transition) 6340 else: 6341 useRev = reciprocal 6342 if useRev is not None: 6343 try: 6344 assert self.getDestination(useConnect, useRev) == fromID 6345 except AmbiguousDecisionSpecifierError: 6346 assert self.getDestination(connectID, useRev) == fromID 6347 6348 # Return our final rename dictionaries 6349 return (destRenames, sourceRenames) 6350 6351 def endingID(self, name: base.DecisionName) -> base.DecisionID: 6352 """ 6353 Returns the decision ID for the ending with the specified name. 6354 Endings are disconnected decisions in the `ENDINGS_DOMAIN`; they 6355 don't normally include any zone information. If no ending with 6356 the specified name already existed, then a new ending with that 6357 name will be created and its Decision ID will be returned. 6358 6359 If a new decision is created, it will be tagged as unconfirmed. 6360 6361 Note that endings mostly aren't special: they're normal 6362 decisions in a separate singular-focalized domain. However, some 6363 parts of the exploration and journal machinery treat them 6364 differently (in particular, taking certain actions via 6365 `advanceSituation` while any decision in the `ENDINGS_DOMAIN` is 6366 active is an error. 6367 """ 6368 # Create our new ending decision if we need to 6369 try: 6370 endID = self.resolveDecision( 6371 base.DecisionSpecifier(ENDINGS_DOMAIN, None, name) 6372 ) 6373 except MissingDecisionError: 6374 # Create a new decision for the ending 6375 endID = self.addDecision(name, domain=ENDINGS_DOMAIN) 6376 # Tag it as unconfirmed 6377 self.tagDecision(endID, 'unconfirmed') 6378 6379 return endID 6380 6381 def triggerGroupID(self, name: base.DecisionName) -> base.DecisionID: 6382 """ 6383 Given the name of a trigger group, returns the ID of the special 6384 node representing that trigger group in the `TRIGGERS_DOMAIN`. 6385 If the specified group didn't already exist, it will be created. 6386 6387 Trigger group decisions are not special: they just exist in a 6388 separate spreading-focalized domain and have a few API methods to 6389 access them, but all the normal decision-related API methods 6390 still work. Their intended use is for sets of global triggers, 6391 by attaching actions with the 'trigger' tag to them and then 6392 activating or deactivating them as needed. 6393 """ 6394 result = self.getDecision( 6395 base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name) 6396 ) 6397 if result is None: 6398 return self.addDecision(name, domain=TRIGGERS_DOMAIN) 6399 else: 6400 return result 6401 6402 @staticmethod 6403 def example(which: Literal['simple', 'abc']) -> 'DecisionGraph': 6404 """ 6405 Returns one of a number of example decision graphs, depending on 6406 the string given. It returns a fresh copy each time. The graphs 6407 are: 6408 6409 - 'simple': Three nodes named 'A', 'B', and 'C' with IDs 0, 1, 6410 and 2, each connected to the next in the sequence by a 6411 'next' transition with reciprocal 'prev'. In other words, a 6412 simple little triangle. There are no tags, annotations, 6413 requirements, consequences, mechanisms, or equivalences. 6414 - 'abc': A more complicated 3-node setup that introduces a 6415 little bit of everything. In this graph, we have the same 6416 three nodes, but different transitions: 6417 6418 * From A you can go 'left' to B with reciprocal 'right'. 6419 * From A you can also go 'up_left' to B with reciprocal 6420 'up_right'. These transitions both require the 6421 'grate' mechanism (which is at decision A) to be in 6422 state 'open'. 6423 * From A you can go 'down' to C with reciprocal 'up'. 6424 6425 (In this graph, B and C are not directly connected to each 6426 other.) 6427 6428 The graph has two level-0 zones 'zoneA' and 'zoneB', along 6429 with a level-1 zone 'upZone'. Decisions A and C are in 6430 zoneA while B is in zoneB; zoneA is in upZone, but zoneB is 6431 not. 6432 6433 The decision A has annotation: 6434 6435 'This is a multi-word "annotation."' 6436 6437 The transition 'down' from A has annotation: 6438 6439 "Transition 'annotation.'" 6440 6441 Decision B has tags 'b' with value 1 and 'tag2' with value 6442 '"value"'. 6443 6444 Decision C has tag 'aw"ful' with value "ha'ha'". 6445 6446 Transition 'up' from C has tag 'fast' with value 1. 6447 6448 At decision C there are actions 'grab_helmet' and 6449 'pull_lever'. 6450 6451 The 'grab_helmet' transition requires that you don't have 6452 the 'helmet' capability, and gives you that capability, 6453 deactivating with delay 3. 6454 6455 The 'pull_lever' transition requires that you do have the 6456 'helmet' capability, and takes away that capability, but it 6457 also gives you 1 token, and if you have 2 tokens (before 6458 getting the one extra), it sets the 'grate' mechanism (which 6459 is a decision A) to state 'open' and deactivates. 6460 6461 The graph has an equivalence: having the 'helmet' capability 6462 satisfies requirements for the 'grate' mechanism to be in the 6463 'open' state. 6464 6465 """ 6466 result = DecisionGraph() 6467 if which == 'simple': 6468 result.addDecision('A') # id 0 6469 result.addDecision('B') # id 1 6470 result.addDecision('C') # id 2 6471 result.addTransition('A', 'next', 'B', 'prev') 6472 result.addTransition('B', 'next', 'C', 'prev') 6473 result.addTransition('C', 'next', 'A', 'prev') 6474 elif which == 'abc': 6475 result.addDecision('A') # id 0 6476 result.addDecision('B') # id 1 6477 result.addDecision('C') # id 2 6478 result.createZone('zoneA', 0) 6479 result.createZone('zoneB', 0) 6480 result.createZone('upZone', 1) 6481 result.addZoneToZone('zoneA', 'upZone') 6482 result.addDecisionToZone('A', 'zoneA') 6483 result.addDecisionToZone('B', 'zoneB') 6484 result.addDecisionToZone('C', 'zoneA') 6485 result.addTransition('A', 'left', 'B', 'right') 6486 result.addTransition('A', 'up_left', 'B', 'up_right') 6487 result.addTransition('A', 'down', 'C', 'up') 6488 result.setTransitionRequirement( 6489 'A', 6490 'up_left', 6491 base.ReqMechanism('grate', 'open') 6492 ) 6493 result.setTransitionRequirement( 6494 'B', 6495 'up_right', 6496 base.ReqMechanism('grate', 'open') 6497 ) 6498 result.annotateDecision('A', 'This is a multi-word "annotation."') 6499 result.annotateTransition('A', 'down', "Transition 'annotation.'") 6500 result.tagDecision('B', 'b') 6501 result.tagDecision('B', 'tag2', '"value"') 6502 result.tagDecision('C', 'aw"ful', "ha'ha") 6503 result.tagTransition('C', 'up', 'fast') 6504 result.addMechanism('grate', 'A') 6505 result.addAction( 6506 'C', 6507 'grab_helmet', 6508 base.ReqNot(base.ReqCapability('helmet')), 6509 [ 6510 base.effect(gain='helmet'), 6511 base.effect(deactivate=True, delay=3) 6512 ] 6513 ) 6514 result.addAction( 6515 'C', 6516 'pull_lever', 6517 base.ReqCapability('helmet'), 6518 [ 6519 base.effect(lose='helmet'), 6520 base.effect(gain=('token', 1)), 6521 base.condition( 6522 base.ReqTokens('token', 2), 6523 [ 6524 base.effect(set=('grate', 'open')), 6525 base.effect(deactivate=True) 6526 ] 6527 ) 6528 ] 6529 ) 6530 result.addEquivalence( 6531 base.ReqCapability('helmet'), 6532 (0, 'open') 6533 ) 6534 else: 6535 raise ValueError(f"Invalid example name: {which!r}") 6536 6537 return result 6538 6539 6540#---------------------------# 6541# DiscreteExploration class # 6542#---------------------------# 6543 6544def emptySituation() -> base.Situation: 6545 """ 6546 Creates and returns an empty situation: A situation that has an 6547 empty `DecisionGraph`, an empty `State`, a 'pending' decision type 6548 with `None` as the action taken, no tags, and no annotations. 6549 """ 6550 return base.Situation( 6551 graph=DecisionGraph(), 6552 state=base.emptyState(), 6553 type='pending', 6554 action=None, 6555 saves={}, 6556 tags={}, 6557 annotations=[] 6558 ) 6559 6560 6561class DiscreteExploration: 6562 """ 6563 A list of `Situations` each of which contains a `DecisionGraph` 6564 representing exploration over time, with `States` containing 6565 `FocalContext` information for each step and 'taken' values for the 6566 transition selected (at a particular decision) in that step. Each 6567 decision graph represents a new state of the world (and/or new 6568 knowledge about a persisting state of the world), and the 'taken' 6569 transition in one situation transition indicates which option was 6570 selected, or what event happened to cause update(s). Depending on the 6571 resolution, it could represent a close record of every decision made 6572 or a more coarse set of snapshots from gameplay with more time in 6573 between. 6574 6575 The steps of the exploration can also be tagged and annotated (see 6576 `tagStep` and `annotateStep`). 6577 6578 It also holds a `layouts` field that includes zero or more 6579 `base.Layout`s by name. 6580 6581 When a new `DiscreteExploration` is created, it starts out with an 6582 empty `Situation` that contains an empty `DecisionGraph`. Use the 6583 `start` method to name the starting decision point and set things up 6584 for other methods. 6585 6586 Tracking of player goals and destinations is also planned (see the 6587 `quest`, `progress`, `complete`, `destination`, and `arrive` methods). 6588 TODO: That 6589 """ 6590 def __init__(self) -> None: 6591 self.situations: List[base.Situation] = [ 6592 base.Situation( 6593 graph=DecisionGraph(), 6594 state=base.emptyState(), 6595 type='pending', 6596 action=None, 6597 saves={}, 6598 tags={}, 6599 annotations=[] 6600 ) 6601 ] 6602 self.layouts: Dict[str, base.Layout] = {} 6603 6604 # Note: not hashable 6605 6606 def __eq__(self, other): 6607 """ 6608 Equality checker. `DiscreteExploration`s can only be equal to 6609 other `DiscreteExploration`s, not to other kinds of things. 6610 """ 6611 if not isinstance(other, DiscreteExploration): 6612 return False 6613 else: 6614 return self.situations == other.situations 6615 6616 @staticmethod 6617 def fromGraph( 6618 graph: DecisionGraph, 6619 state: Optional[base.State] = None 6620 ) -> 'DiscreteExploration': 6621 """ 6622 Creates an exploration which has just a single step whose graph 6623 is the entire specified graph, with the specified decision as 6624 the primary decision (if any). The graph is copied, so that 6625 changes to the exploration will not modify it. A starting state 6626 may also be specified if desired, although if not an empty state 6627 will be used (a provided starting state is NOT copied, but used 6628 directly). 6629 6630 Example: 6631 6632 >>> g = DecisionGraph() 6633 >>> g.addDecision('Room1') 6634 0 6635 >>> g.addDecision('Room2') 6636 1 6637 >>> g.addTransition('Room1', 'door', 'Room2', 'door') 6638 >>> e = DiscreteExploration.fromGraph(g) 6639 >>> len(e) 6640 1 6641 >>> e.getSituation().graph == g 6642 True 6643 >>> e.getActiveDecisions() 6644 set() 6645 >>> e.primaryDecision() is None 6646 True 6647 >>> e.observe('Room1', 'hatch') 6648 2 6649 >>> e.getSituation().graph == g 6650 False 6651 >>> e.getSituation().graph.destinationsFrom('Room1') 6652 {'door': 1, 'hatch': 2} 6653 >>> g.destinationsFrom('Room1') 6654 {'door': 1} 6655 """ 6656 result = DiscreteExploration() 6657 result.situations[0] = base.Situation( 6658 graph=copy.deepcopy(graph), 6659 state=base.emptyState() if state is None else state, 6660 type='pending', 6661 action=None, 6662 saves={}, 6663 tags={}, 6664 annotations=[] 6665 ) 6666 return result 6667 6668 def __len__(self) -> int: 6669 """ 6670 The 'length' of an exploration is the number of steps. 6671 """ 6672 return len(self.situations) 6673 6674 def __getitem__(self, i: int) -> base.Situation: 6675 """ 6676 Indexing an exploration returns the situation at that step. 6677 """ 6678 return self.situations[i] 6679 6680 def __iter__(self) -> Iterator[base.Situation]: 6681 """ 6682 Iterating over an exploration yields each `Situation` in order. 6683 """ 6684 for i in range(len(self)): 6685 yield self[i] 6686 6687 def getSituation(self, step: int = -1) -> base.Situation: 6688 """ 6689 Returns a `base.Situation` named tuple detailing the state of 6690 the exploration at a given step (or at the current step if no 6691 argument is given). Note that this method works the same 6692 way as indexing the exploration: see `__getitem__`. 6693 6694 Raises an `IndexError` if asked for a step that's out-of-range. 6695 """ 6696 return self[step] 6697 6698 def primaryDecision(self, step: int = -1) -> Optional[base.DecisionID]: 6699 """ 6700 Returns the current primary `base.DecisionID`, or the primary 6701 decision from a specific step if one is specified. This may be 6702 `None` for some steps, but mostly it's the destination of the 6703 transition taken in the previous step. 6704 """ 6705 return self[step].state['primaryDecision'] 6706 6707 def effectiveCapabilities( 6708 self, 6709 step: int = -1 6710 ) -> base.CapabilitySet: 6711 """ 6712 Returns the effective capability set for the specified step 6713 (default is the last/current step). See 6714 `base.effectiveCapabilities`. 6715 """ 6716 return base.effectiveCapabilitySet(self.getSituation(step).state) 6717 6718 def getCommonContext( 6719 self, 6720 step: Optional[int] = None 6721 ) -> base.FocalContext: 6722 """ 6723 Returns the common `FocalContext` at the specified step, or at 6724 the current step if no argument is given. Raises an `IndexError` 6725 if an invalid step is specified. 6726 """ 6727 if step is None: 6728 step = -1 6729 state = self.getSituation(step).state 6730 return state['common'] 6731 6732 def getActiveContext( 6733 self, 6734 step: Optional[int] = None 6735 ) -> base.FocalContext: 6736 """ 6737 Returns the active `FocalContext` at the specified step, or at 6738 the current step if no argument is provided. Raises an 6739 `IndexError` if an invalid step is specified. 6740 """ 6741 if step is None: 6742 step = -1 6743 state = self.getSituation(step).state 6744 return state['contexts'][state['activeContext']] 6745 6746 def addFocalContext(self, name: base.FocalContextName) -> None: 6747 """ 6748 Adds a new empty focal context to our set of focal contexts (see 6749 `emptyFocalContext`). Use `setActiveContext` to swap to it. 6750 Raises a `FocalContextCollisionError` if the name is already in 6751 use. 6752 """ 6753 contextMap = self.getSituation().state['contexts'] 6754 if name in contextMap: 6755 raise FocalContextCollisionError( 6756 f"Cannot add focal context {name!r}: a focal context" 6757 f" with that name already exists." 6758 ) 6759 contextMap[name] = base.emptyFocalContext() 6760 6761 def setActiveContext(self, which: base.FocalContextName) -> None: 6762 """ 6763 Sets the active context to the named focal context, creating it 6764 if it did not already exist (makes changes to the current 6765 situation only). Does not add an exploration step (use 6766 `advanceSituation` with a 'swap' action for that). 6767 """ 6768 state = self.getSituation().state 6769 contextMap = state['contexts'] 6770 if which not in contextMap: 6771 self.addFocalContext(which) 6772 state['activeContext'] = which 6773 6774 def createDomain( 6775 self, 6776 name: base.Domain, 6777 focalization: base.DomainFocalization = 'singular', 6778 makeActive: bool = False, 6779 inCommon: Union[bool, Literal["both"]] = "both" 6780 ) -> None: 6781 """ 6782 Creates a new domain with the given focalization type, in either 6783 the common context (`inCommon` = `True`) the active context 6784 (`inCommon` = `False`) or both (the default; `inCommon` = 'both'). 6785 The domain's focalization will be set to the given 6786 `focalization` value (default 'singular') and it will have no 6787 active decisions. Raises a `DomainCollisionError` if a domain 6788 with the specified name already exists. 6789 6790 Creates the domain in the current situation. 6791 6792 If `makeActive` is set to `True` (default is `False`) then the 6793 domain will be made active in whichever context(s) it's created 6794 in. 6795 """ 6796 now = self.getSituation() 6797 state = now.state 6798 modify = [] 6799 if inCommon in (True, "both"): 6800 modify.append(('common', state['common'])) 6801 if inCommon in (False, "both"): 6802 acName = state['activeContext'] 6803 modify.append( 6804 ('current ({repr(acName)})', state['contexts'][acName]) 6805 ) 6806 6807 for (fcType, fc) in modify: 6808 if name in fc['focalization']: 6809 raise DomainCollisionError( 6810 f"Cannot create domain {repr(name)} because a" 6811 f" domain with that name already exists in the" 6812 f" {fcType} focal context." 6813 ) 6814 fc['focalization'][name] = focalization 6815 if makeActive: 6816 fc['activeDomains'].add(name) 6817 if focalization == "spreading": 6818 fc['activeDecisions'][name] = set() 6819 elif focalization == "plural": 6820 fc['activeDecisions'][name] = {} 6821 else: 6822 fc['activeDecisions'][name] = None 6823 6824 def activateDomain( 6825 self, 6826 domain: base.Domain, 6827 activate: bool = True, 6828 inContext: base.ContextSpecifier = "active" 6829 ) -> None: 6830 """ 6831 Sets the given domain as active (or inactive if 'activate' is 6832 given as `False`) in the specified context (default "active"). 6833 6834 Modifies the current situation. 6835 """ 6836 fc: base.FocalContext 6837 if inContext == "active": 6838 fc = self.getActiveContext() 6839 elif inContext == "common": 6840 fc = self.getCommonContext() 6841 6842 if activate: 6843 fc['activeDomains'].add(domain) 6844 else: 6845 try: 6846 fc['activeDomains'].remove(domain) 6847 except KeyError: 6848 pass 6849 6850 def createTriggerGroup( 6851 self, 6852 name: base.DecisionName 6853 ) -> base.DecisionID: 6854 """ 6855 Creates a new trigger group with the given name, returning the 6856 decision ID for that trigger group. If this is the first trigger 6857 group being created, also creates the `TRIGGERS_DOMAIN` domain 6858 as a spreading-focalized domain that's active in the common 6859 context (but does NOT set the created trigger group as an active 6860 decision in that domain). 6861 6862 You can use 'goto' effects to activate trigger domains via 6863 consequences, and 'retreat' effects to deactivate them. 6864 6865 Creating a second trigger group with the same name as another 6866 results in a `ValueError`. 6867 6868 TODO: Retreat effects 6869 """ 6870 ctx = self.getCommonContext() 6871 if TRIGGERS_DOMAIN not in ctx['focalization']: 6872 self.createDomain( 6873 TRIGGERS_DOMAIN, 6874 focalization='spreading', 6875 makeActive=True, 6876 inCommon=True 6877 ) 6878 6879 graph = self.getSituation().graph 6880 if graph.getDecision( 6881 base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name) 6882 ) is not None: 6883 raise ValueError( 6884 f"Cannot create trigger group {name!r}: a trigger group" 6885 f" with that name already exists." 6886 ) 6887 6888 return self.getSituation().graph.triggerGroupID(name) 6889 6890 def toggleTriggerGroup( 6891 self, 6892 name: base.DecisionName, 6893 setActive: Union[bool, None] = None 6894 ): 6895 """ 6896 Toggles whether the specified trigger group (a decision in the 6897 `TRIGGERS_DOMAIN`) is active or not. Pass `True` or `False` as 6898 the `setActive` argument (instead of the default `None`) to set 6899 the state directly instead of toggling it. 6900 6901 Note that trigger groups are decisions in a spreading-focalized 6902 domain, so they can be activated or deactivated by the 'goto' 6903 and 'retreat' effects as well. 6904 6905 This does not affect whether the `TRIGGERS_DOMAIN` itself is 6906 active (normally it would always be active). 6907 6908 Raises a `MissingDecisionError` if the specified trigger group 6909 does not exist yet, including when the entire `TRIGGERS_DOMAIN` 6910 does not exist. Raises a `KeyError` if the target group exists 6911 but the `TRIGGERS_DOMAIN` has not been set up properly. 6912 """ 6913 ctx = self.getCommonContext() 6914 tID = self.getSituation().graph.resolveDecision( 6915 base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name) 6916 ) 6917 activeGroups = ctx['activeDecisions'][TRIGGERS_DOMAIN] 6918 assert isinstance(activeGroups, set) 6919 if tID in activeGroups: 6920 if setActive is not True: 6921 activeGroups.remove(tID) 6922 else: 6923 if setActive is not False: 6924 activeGroups.add(tID) 6925 6926 def getActiveDecisions( 6927 self, 6928 step: Optional[int] = None, 6929 inCommon: Union[bool, Literal["both"]] = "both" 6930 ) -> Set[base.DecisionID]: 6931 """ 6932 Returns the set of active decisions at the given step index, or 6933 at the current step if no step is specified. Raises an 6934 `IndexError` if the step index is out of bounds (see `__len__`). 6935 May return an empty set if no decisions are active. 6936 6937 If `inCommon` is set to "both" (the default) then decisions 6938 active in either the common or active context are returned. Set 6939 it to `True` or `False` to return only decisions active in the 6940 common (when `True`) or active (when `False`) context. 6941 """ 6942 if step is None: 6943 step = -1 6944 state = self.getSituation(step).state 6945 if inCommon == "both": 6946 return base.combinedDecisionSet(state) 6947 elif inCommon is True: 6948 return base.activeDecisionSet(state['common']) 6949 elif inCommon is False: 6950 return base.activeDecisionSet( 6951 state['contexts'][state['activeContext']] 6952 ) 6953 else: 6954 raise ValueError( 6955 f"Invalid inCommon value {repr(inCommon)} (must be" 6956 f" 'both', True, or False)." 6957 ) 6958 6959 def setActiveDecisionsAtStep( 6960 self, 6961 step: int, 6962 domain: base.Domain, 6963 activate: Union[ 6964 base.DecisionID, 6965 Dict[base.FocalPointName, Optional[base.DecisionID]], 6966 Set[base.DecisionID] 6967 ], 6968 inCommon: bool = False 6969 ) -> None: 6970 """ 6971 Changes the activation status of decisions in the active 6972 `FocalContext` at the specified step, for the specified domain 6973 (see `currentActiveContext`). Does this without adding an 6974 exploration step, which is unusual: normally you should use 6975 another method like `warp` to update active decisions. 6976 6977 Note that this does not change which domains are active, and 6978 setting active decisions in inactive domains does not make those 6979 decisions active overall. 6980 6981 Which decisions to activate or deactivate are specified as 6982 either a single `DecisionID`, a list of them, or a set of them, 6983 depending on the `DomainFocalization` setting in the selected 6984 `FocalContext` for the specified domain. A `TypeError` will be 6985 raised if the wrong kind of decision information is provided. If 6986 the focalization context does not have any focalization value for 6987 the domain in question, it will be set based on the kind of 6988 active decision information specified. 6989 6990 A `MissingDecisionError` will be raised if a decision is 6991 included which is not part of the current `DecisionGraph`. 6992 The provided information will overwrite the previous active 6993 decision information. 6994 6995 If `inCommon` is set to `True`, then decisions are activated or 6996 deactivated in the common context, instead of in the active 6997 context. 6998 6999 Example: 7000 7001 >>> e = DiscreteExploration() 7002 >>> e.getActiveDecisions() 7003 set() 7004 >>> graph = e.getSituation().graph 7005 >>> graph.addDecision('A') 7006 0 7007 >>> graph.addDecision('B') 7008 1 7009 >>> graph.addDecision('C') 7010 2 7011 >>> e.setActiveDecisionsAtStep(0, 'main', 0) 7012 >>> e.getActiveDecisions() 7013 {0} 7014 >>> e.setActiveDecisionsAtStep(0, 'main', 1) 7015 >>> e.getActiveDecisions() 7016 {1} 7017 >>> graph = e.getSituation().graph 7018 >>> graph.addDecision('One', domain='numbers') 7019 3 7020 >>> graph.addDecision('Two', domain='numbers') 7021 4 7022 >>> graph.addDecision('Three', domain='numbers') 7023 5 7024 >>> graph.addDecision('Bear', domain='animals') 7025 6 7026 >>> graph.addDecision('Spider', domain='animals') 7027 7 7028 >>> graph.addDecision('Eel', domain='animals') 7029 8 7030 >>> ac = e.getActiveContext() 7031 >>> ac['focalization']['numbers'] = 'plural' 7032 >>> ac['focalization']['animals'] = 'spreading' 7033 >>> ac['activeDecisions']['numbers'] = {'a': None, 'b': None} 7034 >>> ac['activeDecisions']['animals'] = set() 7035 >>> cc = e.getCommonContext() 7036 >>> cc['focalization']['numbers'] = 'plural' 7037 >>> cc['focalization']['animals'] = 'spreading' 7038 >>> cc['activeDecisions']['numbers'] = {'z': None} 7039 >>> cc['activeDecisions']['animals'] = set() 7040 >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 3, 'b': 3}) 7041 >>> e.getActiveDecisions() 7042 {1} 7043 >>> e.activateDomain('numbers') 7044 >>> e.getActiveDecisions() 7045 {1, 3} 7046 >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 4, 'b': None}) 7047 >>> e.getActiveDecisions() 7048 {1, 4} 7049 >>> # Wrong domain for the decision ID: 7050 >>> e.setActiveDecisionsAtStep(0, 'main', 3) 7051 Traceback (most recent call last): 7052 ... 7053 ValueError... 7054 >>> # Wrong domain for one of the decision IDs: 7055 >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 2, 'b': None}) 7056 Traceback (most recent call last): 7057 ... 7058 ValueError... 7059 >>> # Wrong kind of decision information provided. 7060 >>> e.setActiveDecisionsAtStep(0, 'numbers', 3) 7061 Traceback (most recent call last): 7062 ... 7063 TypeError... 7064 >>> e.getActiveDecisions() 7065 {1, 4} 7066 >>> e.setActiveDecisionsAtStep(0, 'animals', {6, 7}) 7067 >>> e.getActiveDecisions() 7068 {1, 4} 7069 >>> e.activateDomain('animals') 7070 >>> e.getActiveDecisions() 7071 {1, 4, 6, 7} 7072 >>> e.setActiveDecisionsAtStep(0, 'animals', {8}) 7073 >>> e.getActiveDecisions() 7074 {8, 1, 4} 7075 >>> e.setActiveDecisionsAtStep(1, 'main', 2) # invalid step 7076 Traceback (most recent call last): 7077 ... 7078 IndexError... 7079 >>> e.setActiveDecisionsAtStep(0, 'novel', 0) # domain mismatch 7080 Traceback (most recent call last): 7081 ... 7082 ValueError... 7083 7084 Example of active/common contexts: 7085 7086 >>> e = DiscreteExploration() 7087 >>> graph = e.getSituation().graph 7088 >>> graph.addDecision('A') 7089 0 7090 >>> graph.addDecision('B') 7091 1 7092 >>> e.activateDomain('main', inContext="common") 7093 >>> e.setActiveDecisionsAtStep(0, 'main', 0, inCommon=True) 7094 >>> e.getActiveDecisions() 7095 {0} 7096 >>> e.setActiveDecisionsAtStep(0, 'main', None) 7097 >>> e.getActiveDecisions() 7098 {0} 7099 >>> # (Still active since it's active in the common context) 7100 >>> e.setActiveDecisionsAtStep(0, 'main', 1) 7101 >>> e.getActiveDecisions() 7102 {0, 1} 7103 >>> e.setActiveDecisionsAtStep(0, 'main', 1, inCommon=True) 7104 >>> e.getActiveDecisions() 7105 {1} 7106 >>> e.setActiveDecisionsAtStep(0, 'main', None, inCommon=True) 7107 >>> e.getActiveDecisions() 7108 {1} 7109 >>> # (Still active since it's active in the active context) 7110 >>> e.setActiveDecisionsAtStep(0, 'main', None) 7111 >>> e.getActiveDecisions() 7112 set() 7113 """ 7114 now = self.getSituation(step) 7115 graph = now.graph 7116 if inCommon: 7117 context = self.getCommonContext(step) 7118 else: 7119 context = self.getActiveContext(step) 7120 7121 defaultFocalization: base.DomainFocalization = 'singular' 7122 if isinstance(activate, base.DecisionID): 7123 defaultFocalization = 'singular' 7124 elif isinstance(activate, dict): 7125 defaultFocalization = 'plural' 7126 elif isinstance(activate, set): 7127 defaultFocalization = 'spreading' 7128 elif domain not in context['focalization']: 7129 raise TypeError( 7130 f"Domain {domain!r} has no focalization in the" 7131 f" {'common' if inCommon else 'active'} context," 7132 f" and the specified position doesn't imply one." 7133 ) 7134 7135 focalization = base.getDomainFocalization( 7136 context, 7137 domain, 7138 defaultFocalization 7139 ) 7140 7141 # Check domain & existence of decision(s) in question 7142 if activate is None: 7143 pass 7144 elif isinstance(activate, base.DecisionID): 7145 if activate not in graph: 7146 raise MissingDecisionError( 7147 f"There is no decision {activate} at step {step}." 7148 ) 7149 if graph.domainFor(activate) != domain: 7150 raise ValueError( 7151 f"Can't set active decisions in domain {domain!r}" 7152 f" to decision {graph.identityOf(activate)} because" 7153 f" that decision is in actually in domain" 7154 f" {graph.domainFor(activate)!r}." 7155 ) 7156 elif isinstance(activate, dict): 7157 for fpName, pos in activate.items(): 7158 if pos is None: 7159 continue 7160 if pos not in graph: 7161 raise MissingDecisionError( 7162 f"There is no decision {pos} at step {step}." 7163 ) 7164 if graph.domainFor(pos) != domain: 7165 raise ValueError( 7166 f"Can't set active decision for focal point" 7167 f" {fpName!r} in domain {domain!r}" 7168 f" to decision {graph.identityOf(pos)} because" 7169 f" that decision is in actually in domain" 7170 f" {graph.domainFor(pos)!r}." 7171 ) 7172 elif isinstance(activate, set): 7173 for pos in activate: 7174 if pos not in graph: 7175 raise MissingDecisionError( 7176 f"There is no decision {pos} at step {step}." 7177 ) 7178 if graph.domainFor(pos) != domain: 7179 raise ValueError( 7180 f"Can't set {graph.identityOf(pos)} as an" 7181 f" active decision in domain {domain!r} to" 7182 f" decision because that decision is in" 7183 f" actually in domain {graph.domainFor(pos)!r}." 7184 ) 7185 else: 7186 raise TypeError( 7187 f"Domain {domain!r} has no focalization in the" 7188 f" {'common' if inCommon else 'active'} context," 7189 f" and the specified position doesn't imply one:" 7190 f"\n{activate!r}" 7191 ) 7192 7193 if focalization == 'singular': 7194 if activate is None or isinstance(activate, base.DecisionID): 7195 if activate is not None: 7196 targetDomain = graph.domainFor(activate) 7197 if activate not in graph: 7198 raise MissingDecisionError( 7199 f"There is no decision {activate} in the" 7200 f" graph at step {step}." 7201 ) 7202 elif targetDomain != domain: 7203 raise ValueError( 7204 f"At step {step}, decision {activate} cannot" 7205 f" be the active decision for domain" 7206 f" {repr(domain)} because it is in a" 7207 f" different domain ({repr(targetDomain)})." 7208 ) 7209 context['activeDecisions'][domain] = activate 7210 else: 7211 raise TypeError( 7212 f"{'Common' if inCommon else 'Active'} focal" 7213 f" context at step {step} has {repr(focalization)}" 7214 f" focalization for domain {repr(domain)}, so the" 7215 f" active decision must be a single decision or" 7216 f" None.\n(You provided: {repr(activate)})" 7217 ) 7218 elif focalization == 'plural': 7219 if ( 7220 isinstance(activate, dict) 7221 and all( 7222 isinstance(k, base.FocalPointName) 7223 for k in activate.keys() 7224 ) 7225 and all( 7226 v is None or isinstance(v, base.DecisionID) 7227 for v in activate.values() 7228 ) 7229 ): 7230 for v in activate.values(): 7231 if v is not None: 7232 targetDomain = graph.domainFor(v) 7233 if v not in graph: 7234 raise MissingDecisionError( 7235 f"There is no decision {v} in the graph" 7236 f" at step {step}." 7237 ) 7238 elif targetDomain != domain: 7239 raise ValueError( 7240 f"At step {step}, decision {activate}" 7241 f" cannot be an active decision for" 7242 f" domain {repr(domain)} because it is" 7243 f" in a different domain" 7244 f" ({repr(targetDomain)})." 7245 ) 7246 context['activeDecisions'][domain] = activate 7247 else: 7248 raise TypeError( 7249 f"{'Common' if inCommon else 'Active'} focal" 7250 f" context at step {step} has {repr(focalization)}" 7251 f" focalization for domain {repr(domain)}, so the" 7252 f" active decision must be a dictionary mapping" 7253 f" focal point names to decision IDs (or Nones)." 7254 f"\n(You provided: {repr(activate)})" 7255 ) 7256 elif focalization == 'spreading': 7257 if ( 7258 isinstance(activate, set) 7259 and all(isinstance(x, base.DecisionID) for x in activate) 7260 ): 7261 for x in activate: 7262 targetDomain = graph.domainFor(x) 7263 if x not in graph: 7264 raise MissingDecisionError( 7265 f"There is no decision {x} in the graph" 7266 f" at step {step}." 7267 ) 7268 elif targetDomain != domain: 7269 raise ValueError( 7270 f"At step {step}, decision {activate}" 7271 f" cannot be an active decision for" 7272 f" domain {repr(domain)} because it is" 7273 f" in a different domain" 7274 f" ({repr(targetDomain)})." 7275 ) 7276 context['activeDecisions'][domain] = activate 7277 else: 7278 raise TypeError( 7279 f"{'Common' if inCommon else 'Active'} focal" 7280 f" context at step {step} has {repr(focalization)}" 7281 f" focalization for domain {repr(domain)}, so the" 7282 f" active decision must be a set of decision IDs" 7283 f"\n(You provided: {repr(activate)})" 7284 ) 7285 else: 7286 raise RuntimeError( 7287 f"Invalid focalization value {repr(focalization)} for" 7288 f" domain {repr(domain)} at step {step}." 7289 ) 7290 7291 def movementAtStep(self, step: int = -1) -> Tuple[ 7292 Union[base.DecisionID, Set[base.DecisionID], None], 7293 Optional[base.Transition], 7294 Union[base.DecisionID, Set[base.DecisionID], None] 7295 ]: 7296 """ 7297 Given a step number, returns information about the starting 7298 decision, transition taken, and destination decision for that 7299 step. Not all steps have all of those, so some items may be 7300 `None`. 7301 7302 For steps where there is no action, where a decision is still 7303 pending, or where the action type is 'focus', 'swap', 'focalize', 7304 or 'revertTo', the result will be `(None, None, None)`, unless a 7305 primary decision is available in which case the first item in the 7306 tuple will be that decision. For 'start' actions, the starting 7307 position and transition will be `None` (again unless the step had 7308 a primary decision) but the destination will be the ID of the 7309 node started at. For 'revertTo' actions, the destination will be 7310 the primary decision of the state reverted to, if available. 7311 7312 Also, if the action taken has multiple potential or actual start 7313 or end points, these may be sets of decision IDs instead of 7314 single IDs. 7315 7316 Note that the primary decision of the starting state is usually 7317 used as the from-decision, but in some cases an action dictates 7318 taking a transition from a different decision, and this function 7319 will return that decision as the from-decision. 7320 7321 TODO: Examples! 7322 7323 TODO: Account for bounce/follow/goto effects!!! 7324 """ 7325 now = self.getSituation(step) 7326 action = now.action 7327 graph = now.graph 7328 primary = now.state['primaryDecision'] 7329 7330 if action is None: 7331 return (primary, None, None) 7332 7333 aType = action[0] 7334 fromID: Optional[base.DecisionID] 7335 destID: Optional[base.DecisionID] 7336 transition: base.Transition 7337 outcomes: List[bool] 7338 7339 if aType in ('noAction', 'focus', 'swap', 'focalize'): 7340 return (primary, None, None) 7341 elif aType == 'start': 7342 assert len(action) == 7 7343 where = cast( 7344 Union[ 7345 base.DecisionID, 7346 Dict[base.FocalPointName, base.DecisionID], 7347 Set[base.DecisionID] 7348 ], 7349 action[1] 7350 ) 7351 if isinstance(where, dict): 7352 where = set(where.values()) 7353 return (primary, None, where) 7354 elif aType in ('take', 'explore'): 7355 if ( 7356 (len(action) == 4 or len(action) == 7) 7357 and isinstance(action[2], base.DecisionID) 7358 ): 7359 fromID = action[2] 7360 assert isinstance(action[3], tuple) 7361 transition, outcomes = action[3] 7362 if ( 7363 action[0] == "explore" 7364 and isinstance(action[4], base.DecisionID) 7365 ): 7366 destID = action[4] 7367 else: 7368 destID = graph.getDestination(fromID, transition) 7369 return (fromID, transition, destID) 7370 elif ( 7371 (len(action) == 3 or len(action) == 6) 7372 and isinstance(action[1], tuple) 7373 and isinstance(action[2], base.Transition) 7374 and len(action[1]) == 3 7375 and action[1][0] in get_args(base.ContextSpecifier) 7376 and isinstance(action[1][1], base.Domain) 7377 and isinstance(action[1][2], base.FocalPointName) 7378 ): 7379 fromID = base.resolvePosition(now, action[1]) 7380 if fromID is None: 7381 raise InvalidActionError( 7382 f"{aType!r} action at step {step} has position" 7383 f" {action[1]!r} which cannot be resolved to a" 7384 f" decision." 7385 ) 7386 transition, outcomes = action[2] 7387 if ( 7388 action[0] == "explore" 7389 and isinstance(action[3], base.DecisionID) 7390 ): 7391 destID = action[3] 7392 else: 7393 destID = graph.getDestination(fromID, transition) 7394 return (fromID, transition, destID) 7395 else: 7396 raise InvalidActionError( 7397 f"Malformed {aType!r} action:\n{repr(action)}" 7398 ) 7399 elif aType == 'warp': 7400 if len(action) != 3: 7401 raise InvalidActionError( 7402 f"Malformed 'warp' action:\n{repr(action)}" 7403 ) 7404 dest = action[2] 7405 assert isinstance(dest, base.DecisionID) 7406 if action[1] in get_args(base.ContextSpecifier): 7407 # Unspecified starting point; find active decisions in 7408 # same domain if primary is None 7409 if primary is not None: 7410 return (primary, None, dest) 7411 else: 7412 toDomain = now.graph.domainFor(dest) 7413 # TODO: Could check destination focalization here... 7414 active = self.getActiveDecisions(step) 7415 sameDomain = set( 7416 dID 7417 for dID in active 7418 if now.graph.domainFor(dID) == toDomain 7419 ) 7420 if len(sameDomain) == 1: 7421 return ( 7422 list(sameDomain)[0], 7423 None, 7424 dest 7425 ) 7426 else: 7427 return ( 7428 sameDomain, 7429 None, 7430 dest 7431 ) 7432 else: 7433 if ( 7434 not isinstance(action[1], tuple) 7435 or not len(action[1]) == 3 7436 or not action[1][0] in get_args(base.ContextSpecifier) 7437 or not isinstance(action[1][1], base.Domain) 7438 or not isinstance(action[1][2], base.FocalPointName) 7439 ): 7440 raise InvalidActionError( 7441 f"Malformed 'warp' action:\n{repr(action)}" 7442 ) 7443 return ( 7444 base.resolvePosition(now, action[1]), 7445 None, 7446 dest 7447 ) 7448 elif aType == 'revertTo': 7449 assert len(action) == 3 # type, save slot, & aspects 7450 if primary is not None: 7451 cameFrom = primary 7452 nextSituation = self.getSituation(step + 1) 7453 wentTo = nextSituation.state['primaryDecision'] 7454 return (primary, None, wentTo) 7455 else: 7456 raise InvalidActionError( 7457 f"Action taken had invalid action type {repr(aType)}:" 7458 f"\n{repr(action)}" 7459 ) 7460 7461 def latestStepWithDecision( 7462 self, 7463 dID: base.DecisionID, 7464 startFrom: int = -1 7465 ) -> int: 7466 """ 7467 Scans backwards through exploration steps until it finds a graph 7468 that contains a decision with the specified ID, and returns the 7469 step number of that step. Instead of starting from the last step, 7470 you can tell it to start from a different step (either positive 7471 or negative index) via `startFrom`. Raises a 7472 `MissingDecisionError` if there is no such step. 7473 """ 7474 if startFrom < 0: 7475 startFrom = len(self) + startFrom 7476 for step in range(startFrom, -1, -1): 7477 graph = self.getSituation(step).graph 7478 try: 7479 return step 7480 except MissingDecisionError: 7481 continue 7482 raise MissingDecisionError( 7483 f"Decision {dID!r} does not exist at any step of the" 7484 f" exploration." 7485 ) 7486 7487 def latestDecisionInfo(self, dID: base.DecisionID) -> DecisionInfo: 7488 """ 7489 Looks up decision info for the given decision in the latest step 7490 in which that decision exists (which will usually be the final 7491 exploration step, unless the decision was merged or otherwise 7492 removed along the way). This will raise a `MissingDecisionError` 7493 only if there is no step at which the specified decision exists. 7494 """ 7495 for step in range(len(self) - 1, -1, -1): 7496 graph = self.getSituation(step).graph 7497 try: 7498 return graph.decisionInfo(dID) 7499 except MissingDecisionError: 7500 continue 7501 raise MissingDecisionError( 7502 f"Decision {dID!r} does not exist at any step of the" 7503 f" exploration." 7504 ) 7505 7506 def latestTransitionProperties( 7507 self, 7508 dID: base.DecisionID, 7509 transition: base.Transition 7510 ) -> TransitionProperties: 7511 """ 7512 Looks up transition properties for the transition with the given 7513 name outgoing from the decision with the given ID, in the latest 7514 step in which a transiiton with that name from that decision 7515 exists (which will usually be the final exploration step, unless 7516 transitions get removed/renamed along the way). Note that because 7517 a transition can be deleted and later added back (unlike 7518 decisions where an ID will not be re-used), it's possible there 7519 are two or more different transitions that meet the 7520 specifications at different points in time, and this will always 7521 return the properties of the last of them. This will raise a 7522 `MissingDecisionError` if there is no step at which the specified 7523 decision exists, and a `MissingTransitionError` if the target 7524 decision exists at some step but never has a transition with the 7525 specified name. 7526 """ 7527 sawDecision: Optional[int] = None 7528 for step in range(len(self) - 1, -1, -1): 7529 graph = self.getSituation(step).graph 7530 try: 7531 return graph.getTransitionProperties(dID, transition) 7532 except (MissingDecisionError, MissingTransitionError) as e: 7533 if ( 7534 sawDecision is None 7535 and isinstance(e, MissingTransitionError) 7536 ): 7537 sawDecision = step 7538 continue 7539 if sawDecision is None: 7540 raise MissingDecisionError( 7541 f"Decision {dID!r} does not exist at any step of the" 7542 f" exploration." 7543 ) 7544 else: 7545 raise MissingTransitionError( 7546 f"Decision {dID!r} does exist (last seen at step" 7547 f" {sawDecision}) but it never has an outgoing" 7548 f" transition named {transition!r}." 7549 ) 7550 7551 def tagStep( 7552 self, 7553 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 7554 tagValue: Union[ 7555 base.TagValue, 7556 type[base.NoTagValue] 7557 ] = base.NoTagValue, 7558 step: int = -1 7559 ) -> None: 7560 """ 7561 Adds a tag (or multiple tags) to the current step, or to a 7562 specific step if `n` is given as an integer rather than the 7563 default `None`. A tag value should be supplied when a tag is 7564 given (unless you want to use the default of `1`), but it's a 7565 `ValueError` to supply a tag value when a dictionary of tags to 7566 update is provided. 7567 """ 7568 if isinstance(tagOrTags, base.Tag): 7569 if tagValue is base.NoTagValue: 7570 tagValue = 1 7571 7572 # Not sure why this is necessary... 7573 tagValue = cast(base.TagValue, tagValue) 7574 7575 self.getSituation(step).tags.update({tagOrTags: tagValue}) 7576 else: 7577 self.getSituation(step).tags.update(tagOrTags) 7578 7579 def annotateStep( 7580 self, 7581 annotationOrAnnotations: Union[ 7582 base.Annotation, 7583 Sequence[base.Annotation] 7584 ], 7585 step: Optional[int] = None 7586 ) -> None: 7587 """ 7588 Adds an annotation to the current exploration step, or to a 7589 specific step if `n` is given as an integer rather than the 7590 default `None`. 7591 """ 7592 if step is None: 7593 step = -1 7594 if isinstance(annotationOrAnnotations, base.Annotation): 7595 self.getSituation(step).annotations.append( 7596 annotationOrAnnotations 7597 ) 7598 else: 7599 self.getSituation(step).annotations.extend( 7600 annotationOrAnnotations 7601 ) 7602 7603 def hasCapability( 7604 self, 7605 capability: base.Capability, 7606 step: Optional[int] = None, 7607 inCommon: Union[bool, Literal['both']] = "both" 7608 ) -> bool: 7609 """ 7610 Returns True if the player currently had the specified 7611 capability, at the specified exploration step, and False 7612 otherwise. Checks the current state if no step is given. Does 7613 NOT return true if the game state means that the player has an 7614 equivalent for that capability (see 7615 `hasCapabilityOrEquivalent`). 7616 7617 Normally, `inCommon` is set to 'both' by default and so if 7618 either the common `FocalContext` or the active one has the 7619 capability, this will return `True`. `inCommon` may instead be 7620 set to `True` or `False` to ask about just the common (or 7621 active) focal context. 7622 """ 7623 state = self.getSituation().state 7624 commonCapabilities = state['common']['capabilities']\ 7625 ['capabilities'] # noqa 7626 activeCapabilities = state['contexts'][state['activeContext']]\ 7627 ['capabilities']['capabilities'] # noqa 7628 7629 if inCommon == 'both': 7630 return ( 7631 capability in commonCapabilities 7632 or capability in activeCapabilities 7633 ) 7634 elif inCommon is True: 7635 return capability in commonCapabilities 7636 elif inCommon is False: 7637 return capability in activeCapabilities 7638 else: 7639 raise ValueError( 7640 f"Invalid inCommon value (must be False, True, or" 7641 f" 'both'; got {repr(inCommon)})." 7642 ) 7643 7644 def hasCapabilityOrEquivalent( 7645 self, 7646 capability: base.Capability, 7647 step: Optional[int] = None, 7648 location: Optional[Set[base.DecisionID]] = None 7649 ) -> bool: 7650 """ 7651 Works like `hasCapability`, but also returns `True` if the 7652 player counts as having the specified capability via an equivalence 7653 that's part of the current graph. As with `hasCapability`, the 7654 optional `step` argument is used to specify which step to check, 7655 with the current step being used as the default. 7656 7657 The `location` set can specify where to start looking for 7658 mechanisms; if left unspecified active decisions for that step 7659 will be used. 7660 """ 7661 if step is None: 7662 step = -1 7663 if location is None: 7664 location = self.getActiveDecisions(step) 7665 situation = self.getSituation(step) 7666 return base.hasCapabilityOrEquivalent( 7667 capability, 7668 base.RequirementContext( 7669 state=situation.state, 7670 graph=situation.graph, 7671 searchFrom=location 7672 ) 7673 ) 7674 7675 def gainCapabilityNow( 7676 self, 7677 capability: base.Capability, 7678 inCommon: bool = False 7679 ) -> None: 7680 """ 7681 Modifies the current game state to add the specified `Capability` 7682 to the player's capabilities. No changes are made to the current 7683 graph. 7684 7685 If `inCommon` is set to `True` (default is `False`) then the 7686 capability will be added to the common `FocalContext` and will 7687 therefore persist even when a focal context switch happens. 7688 Normally, it will be added to the currently-active focal 7689 context. 7690 """ 7691 state = self.getSituation().state 7692 if inCommon: 7693 context = state['common'] 7694 else: 7695 context = state['contexts'][state['activeContext']] 7696 context['capabilities']['capabilities'].add(capability) 7697 7698 def loseCapabilityNow( 7699 self, 7700 capability: base.Capability, 7701 inCommon: Union[bool, Literal['both']] = "both" 7702 ) -> None: 7703 """ 7704 Modifies the current game state to remove the specified `Capability` 7705 from the player's capabilities. Does nothing if the player 7706 doesn't already have that capability. 7707 7708 By default, this removes the capability from both the common 7709 capabilities set and the active `FocalContext`'s capabilities 7710 set, so that afterwards the player will definitely not have that 7711 capability. However, if you set `inCommon` to either `True` or 7712 `False`, it will remove the capability from just the common 7713 capabilities set (if `True`) or just the active capabilities set 7714 (if `False`). In these cases, removing the capability from just 7715 one capability set will not actually remove it in terms of the 7716 `hasCapability` result if it had been present in the other set. 7717 Set `inCommon` to "both" to use the default behavior explicitly. 7718 """ 7719 now = self.getSituation() 7720 if inCommon in ("both", True): 7721 context = now.state['common'] 7722 try: 7723 context['capabilities']['capabilities'].remove(capability) 7724 except KeyError: 7725 pass 7726 elif inCommon in ("both", False): 7727 context = now.state['contexts'][now.state['activeContext']] 7728 try: 7729 context['capabilities']['capabilities'].remove(capability) 7730 except KeyError: 7731 pass 7732 else: 7733 raise ValueError( 7734 f"Invalid inCommon value (must be False, True, or" 7735 f" 'both'; got {repr(inCommon)})." 7736 ) 7737 7738 def tokenCountNow(self, tokenType: base.Token) -> Optional[int]: 7739 """ 7740 Returns the number of tokens the player currently has of a given 7741 type. Returns `None` if the player has never acquired or lost 7742 tokens of that type. 7743 7744 This method adds together tokens from the common and active 7745 focal contexts. 7746 """ 7747 state = self.getSituation().state 7748 commonContext = state['common'] 7749 activeContext = state['contexts'][state['activeContext']] 7750 base = commonContext['capabilities']['tokens'].get(tokenType) 7751 if base is None: 7752 return activeContext['capabilities']['tokens'].get(tokenType) 7753 else: 7754 return base + activeContext['capabilities']['tokens'].get( 7755 tokenType, 7756 0 7757 ) 7758 7759 def adjustTokensNow( 7760 self, 7761 tokenType: base.Token, 7762 amount: int, 7763 inCommon: bool = False 7764 ) -> None: 7765 """ 7766 Modifies the current game state to add the specified number of 7767 `Token`s of the given type to the player's tokens. No changes are 7768 made to the current graph. Reduce the number of tokens by 7769 supplying a negative amount; note that negative token amounts 7770 are possible. 7771 7772 By default, the number of tokens for the current active 7773 `FocalContext` will be adjusted. However, if `inCommon` is set 7774 to `True`, then the number of tokens for the common context will 7775 be adjusted instead. 7776 """ 7777 # TODO: Custom token caps! 7778 state = self.getSituation().state 7779 if inCommon: 7780 context = state['common'] 7781 else: 7782 context = state['contexts'][state['activeContext']] 7783 tokens = context['capabilities']['tokens'] 7784 tokens[tokenType] = tokens.get(tokenType, 0) + amount 7785 7786 def setTokensNow( 7787 self, 7788 tokenType: base.Token, 7789 amount: int, 7790 inCommon: bool = False 7791 ) -> None: 7792 """ 7793 Modifies the current game state to set number of `Token`s of the 7794 given type to a specific amount, regardless of the old value. No 7795 changes are made to the current graph. 7796 7797 By default this sets the number of tokens for the active 7798 `FocalContext`. But if you set `inCommon` to `True`, it will 7799 set the number of tokens in the common context instead. 7800 """ 7801 # TODO: Custom token caps! 7802 state = self.getSituation().state 7803 if inCommon: 7804 context = state['common'] 7805 else: 7806 context = state['contexts'][state['activeContext']] 7807 context['capabilities']['tokens'][tokenType] = amount 7808 7809 def lookupMechanism( 7810 self, 7811 mechanism: base.MechanismName, 7812 step: Optional[int] = None, 7813 where: Union[ 7814 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]], 7815 Collection[base.AnyDecisionSpecifier], 7816 None 7817 ] = None 7818 ) -> base.MechanismID: 7819 """ 7820 Looks up a mechanism ID by name, in the graph for the specified 7821 step. The `where` argument specifies where to start looking, 7822 which helps disambiguate. It can be a tuple with a decision 7823 specifier and `None` to start from a single decision, or with a 7824 decision specifier and a transition name to start from either 7825 end of that transition. It can also be `None` to look at global 7826 mechanisms and then all decisions directly, although this 7827 increases the chance of a `MechanismCollisionError`. Finally, it 7828 can be some other non-tuple collection of decision specifiers to 7829 start from that set. 7830 7831 If no step is specified, uses the current step. 7832 """ 7833 if step is None: 7834 step = -1 7835 situation = self.getSituation(step) 7836 graph = situation.graph 7837 searchFrom: Collection[base.AnyDecisionSpecifier] 7838 if where is None: 7839 searchFrom = set() 7840 elif isinstance(where, tuple): 7841 if len(where) != 2: 7842 raise ValueError( 7843 f"Mechanism lookup location was a tuple with an" 7844 f" invalid length (must be length-2 if it's a" 7845 f" tuple):\n {repr(where)}" 7846 ) 7847 where = cast( 7848 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]], 7849 where 7850 ) 7851 if where[1] is None: 7852 searchFrom = {graph.resolveDecision(where[0])} 7853 else: 7854 searchFrom = graph.bothEnds(where[0], where[1]) 7855 else: # must be a collection of specifiers 7856 searchFrom = cast(Collection[base.AnyDecisionSpecifier], where) 7857 return graph.lookupMechanism(searchFrom, mechanism) 7858 7859 def mechanismState( 7860 self, 7861 mechanism: base.AnyMechanismSpecifier, 7862 where: Optional[Set[base.DecisionID]] = None, 7863 step: int = -1 7864 ) -> Optional[base.MechanismState]: 7865 """ 7866 Returns the current state for the specified mechanism (or the 7867 state at the specified step if a step index is given). `where` 7868 may be provided as a set of decision IDs to indicate where to 7869 search for the named mechanism, or a mechanism ID may be provided 7870 in the first place. Mechanism states are properties of a `State` 7871 but are not associated with focal contexts. 7872 """ 7873 situation = self.getSituation(step) 7874 mID = situation.graph.resolveMechanism(mechanism, startFrom=where) 7875 return situation.state['mechanisms'].get( 7876 mID, 7877 base.DEFAULT_MECHANISM_STATE 7878 ) 7879 7880 def setMechanismStateNow( 7881 self, 7882 mechanism: base.AnyMechanismSpecifier, 7883 toState: base.MechanismState, 7884 where: Optional[Set[base.DecisionID]] = None 7885 ) -> None: 7886 """ 7887 Sets the state of the specified mechanism to the specified 7888 state. Mechanisms can only be in one state at once, so this 7889 removes any previous states for that mechanism (note that via 7890 equivalences multiple mechanism states can count as active). 7891 7892 The mechanism can be any kind of mechanism specifier (see 7893 `base.AnyMechanismSpecifier`). If it's not a mechanism ID and 7894 doesn't have its own position information, the 'where' argument 7895 can be used to hint where to search for the mechanism. 7896 """ 7897 now = self.getSituation() 7898 mID = now.graph.resolveMechanism(mechanism, startFrom=where) 7899 if mID is None: 7900 raise MissingMechanismError( 7901 f"Couldn't find mechanism for {repr(mechanism)}." 7902 ) 7903 now.state['mechanisms'][mID] = toState 7904 7905 def skillLevel( 7906 self, 7907 skill: base.Skill, 7908 step: Optional[int] = None 7909 ) -> Optional[base.Level]: 7910 """ 7911 Returns the skill level the player had in a given skill at a 7912 given step, or for the current step if no step is specified. 7913 Returns `None` if the player had never acquired or lost levels 7914 in that skill before the specified step (skill level would count 7915 as 0 in that case). 7916 7917 This method adds together levels from the common and active 7918 focal contexts. 7919 """ 7920 if step is None: 7921 step = -1 7922 state = self.getSituation(step).state 7923 commonContext = state['common'] 7924 activeContext = state['contexts'][state['activeContext']] 7925 base = commonContext['capabilities']['skills'].get(skill) 7926 if base is None: 7927 return activeContext['capabilities']['skills'].get(skill) 7928 else: 7929 return base + activeContext['capabilities']['skills'].get( 7930 skill, 7931 0 7932 ) 7933 7934 def adjustSkillLevelNow( 7935 self, 7936 skill: base.Skill, 7937 levels: base.Level, 7938 inCommon: bool = False 7939 ) -> None: 7940 """ 7941 Modifies the current game state to add the specified number of 7942 `Level`s of the given skill. No changes are made to the current 7943 graph. Reduce the skill level by supplying negative levels; note 7944 that negative skill levels are possible. 7945 7946 By default, the skill level for the current active 7947 `FocalContext` will be adjusted. However, if `inCommon` is set 7948 to `True`, then the skill level for the common context will be 7949 adjusted instead. 7950 """ 7951 # TODO: Custom level caps? 7952 state = self.getSituation().state 7953 if inCommon: 7954 context = state['common'] 7955 else: 7956 context = state['contexts'][state['activeContext']] 7957 skills = context['capabilities']['skills'] 7958 skills[skill] = skills.get(skill, 0) + levels 7959 7960 def setSkillLevelNow( 7961 self, 7962 skill: base.Skill, 7963 level: base.Level, 7964 inCommon: bool = False 7965 ) -> None: 7966 """ 7967 Modifies the current game state to set `Skill` `Level` for the 7968 given skill, regardless of the old value. No changes are made to 7969 the current graph. 7970 7971 By default this sets the skill level for the active 7972 `FocalContext`. But if you set `inCommon` to `True`, it will set 7973 the skill level in the common context instead. 7974 """ 7975 # TODO: Custom level caps? 7976 state = self.getSituation().state 7977 if inCommon: 7978 context = state['common'] 7979 else: 7980 context = state['contexts'][state['activeContext']] 7981 skills = context['capabilities']['skills'] 7982 skills[skill] = level 7983 7984 def updateRequirementNow( 7985 self, 7986 decision: base.AnyDecisionSpecifier, 7987 transition: base.Transition, 7988 requirement: Optional[base.Requirement] 7989 ) -> None: 7990 """ 7991 Updates the requirement for a specific transition in a specific 7992 decision. Use `None` to remove the requirement for that edge. 7993 """ 7994 if requirement is None: 7995 requirement = base.ReqNothing() 7996 self.getSituation().graph.setTransitionRequirement( 7997 decision, 7998 transition, 7999 requirement 8000 ) 8001 8002 def isTraversable( 8003 self, 8004 decision: base.AnyDecisionSpecifier, 8005 transition: base.Transition, 8006 step: int = -1 8007 ) -> bool: 8008 """ 8009 Returns True if the specified transition from the specified 8010 decision had its requirement satisfied by the game state at the 8011 specified step (or at the current step if no step is specified). 8012 Raises an `IndexError` if the specified step doesn't exist, and 8013 a `KeyError` if the decision or transition specified does not 8014 exist in the `DecisionGraph` at that step. 8015 """ 8016 situation = self.getSituation(step) 8017 req = situation.graph.getTransitionRequirement(decision, transition) 8018 ctx = base.contextForTransition(situation, decision, transition) 8019 fromID = situation.graph.resolveDecision(decision) 8020 return ( 8021 req.satisfied(ctx) 8022 and (fromID, transition) not in situation.state['deactivated'] 8023 ) 8024 8025 def applyTransitionEffect( 8026 self, 8027 whichEffect: base.EffectSpecifier, 8028 moveWhich: Optional[base.FocalPointName] = None 8029 ) -> Optional[base.DecisionID]: 8030 """ 8031 Applies an effect attached to a transition, taking charges and 8032 delay into account based on the current `Situation`. 8033 Modifies the effect's trigger count (but may not actually 8034 trigger the effect if the charges and/or delay values indicate 8035 not to; see `base.doTriggerEffect`). 8036 8037 If a specific focal point in a plural-focalized domain is 8038 triggering the effect, the focal point name should be specified 8039 via `moveWhich` so that goto `Effect`s can know which focal 8040 point to move when it's not explicitly specified in the effect. 8041 TODO: Test this! 8042 8043 Returns None most of the time, but if a 'goto', 'bounce', or 8044 'follow' effect was applied, it returns the decision ID for that 8045 effect's destination, which would override a transition's normal 8046 destination. If it returns a destination ID, then the exploration 8047 state will already have been updated to set the position there, 8048 and further position updates are not needed. 8049 8050 Note that transition effects which update active decisions will 8051 also update the exploration status of those decisions to 8052 'exploring' if they had been in an unvisited status (see 8053 `updatePosition` and `hasBeenVisited`). 8054 8055 Note: callers should immediately update situation-based variables 8056 that might have been changes by a 'revert' effect. 8057 """ 8058 now = self.getSituation() 8059 effect, triggerCount = base.doTriggerEffect(now, whichEffect) 8060 if triggerCount is not None: 8061 return self.applyExtraneousEffect( 8062 effect, 8063 where=whichEffect[:2], 8064 moveWhich=moveWhich 8065 ) 8066 else: 8067 return None 8068 8069 def applyExtraneousEffect( 8070 self, 8071 effect: base.Effect, 8072 where: Optional[ 8073 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]] 8074 ] = None, 8075 moveWhich: Optional[base.FocalPointName] = None, 8076 challengePolicy: base.ChallengePolicy = "specified" 8077 ) -> Optional[base.DecisionID]: 8078 """ 8079 Applies a single extraneous effect to the state & graph, 8080 *without* accounting for charges or delay values, since the 8081 effect is not part of the graph (use `applyTransitionEffect` to 8082 apply effects that are attached to transitions, which is almost 8083 always the function you should be using). An associated 8084 transition for the extraneous effect can be supplied using the 8085 `where` argument, and effects like 'deactivate' and 'edit' will 8086 affect it (but the effect's charges and delay values will still 8087 be ignored). 8088 8089 If the effect would change the destination of a transition, the 8090 altered destination ID is returned: 'bounce' effects return the 8091 provided decision part of `where`, 'goto' effects return their 8092 target, and 'follow' effects return the destination followed to 8093 (possibly via chained follows in the extreme case). In all other 8094 cases, `None` is returned indicating no change to a normal 8095 destination. 8096 8097 If a specific focal point in a plural-focalized domain is 8098 triggering the effect, the focal point name should be specified 8099 via `moveWhich` so that goto `Effect`s can know which focal 8100 point to move when it's not explicitly specified in the effect. 8101 TODO: Test this! 8102 8103 Note that transition effects which update active decisions will 8104 also update the exploration status of those decisions to 8105 'exploring' if they had been in an unvisited status and will 8106 remove any 'unconfirmed' tag they might still have (see 8107 `updatePosition` and `hasBeenVisited`). 8108 8109 The given `challengePolicy` is applied when traversing further 8110 transitions due to 'follow' effects. 8111 8112 Note: Anyone calling `applyExtraneousEffect` should update any 8113 situation-based variables immediately after the call, as a 8114 'revert' effect may have changed the current graph and/or state. 8115 """ 8116 typ = effect['type'] 8117 value = effect['value'] 8118 applyTo = effect['applyTo'] 8119 inCommon = applyTo == 'common' 8120 8121 now = self.getSituation() 8122 8123 if where is not None: 8124 if where[1] is not None: 8125 searchFrom = now.graph.bothEnds(where[0], where[1]) 8126 else: 8127 searchFrom = {now.graph.resolveDecision(where[0])} 8128 else: 8129 searchFrom = None 8130 8131 # Note: Delay and charges are ignored! 8132 8133 if typ in ("gain", "lose"): 8134 value = cast( 8135 Union[ 8136 base.Capability, 8137 Tuple[base.Token, base.TokenCount], 8138 Tuple[Literal['skill'], base.Skill, base.Level], 8139 ], 8140 value 8141 ) 8142 if isinstance(value, base.Capability): 8143 if typ == "gain": 8144 self.gainCapabilityNow(value, inCommon) 8145 else: 8146 self.loseCapabilityNow(value, inCommon) 8147 elif len(value) == 2: # must be a token, amount pair 8148 token, amount = cast( 8149 Tuple[base.Token, base.TokenCount], 8150 value 8151 ) 8152 if typ == "lose": 8153 amount *= -1 8154 self.adjustTokensNow(token, amount, inCommon) 8155 else: # must be a 'skill', skill, level triple 8156 _, skill, levels = cast( 8157 Tuple[Literal['skill'], base.Skill, base.Level], 8158 value 8159 ) 8160 if typ == "lose": 8161 levels *= -1 8162 self.adjustSkillLevelNow(skill, levels, inCommon) 8163 8164 elif typ == "set": 8165 value = cast( 8166 Union[ 8167 Tuple[base.Token, base.TokenCount], 8168 Tuple[base.AnyMechanismSpecifier, base.MechanismState], 8169 Tuple[Literal['skill'], base.Skill, base.Level], 8170 ], 8171 value 8172 ) 8173 if len(value) == 2: # must be a token or mechanism pair 8174 if isinstance(value[1], base.TokenCount): # token 8175 token, amount = cast( 8176 Tuple[base.Token, base.TokenCount], 8177 value 8178 ) 8179 self.setTokensNow(token, amount, inCommon) 8180 else: # mechanism 8181 mechanism, state = cast( 8182 Tuple[ 8183 base.AnyMechanismSpecifier, 8184 base.MechanismState 8185 ], 8186 value 8187 ) 8188 self.setMechanismStateNow(mechanism, state, searchFrom) 8189 else: # must be a 'skill', skill, level triple 8190 _, skill, level = cast( 8191 Tuple[Literal['skill'], base.Skill, base.Level], 8192 value 8193 ) 8194 self.setSkillLevelNow(skill, level, inCommon) 8195 8196 elif typ == "toggle": 8197 # Length-1 list just toggles a capability on/off based on current 8198 # state (not attending to equivalents): 8199 if isinstance(value, List): # capabilities list 8200 value = cast(List[base.Capability], value) 8201 if len(value) == 0: 8202 raise ValueError( 8203 "Toggle effect has empty capabilities list." 8204 ) 8205 if len(value) == 1: 8206 capability = value[0] 8207 if self.hasCapability(capability, inCommon=False): 8208 self.loseCapabilityNow(capability, inCommon=False) 8209 else: 8210 self.gainCapabilityNow(capability) 8211 else: 8212 # Otherwise toggle all powers off, then one on, 8213 # based on the first capability that's currently on. 8214 # Note we do NOT count equivalences. 8215 8216 # Find first capability that's on: 8217 firstIndex: Optional[int] = None 8218 for i, capability in enumerate(value): 8219 if self.hasCapability(capability): 8220 firstIndex = i 8221 break 8222 8223 # Turn them all off: 8224 for capability in value: 8225 self.loseCapabilityNow(capability, inCommon=False) 8226 # TODO: inCommon for the check? 8227 8228 if firstIndex is None: 8229 self.gainCapabilityNow(value[0]) 8230 else: 8231 self.gainCapabilityNow( 8232 value[(firstIndex + 1) % len(value)] 8233 ) 8234 else: # must be a mechanism w/ states list 8235 mechanism, states = cast( 8236 Tuple[ 8237 base.AnyMechanismSpecifier, 8238 List[base.MechanismState] 8239 ], 8240 value 8241 ) 8242 currentState = self.mechanismState(mechanism, where=searchFrom) 8243 if len(states) == 1: 8244 if currentState == states[0]: 8245 # default alternate state 8246 self.setMechanismStateNow( 8247 mechanism, 8248 base.DEFAULT_MECHANISM_STATE, 8249 searchFrom 8250 ) 8251 else: 8252 self.setMechanismStateNow( 8253 mechanism, 8254 states[0], 8255 searchFrom 8256 ) 8257 else: 8258 # Find our position in the list, if any 8259 try: 8260 currentIndex = states.index(cast(str, currentState)) 8261 # Cast here just because we know that None will 8262 # raise a ValueError but we'll catch it, and we 8263 # want to suppress the mypy warning about the 8264 # option 8265 except ValueError: 8266 currentIndex = len(states) - 1 8267 # Set next state in list as current state 8268 nextIndex = (currentIndex + 1) % len(states) 8269 self.setMechanismStateNow( 8270 mechanism, 8271 states[nextIndex], 8272 searchFrom 8273 ) 8274 8275 elif typ == "deactivate": 8276 if where is None or where[1] is None: 8277 raise ValueError( 8278 "Can't apply a deactivate effect without specifying" 8279 " which transition it applies to." 8280 ) 8281 8282 decision, transition = cast( 8283 Tuple[base.AnyDecisionSpecifier, base.Transition], 8284 where 8285 ) 8286 8287 dID = now.graph.resolveDecision(decision) 8288 now.state['deactivated'].add((dID, transition)) 8289 8290 elif typ == "edit": 8291 value = cast(List[List[commands.Command]], value) 8292 # If there are no blocks, do nothing 8293 if len(value) > 0: 8294 # Apply the first block of commands and then rotate the list 8295 scope: commands.Scope = {} 8296 if where is not None: 8297 here: base.DecisionID = now.graph.resolveDecision( 8298 where[0] 8299 ) 8300 outwards: Optional[base.Transition] = where[1] 8301 scope['@'] = here 8302 scope['@t'] = outwards 8303 if outwards is not None: 8304 reciprocal = now.graph.getReciprocal(here, outwards) 8305 destination = now.graph.getDestination(here, outwards) 8306 else: 8307 reciprocal = None 8308 destination = None 8309 scope['@r'] = reciprocal 8310 scope['@d'] = destination 8311 self.runCommandBlock(value[0], scope) 8312 value.append(value.pop(0)) 8313 8314 elif typ == "goto": 8315 if isinstance(value, base.DecisionSpecifier): 8316 target: base.AnyDecisionSpecifier = value 8317 # use moveWhich provided as argument 8318 elif isinstance(value, tuple): 8319 target, moveWhich = cast( 8320 Tuple[base.AnyDecisionSpecifier, base.FocalPointName], 8321 value 8322 ) 8323 else: 8324 target = cast(base.AnyDecisionSpecifier, value) 8325 # use moveWhich provided as argument 8326 8327 destID = now.graph.resolveDecision(target) 8328 base.updatePosition(now, destID, applyTo, moveWhich) 8329 return destID 8330 8331 elif typ == "bounce": 8332 # Just need to let the caller know they should cancel 8333 if where is None: 8334 raise ValueError( 8335 "Can't apply a 'bounce' effect without a position" 8336 " to apply it from." 8337 ) 8338 return now.graph.resolveDecision(where[0]) 8339 8340 elif typ == "follow": 8341 if where is None: 8342 raise ValueError( 8343 f"Can't follow transition {value!r} because there" 8344 f" is no position information when applying the" 8345 f" effect." 8346 ) 8347 if where[1] is not None: 8348 followFrom = now.graph.getDestination(where[0], where[1]) 8349 if followFrom is None: 8350 raise ValueError( 8351 f"Can't follow transition {value!r} because the" 8352 f" position information specifies transition" 8353 f" {where[1]!r} from decision" 8354 f" {now.graph.identityOf(where[0])} but that" 8355 f" transition does not exist." 8356 ) 8357 else: 8358 followFrom = now.graph.resolveDecision(where[0]) 8359 8360 following = cast(base.Transition, value) 8361 8362 followTo = now.graph.getDestination(followFrom, following) 8363 8364 if followTo is None: 8365 raise ValueError( 8366 f"Can't follow transition {following!r} because" 8367 f" that transition doesn't exist at the specified" 8368 f" destination {now.graph.identityOf(followFrom)}." 8369 ) 8370 8371 if self.isTraversable(followFrom, following): # skip if not 8372 # Perform initial position update before following new 8373 # transition: 8374 base.updatePosition( 8375 now, 8376 followFrom, 8377 applyTo, 8378 moveWhich 8379 ) 8380 8381 # Apply consequences of followed transition 8382 fullFollowTo = self.applyTransitionConsequence( 8383 followFrom, 8384 following, 8385 moveWhich, 8386 challengePolicy 8387 ) 8388 8389 # Now update to end of followed transition 8390 if fullFollowTo is None: 8391 base.updatePosition( 8392 now, 8393 followTo, 8394 applyTo, 8395 moveWhich 8396 ) 8397 fullFollowTo = followTo 8398 8399 # Skip the normal update: we've taken care of that plus more 8400 return fullFollowTo 8401 else: 8402 # Normal position updates still applies since follow 8403 # transition wasn't possible 8404 return None 8405 8406 elif typ == "save": 8407 assert isinstance(value, base.SaveSlot) 8408 now.saves[value] = copy.deepcopy((now.graph, now.state)) 8409 8410 else: 8411 raise ValueError(f"Invalid effect type {typ!r}.") 8412 8413 return None # default return value if we didn't return above 8414 8415 def applyExtraneousConsequence( 8416 self, 8417 consequence: base.Consequence, 8418 where: Optional[ 8419 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]] 8420 ] = None, 8421 moveWhich: Optional[base.FocalPointName] = None 8422 ) -> Optional[base.DecisionID]: 8423 """ 8424 Applies an extraneous consequence not associated with a 8425 transition. Unlike `applyTransitionConsequence`, the provided 8426 `base.Consequence` must already have observed outcomes (see 8427 `base.observeChallengeOutcomes`). Returns the decision ID for a 8428 decision implied by a goto, follow, or bounce effect, or `None` 8429 if no effect implies a destination. 8430 8431 The `where` and `moveWhich` optional arguments specify which 8432 decision and/or transition to use as the application position, 8433 and/or which focal point to move. This affects mechanism lookup 8434 as well as the end position when 'follow' effects are used. 8435 Specifically: 8436 8437 - A 'follow' trigger will search for transitions to follow from 8438 the destination of the specified transition, or if only a 8439 decision was supplied, from that decision. 8440 - Mechanism lookups will start with both ends of the specified 8441 transition as their search field (or with just the specified 8442 decision if no transition is included). 8443 8444 'bounce' effects will cause an error unless position information 8445 is provided, and will set the position to the base decision 8446 provided in `where`. 8447 8448 Note: callers should update any situation-based variables 8449 immediately after calling this as a 'revert' effect could change 8450 the current graph and/or state and other changes could get lost 8451 if they get applied to a stale graph/state. 8452 8453 # TODO: Examples for goto and follow effects. 8454 """ 8455 now = self.getSituation() 8456 searchFrom = set() 8457 if where is not None: 8458 if where[1] is not None: 8459 searchFrom = now.graph.bothEnds(where[0], where[1]) 8460 else: 8461 searchFrom = {now.graph.resolveDecision(where[0])} 8462 8463 context = base.RequirementContext( 8464 state=now.state, 8465 graph=now.graph, 8466 searchFrom=searchFrom 8467 ) 8468 8469 effectIndices = base.observedEffects(context, consequence) 8470 destID = None 8471 for index in effectIndices: 8472 effect = base.consequencePart(consequence, index) 8473 if not isinstance(effect, dict) or 'value' not in effect: 8474 raise RuntimeError( 8475 f"Invalid effect index {index}: Consequence part at" 8476 f" that index is not an Effect. Got:\n{effect}" 8477 ) 8478 effect = cast(base.Effect, effect) 8479 destID = self.applyExtraneousEffect( 8480 effect, 8481 where, 8482 moveWhich 8483 ) 8484 # technically this variable is not used later in this 8485 # function, but the `applyExtraneousEffect` call means it 8486 # needs an update, so we're doing that in case someone later 8487 # adds code to this function that uses 'now' after this 8488 # point. 8489 now = self.getSituation() 8490 8491 return destID 8492 8493 def applyTransitionConsequence( 8494 self, 8495 decision: base.AnyDecisionSpecifier, 8496 transition: base.AnyTransition, 8497 moveWhich: Optional[base.FocalPointName] = None, 8498 policy: base.ChallengePolicy = "specified", 8499 fromIndex: Optional[int] = None, 8500 toIndex: Optional[int] = None 8501 ) -> Optional[base.DecisionID]: 8502 """ 8503 Applies the effects of the specified transition to the current 8504 graph and state, possibly overriding observed outcomes using 8505 outcomes specified as part of a `base.TransitionWithOutcomes`. 8506 8507 The `where` and `moveWhich` function serve the same purpose as 8508 for `applyExtraneousEffect`. If `where` is `None`, then the 8509 effects will be applied as extraneous effects, meaning that 8510 their delay and charges values will be ignored and their trigger 8511 count will not be tracked. If `where` is supplied 8512 8513 Returns either None to indicate that the position update for the 8514 transition should apply as usual, or a decision ID indicating 8515 another destination which has already been applied by a 8516 transition effect. 8517 8518 If `fromIndex` and/or `toIndex` are specified, then only effects 8519 which have indices between those two (inclusive) will be 8520 applied, and other effects will neither apply nor be updated in 8521 any way. Note that `onlyPart` does not override the challenge 8522 policy: if the effects in the specified part are not applied due 8523 to a challenge outcome, they still won't happen, including 8524 challenge outcomes outside of that part. Also, outcomes for 8525 challenges of the entire consequence are re-observed if the 8526 challenge policy implies it. 8527 8528 Note: Anyone calling this should update any situation-based 8529 variables immediately after the call, as a 'revert' effect may 8530 have changed the current graph and/or state. 8531 """ 8532 now = self.getSituation() 8533 dID = now.graph.resolveDecision(decision) 8534 8535 transitionName, outcomes = base.nameAndOutcomes(transition) 8536 8537 searchFrom = set() 8538 searchFrom = now.graph.bothEnds(dID, transitionName) 8539 8540 context = base.RequirementContext( 8541 state=now.state, 8542 graph=now.graph, 8543 searchFrom=searchFrom 8544 ) 8545 8546 consequence = now.graph.getConsequence(dID, transitionName) 8547 8548 # Make sure that challenge outcomes are known 8549 if policy != "specified": 8550 base.resetChallengeOutcomes(consequence) 8551 useUp = outcomes[:] 8552 base.observeChallengeOutcomes( 8553 context, 8554 consequence, 8555 location=searchFrom, 8556 policy=policy, 8557 knownOutcomes=useUp 8558 ) 8559 if len(useUp) > 0: 8560 raise ValueError( 8561 f"More outcomes specified than challenges observed in" 8562 f" consequence:\n{consequence}" 8563 f"\nRemaining outcomes:\n{useUp}" 8564 ) 8565 8566 # Figure out which effects apply, and apply each of them 8567 effectIndices = base.observedEffects(context, consequence) 8568 if fromIndex is None: 8569 fromIndex = 0 8570 8571 altDest = None 8572 for index in effectIndices: 8573 if ( 8574 index >= fromIndex 8575 and (toIndex is None or index <= toIndex) 8576 ): 8577 thisDest = self.applyTransitionEffect( 8578 (dID, transitionName, index), 8579 moveWhich 8580 ) 8581 if thisDest is not None: 8582 altDest = thisDest 8583 # TODO: What if this updates state with 'revert' to a 8584 # graph that doesn't contain the same effects? 8585 # TODO: Update 'now' and 'context'?! 8586 return altDest 8587 8588 def allDecisions(self) -> List[base.DecisionID]: 8589 """ 8590 Returns the list of all decisions which existed at any point 8591 within the exploration. Example: 8592 8593 >>> ex = DiscreteExploration() 8594 >>> ex.start('A') 8595 0 8596 >>> ex.observe('A', 'right') 8597 1 8598 >>> ex.explore('right', 'B', 'left') 8599 1 8600 >>> ex.observe('B', 'right') 8601 2 8602 >>> ex.allDecisions() # 'A', 'B', and the unnamed 'right of B' 8603 [0, 1, 2] 8604 """ 8605 seen = set() 8606 result = [] 8607 for situation in self: 8608 for decision in situation.graph: 8609 if decision not in seen: 8610 result.append(decision) 8611 seen.add(decision) 8612 8613 return result 8614 8615 def allExploredDecisions(self) -> List[base.DecisionID]: 8616 """ 8617 Returns the list of all decisions which existed at any point 8618 within the exploration, excluding decisions whose highest 8619 exploration status was `noticed` or lower. May still include 8620 decisions which don't exist in the final situation's graph due to 8621 things like decision merging. Example: 8622 8623 >>> ex = DiscreteExploration() 8624 >>> ex.start('A') 8625 0 8626 >>> ex.observe('A', 'right') 8627 1 8628 >>> ex.explore('right', 'B', 'left') 8629 1 8630 >>> ex.observe('B', 'right') 8631 2 8632 >>> graph = ex.getSituation().graph 8633 >>> graph.addDecision('C') # add isolated decision; doesn't set status 8634 3 8635 >>> ex.hasBeenVisited('C') 8636 False 8637 >>> ex.allExploredDecisions() 8638 [0, 1] 8639 >>> ex.setExplorationStatus('C', 'exploring') 8640 >>> ex.allExploredDecisions() # 2 is the decision right from 'B' 8641 [0, 1, 3] 8642 >>> ex.setExplorationStatus('A', 'explored') 8643 >>> ex.allExploredDecisions() 8644 [0, 1, 3] 8645 >>> ex.setExplorationStatus('A', 'unknown') 8646 >>> # remains visisted in an earlier step 8647 >>> ex.allExploredDecisions() 8648 [0, 1, 3] 8649 >>> ex.setExplorationStatus('C', 'unknown') # not explored earlier 8650 >>> ex.allExploredDecisions() # 2 is the decision right from 'B' 8651 [0, 1] 8652 """ 8653 seen = set() 8654 result = [] 8655 for situation in self: 8656 graph = situation.graph 8657 for decision in graph: 8658 if ( 8659 decision not in seen 8660 and base.hasBeenVisited(situation, decision) 8661 ): 8662 result.append(decision) 8663 seen.add(decision) 8664 8665 return result 8666 8667 def allVisitedDecisions(self) -> List[base.DecisionID]: 8668 """ 8669 Returns the list of all decisions which existed at any point 8670 within the exploration and which were visited at least once. 8671 Orders them in the same order they were visited in. 8672 8673 Usually all of these decisions will be present in the final 8674 situation's graph, but sometimes merging or other factors means 8675 there might be some that won't be. Being present on the game 8676 state's 'active' list in a step for its domain is what counts as 8677 "being visited," which means that nodes which were passed through 8678 directly via a 'follow' effect won't be counted, for example. 8679 8680 This should usually correspond with the absence of the 8681 'unconfirmed' tag. 8682 8683 Example: 8684 8685 >>> ex = DiscreteExploration() 8686 >>> ex.start('A') 8687 0 8688 >>> ex.observe('A', 'right') 8689 1 8690 >>> ex.explore('right', 'B', 'left') 8691 1 8692 >>> ex.observe('B', 'right') 8693 2 8694 >>> ex.getSituation().graph.addDecision('C') # add isolated decision 8695 3 8696 >>> av = ex.allVisitedDecisions() 8697 >>> av 8698 [0, 1] 8699 >>> all( # no decisions in the 'visited' list are tagged 8700 ... 'unconfirmed' not in ex.getSituation().graph.decisionTags(d) 8701 ... for d in av 8702 ... ) 8703 True 8704 >>> graph = ex.getSituation().graph 8705 >>> 'unconfirmed' in graph.decisionTags(0) 8706 False 8707 >>> 'unconfirmed' in graph.decisionTags(1) 8708 False 8709 >>> 'unconfirmed' in graph.decisionTags(2) 8710 True 8711 >>> 'unconfirmed' in graph.decisionTags(3) # not tagged; not explored 8712 False 8713 """ 8714 seen = set() 8715 result = [] 8716 for step in range(len(self)): 8717 active = self.getActiveDecisions(step) 8718 for dID in active: 8719 if dID not in seen: 8720 result.append(dID) 8721 seen.add(dID) 8722 8723 return result 8724 8725 def allTransitions(self) -> List[ 8726 Tuple[base.DecisionID, base.Transition, base.DecisionID] 8727 ]: 8728 """ 8729 Returns the list of all transitions which existed at any point 8730 within the exploration, as 3-tuples with source decision ID, 8731 transition name, and destination decision ID. Note that since 8732 transitions can be deleted or re-targeted, and a transition name 8733 can be re-used after being deleted, things can get messy in the 8734 edges cases. When the same transition name is used in different 8735 steps with different decision targets, we end up including each 8736 possible source-transition-destination triple. Example: 8737 8738 >>> ex = DiscreteExploration() 8739 >>> ex.start('A') 8740 0 8741 >>> ex.observe('A', 'right') 8742 1 8743 >>> ex.explore('right', 'B', 'left') 8744 1 8745 >>> ex.observe('B', 'right') 8746 2 8747 >>> ex.wait() # leave behind a step where 'B' has a 'right' 8748 >>> ex.primaryDecision(0) 8749 >>> ex.primaryDecision(1) 8750 0 8751 >>> ex.primaryDecision(2) 8752 1 8753 >>> ex.primaryDecision(3) 8754 1 8755 >>> len(ex) 8756 4 8757 >>> ex[3].graph.removeDecision(2) # delete 'right of B' 8758 >>> ex.observe('B', 'down') 8759 3 8760 >>> # Decisions are: 'A', 'B', and the unnamed 'right of B' 8761 >>> # (now-deleted), and the unnamed 'down from B' 8762 >>> ex.allDecisions() 8763 [0, 1, 2, 3] 8764 >>> for tr in ex.allTransitions(): 8765 ... print(tr) 8766 ... 8767 (0, 'right', 1) 8768 (1, 'return', 0) 8769 (1, 'left', 0) 8770 (1, 'right', 2) 8771 (2, 'return', 1) 8772 (1, 'down', 3) 8773 (3, 'return', 1) 8774 >>> # Note transitions from now-deleted nodes, and 'return' 8775 >>> # transitions for unexplored nodes before they get explored 8776 """ 8777 seen = set() 8778 result = [] 8779 for situation in self: 8780 graph = situation.graph 8781 for (src, dst, transition) in graph.allEdges(): # type:ignore 8782 trans = (src, transition, dst) 8783 if trans not in seen: 8784 result.append(trans) 8785 seen.add(trans) 8786 8787 return result 8788 8789 def start( 8790 self, 8791 decision: base.AnyDecisionSpecifier, 8792 startCapabilities: Optional[base.CapabilitySet] = None, 8793 setMechanismStates: Optional[ 8794 Dict[base.MechanismID, base.MechanismState] 8795 ] = None, 8796 setCustomState: Optional[dict] = None, 8797 decisionType: base.DecisionType = "imposed" 8798 ) -> base.DecisionID: 8799 """ 8800 Sets the initial position information for a newly-relevant 8801 domain for the current focal context. Creates a new decision 8802 if the decision is specified by name or `DecisionSpecifier` and 8803 that decision doesn't already exist. Returns the decision ID for 8804 the newly-placed decision (or for the specified decision if it 8805 already existed). 8806 8807 Raises a `BadStart` error if the current focal context already 8808 has position information for the specified domain. 8809 8810 - The given `startCapabilities` replaces any existing 8811 capabilities for the current focal context, although you can 8812 leave it as the default `None` to avoid that and retain any 8813 capabilities that have been set up already. 8814 - The given `setMechanismStates` and `setCustomState` 8815 dictionaries override all previous mechanism states & custom 8816 states in the new situation. Leave these as the default 8817 `None` to maintain those states. 8818 - If created, the decision will be placed in the DEFAULT_DOMAIN 8819 domain unless it's specified as a `base.DecisionSpecifier` 8820 with a domain part, in which case that domain is used. 8821 - If specified as a `base.DecisionSpecifier` with a zone part 8822 and a new decision needs to be created, the decision will be 8823 added to that zone, creating it at level 0 if necessary, 8824 although otherwise no zone information will be changed. 8825 - Resets the decision type to "pending" and the action taken to 8826 `None`. Sets the decision type of the previous situation to 8827 'imposed' (or the specified `decisionType`) and sets an 8828 appropriate 'start' action for that situation. 8829 - Tags the step with 'start'. 8830 - Even in a plural- or spreading-focalized domain, you still need 8831 to pick one decision to start at. 8832 """ 8833 now = self.getSituation() 8834 8835 startID = now.graph.getDecision(decision) 8836 zone = None 8837 domain = base.DEFAULT_DOMAIN 8838 if startID is None: 8839 if isinstance(decision, base.DecisionID): 8840 raise MissingDecisionError( 8841 f"Cannot start at decision {decision} because no" 8842 f" decision with that ID exists. Supply a name or" 8843 f" DecisionSpecifier if you need the start decision" 8844 f" to be created automatically." 8845 ) 8846 elif isinstance(decision, base.DecisionName): 8847 decision = base.DecisionSpecifier( 8848 domain=None, 8849 zone=None, 8850 name=decision 8851 ) 8852 startID = now.graph.addDecision( 8853 decision.name, 8854 domain=decision.domain 8855 ) 8856 zone = decision.zone 8857 if decision.domain is not None: 8858 domain = decision.domain 8859 8860 if zone is not None: 8861 if now.graph.getZoneInfo(zone) is None: 8862 now.graph.createZone(zone, 0) 8863 now.graph.addDecisionToZone(startID, zone) 8864 8865 action: base.ExplorationAction = ( 8866 'start', 8867 startID, 8868 startID, 8869 domain, 8870 startCapabilities, 8871 setMechanismStates, 8872 setCustomState 8873 ) 8874 8875 self.advanceSituation(action, decisionType) 8876 8877 return startID 8878 8879 def hasBeenVisited( 8880 self, 8881 decision: base.AnyDecisionSpecifier, 8882 step: int = -1 8883 ): 8884 """ 8885 Returns whether or not the specified decision has been visited in 8886 the specified step (default current step). 8887 """ 8888 return base.hasBeenVisited(self.getSituation(step), decision) 8889 8890 def setExplorationStatus( 8891 self, 8892 decision: base.AnyDecisionSpecifier, 8893 status: base.ExplorationStatus, 8894 upgradeOnly: bool = False 8895 ): 8896 """ 8897 Updates the current exploration status of a specific decision in 8898 the current situation. If `upgradeOnly` is true (default is 8899 `False` then the update will only apply if the new exploration 8900 status counts as 'more-explored' than the old one (see 8901 `base.moreExplored`). 8902 """ 8903 base.setExplorationStatus( 8904 self.getSituation(), 8905 decision, 8906 status, 8907 upgradeOnly 8908 ) 8909 8910 def getExplorationStatus( 8911 self, 8912 decision: base.AnyDecisionSpecifier, 8913 step: int = -1 8914 ): 8915 """ 8916 Returns the exploration status of the specified decision at the 8917 specified step (default is last step). Decisions whose 8918 exploration status has never been set will have a default status 8919 of 'unknown'. 8920 """ 8921 situation = self.getSituation(step) 8922 dID = situation.graph.resolveDecision(decision) 8923 return situation.state['exploration'].get(dID, 'unknown') 8924 8925 def deduceTransitionDetailsAtStep( 8926 self, 8927 step: int, 8928 transition: base.Transition, 8929 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 8930 whichFocus: Optional[base.FocalPointSpecifier] = None, 8931 inCommon: Union[bool, Literal["auto"]] = "auto" 8932 ) -> Tuple[ 8933 base.ContextSpecifier, 8934 base.DecisionID, 8935 base.DecisionID, 8936 Optional[base.FocalPointSpecifier] 8937 ]: 8938 """ 8939 Given just a transition name which the player intends to take in 8940 a specific step, deduces the `ContextSpecifier` for which 8941 context should be updated, the source and destination 8942 `DecisionID`s for the transition, and if the destination 8943 decision's domain is plural-focalized, the `FocalPointName` 8944 specifying which focal point should be moved. 8945 8946 Because many of those things are ambiguous, you may get an 8947 `AmbiguousTransitionError` when things are underspecified, and 8948 there are options for specifying some of the extra information 8949 directly: 8950 8951 - `fromDecision` may be used to specify the source decision. 8952 - `whichFocus` may be used to specify the focal point (within a 8953 particular context/domain) being updated. When focal point 8954 ambiguity remains and this is unspecified, the 8955 alphabetically-earliest relevant focal point will be used 8956 (either among all focal points which activate the source 8957 decision, if there are any, or among all focal points for 8958 the entire domain of the destination decision). 8959 - `inCommon` (a `ContextSpecifier`) may be used to specify which 8960 context to update. The default of "auto" will cause the 8961 active context to be selected unless it does not activate 8962 the source decision, in which case the common context will 8963 be selected. 8964 8965 A `MissingDecisionError` will be raised if there are no current 8966 active decisions (e.g., before `start` has been called), and a 8967 `MissingTransitionError` will be raised if the listed transition 8968 does not exist from any active decision (or from the specified 8969 decision if `fromDecision` is used). 8970 """ 8971 now = self.getSituation(step) 8972 active = self.getActiveDecisions(step) 8973 if len(active) == 0: 8974 raise MissingDecisionError( 8975 f"There are no active decisions from which transition" 8976 f" {repr(transition)} could be taken at step {step}." 8977 ) 8978 8979 # All source/destination decision pairs for transitions with the 8980 # given transition name. 8981 allDecisionPairs: Dict[base.DecisionID, base.DecisionID] = {} 8982 8983 # TODO: When should we be trimming the active decisions to match 8984 # any alterations to the graph? 8985 for dID in active: 8986 outgoing = now.graph.destinationsFrom(dID) 8987 if transition in outgoing: 8988 allDecisionPairs[dID] = outgoing[transition] 8989 8990 if len(allDecisionPairs) == 0: 8991 raise MissingTransitionError( 8992 f"No transitions named {repr(transition)} are outgoing" 8993 f" from active decisions at step {step}." 8994 f"\nActive decisions are:" 8995 f"\n{now.graph.namesListing(active)}" 8996 ) 8997 8998 if ( 8999 fromDecision is not None 9000 and fromDecision not in allDecisionPairs 9001 ): 9002 raise MissingTransitionError( 9003 f"{fromDecision} was specified as the source decision" 9004 f" for traversing transition {repr(transition)} but" 9005 f" there is no transition of that name from that" 9006 f" decision at step {step}." 9007 f"\nValid source decisions are:" 9008 f"\n{now.graph.namesListing(allDecisionPairs)}" 9009 ) 9010 elif fromDecision is not None: 9011 fromID = now.graph.resolveDecision(fromDecision) 9012 destID = allDecisionPairs[fromID] 9013 fromDomain = now.graph.domainFor(fromID) 9014 elif len(allDecisionPairs) == 1: 9015 fromID, destID = list(allDecisionPairs.items())[0] 9016 fromDomain = now.graph.domainFor(fromID) 9017 else: 9018 fromID = None 9019 destID = None 9020 fromDomain = None 9021 # Still ambiguous; resolve this below 9022 9023 # Use whichFocus if provided 9024 if whichFocus is not None: 9025 # Type/value check for whichFocus 9026 if ( 9027 not isinstance(whichFocus, tuple) 9028 or len(whichFocus) != 3 9029 or whichFocus[0] not in ("active", "common") 9030 or not isinstance(whichFocus[1], base.Domain) 9031 or not isinstance(whichFocus[2], base.FocalPointName) 9032 ): 9033 raise ValueError( 9034 f"Invalid whichFocus value {repr(whichFocus)}." 9035 f"\nMust be a length-3 tuple with 'active' or 'common'" 9036 f" as the first element, a Domain as the second" 9037 f" element, and a FocalPointName as the third" 9038 f" element." 9039 ) 9040 9041 # Resolve focal point specified 9042 fromID = base.resolvePosition( 9043 now, 9044 whichFocus 9045 ) 9046 if fromID is None: 9047 raise MissingTransitionError( 9048 f"Focal point {repr(whichFocus)} was specified as" 9049 f" the transition source, but that focal point does" 9050 f" not have a position." 9051 ) 9052 else: 9053 destID = now.graph.destination(fromID, transition) 9054 fromDomain = now.graph.domainFor(fromID) 9055 9056 elif fromID is None: # whichFocus is None, so it can't disambiguate 9057 raise AmbiguousTransitionError( 9058 f"Transition {repr(transition)} was selected for" 9059 f" disambiguation, but there are multiple transitions" 9060 f" with that name from currently-active decisions, and" 9061 f" neither fromDecision nor whichFocus adequately" 9062 f" disambiguates the specific transition taken." 9063 f"\nValid source decisions at step {step} are:" 9064 f"\n{now.graph.namesListing(allDecisionPairs)}" 9065 ) 9066 9067 # At this point, fromID, destID, and fromDomain have 9068 # been resolved. 9069 if fromID is None or destID is None or fromDomain is None: 9070 raise RuntimeError( 9071 f"One of fromID, destID, or fromDomain was None after" 9072 f" disambiguation was finished:" 9073 f"\nfromID: {fromID}, destID: {destID}, fromDomain:" 9074 f" {repr(fromDomain)}" 9075 ) 9076 9077 # Now figure out which context activated the source so we know 9078 # which focal point we're moving: 9079 context = self.getActiveContext() 9080 active = base.activeDecisionSet(context) 9081 using: base.ContextSpecifier = "active" 9082 if fromID not in active: 9083 context = self.getCommonContext(step) 9084 using = "common" 9085 9086 destDomain = now.graph.domainFor(destID) 9087 if ( 9088 whichFocus is None 9089 and base.getDomainFocalization(context, destDomain) == 'plural' 9090 ): 9091 # Need to figure out which focal point is moving; use the 9092 # alphabetically earliest one that's positioned at the 9093 # fromID, or just the earliest one overall if none of them 9094 # are there. 9095 contextFocalPoints: Dict[ 9096 base.FocalPointName, 9097 Optional[base.DecisionID] 9098 ] = cast( 9099 Dict[base.FocalPointName, Optional[base.DecisionID]], 9100 context['activeDecisions'][destDomain] 9101 ) 9102 if not isinstance(contextFocalPoints, dict): 9103 raise RuntimeError( 9104 f"Active decisions specifier for domain" 9105 f" {repr(destDomain)} with plural focalization has" 9106 f" a non-dictionary value." 9107 ) 9108 9109 if fromDomain == destDomain: 9110 focalCandidates = [ 9111 fp 9112 for fp, pos in contextFocalPoints.items() 9113 if pos == fromID 9114 ] 9115 else: 9116 focalCandidates = list(contextFocalPoints) 9117 9118 whichFocus = (using, destDomain, min(focalCandidates)) 9119 9120 # Now whichFocus has been set if it wasn't already specified; 9121 # might still be None if it's not relevant. 9122 return (using, fromID, destID, whichFocus) 9123 9124 def advanceSituation( 9125 self, 9126 action: base.ExplorationAction, 9127 decisionType: base.DecisionType = "active", 9128 challengePolicy: base.ChallengePolicy = "specified" 9129 ) -> Tuple[base.Situation, Set[base.DecisionID]]: 9130 """ 9131 Given an `ExplorationAction`, sets that as the action taken in 9132 the current situation, and adds a new situation with the results 9133 of that action. A `DoubleActionError` will be raised if the 9134 current situation already has an action specified, and/or has a 9135 decision type other than 'pending'. By default the type of the 9136 decision will be 'active' but another `DecisionType` can be 9137 specified via the `decisionType` parameter. 9138 9139 If the action specified is `('noAction',)`, then the new 9140 situation will be a copy of the old one; this represents waiting 9141 or being at an ending (a decision type other than 'pending' 9142 should be used). 9143 9144 Although `None` can appear as the action entry in situations 9145 with pending decisions, you cannot call `advanceSituation` with 9146 `None` as the action. 9147 9148 If the action includes taking a transition whose requirements 9149 are not satisfied, the transition will still be taken (and any 9150 consequences applied) but a `TransitionBlockedWarning` will be 9151 issued. 9152 9153 A `ChallengePolicy` may be specified, the default is 'specified' 9154 which requires that outcomes are pre-specified. If any other 9155 policy is set, the challenge outcomes will be reset before 9156 re-resolving them according to the provided policy. 9157 9158 The new situation will have decision type 'pending' and `None` 9159 as the action. 9160 9161 The new situation created as a result of the action is returned, 9162 along with the set of destination decision IDs, including 9163 possibly a modified destination via 'bounce', 'goto', and/or 9164 'follow' effects. For actions that don't have a destination, the 9165 second part of the returned tuple will be an empty set. Multiple 9166 IDs may be in the set when using a start action in a plural- or 9167 spreading-focalized domain, for example. 9168 9169 If the action updates active decisions (including via transition 9170 effects) this will also update the exploration status of those 9171 decisions to 'exploring' if they had been in an unvisited 9172 status (see `updatePosition` and `hasBeenVisited`). This 9173 includes decisions traveled through but not ultimately arrived 9174 at via 'follow' effects. 9175 9176 If any decisions are active in the `ENDINGS_DOMAIN`, attempting 9177 to 'warp', 'explore', 'take', or 'start' will raise an 9178 `InvalidActionError`. 9179 """ 9180 now = self.getSituation() 9181 if now.type != 'pending' or now.action is not None: 9182 raise DoubleActionError( 9183 f"Attempted to take action {repr(action)} at step" 9184 f" {len(self) - 1}, but an action and/or decision type" 9185 f" had already been specified:" 9186 f"\nAction: {repr(now.action)}" 9187 f"\nType: {repr(now.type)}" 9188 ) 9189 9190 # Update the now situation to add in the decision type and 9191 # action taken: 9192 revised = base.Situation( 9193 now.graph, 9194 now.state, 9195 decisionType, 9196 action, 9197 now.saves, 9198 now.tags, 9199 now.annotations 9200 ) 9201 self.situations[-1] = revised 9202 9203 # Separate update process when reverting (this branch returns) 9204 if ( 9205 action is not None 9206 and isinstance(action, tuple) 9207 and len(action) == 3 9208 and action[0] == 'revertTo' 9209 and isinstance(action[1], base.SaveSlot) 9210 and isinstance(action[2], set) 9211 and all(isinstance(x, str) for x in action[2]) 9212 ): 9213 _, slot, aspects = action 9214 if slot not in now.saves: 9215 raise KeyError( 9216 f"Cannot load save slot {slot!r} because no save" 9217 f" data has been established for that slot." 9218 ) 9219 load = now.saves[slot] 9220 rGraph, rState = base.revertedState( 9221 (now.graph, now.state), 9222 load, 9223 aspects 9224 ) 9225 reverted = base.Situation( 9226 graph=rGraph, 9227 state=rState, 9228 type='pending', 9229 action=None, 9230 saves=copy.deepcopy(now.saves), 9231 tags={}, 9232 annotations=[] 9233 ) 9234 self.situations.append(reverted) 9235 # Apply any active triggers (edits reverted) 9236 self.applyActiveTriggers() 9237 # Figure out destinations set to return 9238 newDestinations = set() 9239 newPr = rState['primaryDecision'] 9240 if newPr is not None: 9241 newDestinations.add(newPr) 9242 return (reverted, newDestinations) 9243 9244 # TODO: These deep copies are expensive time-wise. Can we avoid 9245 # them? Probably not. 9246 newGraph = copy.deepcopy(now.graph) 9247 newState = copy.deepcopy(now.state) 9248 newSaves = copy.copy(now.saves) # a shallow copy 9249 newTags: Dict[base.Tag, base.TagValue] = {} 9250 newAnnotations: List[base.Annotation] = [] 9251 updated = base.Situation( 9252 graph=newGraph, 9253 state=newState, 9254 type='pending', 9255 action=None, 9256 saves=newSaves, 9257 tags=newTags, 9258 annotations=newAnnotations 9259 ) 9260 9261 targetContext: base.FocalContext 9262 9263 # Now that action effects have been imprinted into the updated 9264 # situation, append it to our situations list 9265 self.situations.append(updated) 9266 9267 # Figure out effects of the action: 9268 if action is None: 9269 raise InvalidActionError( 9270 "None cannot be used as an action when advancing the" 9271 " situation." 9272 ) 9273 9274 aLen = len(action) 9275 9276 destIDs = set() 9277 9278 if ( 9279 action[0] in ('start', 'take', 'explore', 'warp') 9280 and any( 9281 newGraph.domainFor(d) == ENDINGS_DOMAIN 9282 for d in self.getActiveDecisions() 9283 ) 9284 ): 9285 activeEndings = [ 9286 d 9287 for d in self.getActiveDecisions() 9288 if newGraph.domainFor(d) == ENDINGS_DOMAIN 9289 ] 9290 raise InvalidActionError( 9291 f"Attempted to {action[0]!r} while an ending was" 9292 f" active. Active endings are:" 9293 f"\n{newGraph.namesListing(activeEndings)}" 9294 ) 9295 9296 if action == ('noAction',): 9297 # No updates needed 9298 pass 9299 9300 elif ( 9301 not isinstance(action, tuple) 9302 or (action[0] not in get_args(base.ExplorationActionType)) 9303 or not (2 <= aLen <= 7) 9304 ): 9305 raise InvalidActionError( 9306 f"Invalid ExplorationAction tuple (must be a tuple that" 9307 f" starts with an ExplorationActionType and has 2-6" 9308 f" entries if it's not ('noAction',)):" 9309 f"\n{repr(action)}" 9310 ) 9311 9312 elif action[0] == 'start': 9313 ( 9314 _, 9315 positionSpecifier, 9316 primary, 9317 domain, 9318 capabilities, 9319 mechanismStates, 9320 customState 9321 ) = cast( 9322 Tuple[ 9323 Literal['start'], 9324 Union[ 9325 base.DecisionID, 9326 Dict[base.FocalPointName, base.DecisionID], 9327 Set[base.DecisionID] 9328 ], 9329 Optional[base.DecisionID], 9330 base.Domain, 9331 Optional[base.CapabilitySet], 9332 Optional[Dict[base.MechanismID, base.MechanismState]], 9333 Optional[dict] 9334 ], 9335 action 9336 ) 9337 targetContext = newState['contexts'][ 9338 newState['activeContext'] 9339 ] 9340 9341 targetFocalization = base.getDomainFocalization( 9342 targetContext, 9343 domain 9344 ) # sets up 'singular' as default if 9345 9346 # Check if there are any already-active decisions. 9347 if targetContext['activeDecisions'][domain] is not None: 9348 raise BadStart( 9349 f"Cannot start in domain {repr(domain)} because" 9350 f" that domain already has a position. 'start' may" 9351 f" only be used with domains that don't yet have" 9352 f" any position information." 9353 ) 9354 9355 # Make the domain active 9356 if domain not in targetContext['activeDomains']: 9357 targetContext['activeDomains'].add(domain) 9358 9359 # Check position info matches focalization type and update 9360 # exploration statuses 9361 if isinstance(positionSpecifier, base.DecisionID): 9362 if targetFocalization != 'singular': 9363 raise BadStart( 9364 f"Invalid position specifier" 9365 f" {repr(positionSpecifier)} (type" 9366 f" {type(positionSpecifier)}). Domain" 9367 f" {repr(domain)} has {targetFocalization}" 9368 f" focalization." 9369 ) 9370 base.setExplorationStatus( 9371 updated, 9372 positionSpecifier, 9373 'exploring', 9374 upgradeOnly=True 9375 ) 9376 destIDs.add(positionSpecifier) 9377 elif isinstance(positionSpecifier, dict): 9378 if targetFocalization != 'plural': 9379 raise BadStart( 9380 f"Invalid position specifier" 9381 f" {repr(positionSpecifier)} (type" 9382 f" {type(positionSpecifier)}). Domain" 9383 f" {repr(domain)} has {targetFocalization}" 9384 f" focalization." 9385 ) 9386 destIDs |= set(positionSpecifier.values()) 9387 elif isinstance(positionSpecifier, set): 9388 if targetFocalization != 'spreading': 9389 raise BadStart( 9390 f"Invalid position specifier" 9391 f" {repr(positionSpecifier)} (type" 9392 f" {type(positionSpecifier)}). Domain" 9393 f" {repr(domain)} has {targetFocalization}" 9394 f" focalization." 9395 ) 9396 destIDs |= positionSpecifier 9397 else: 9398 raise TypeError( 9399 f"Invalid position specifier" 9400 f" {repr(positionSpecifier)} (type" 9401 f" {type(positionSpecifier)}). It must be a" 9402 f" DecisionID, a dictionary from FocalPointNames to" 9403 f" DecisionIDs, or a set of DecisionIDs, according" 9404 f" to the focalization of the relevant domain." 9405 ) 9406 9407 # Put specified position(s) in place 9408 # TODO: This cast is really silly... 9409 targetContext['activeDecisions'][domain] = cast( 9410 Union[ 9411 None, 9412 base.DecisionID, 9413 Dict[base.FocalPointName, Optional[base.DecisionID]], 9414 Set[base.DecisionID] 9415 ], 9416 positionSpecifier 9417 ) 9418 9419 # Set primary decision 9420 newState['primaryDecision'] = primary 9421 9422 # Set capabilities 9423 if capabilities is not None: 9424 targetContext['capabilities'] = capabilities 9425 9426 # Set mechanism states 9427 if mechanismStates is not None: 9428 newState['mechanisms'] = mechanismStates 9429 9430 # Set custom state 9431 if customState is not None: 9432 newState['custom'] = customState 9433 9434 elif action[0] in ('explore', 'take', 'warp'): # similar handling 9435 assert ( 9436 len(action) == 3 9437 or len(action) == 4 9438 or len(action) == 6 9439 or len(action) == 7 9440 ) 9441 # Set up necessary variables 9442 cSpec: base.ContextSpecifier = "active" 9443 fromID: Optional[base.DecisionID] = None 9444 takeTransition: Optional[base.Transition] = None 9445 outcomes: List[bool] = [] 9446 destID: base.DecisionID # No starting value as it's not optional 9447 moveInDomain: Optional[base.Domain] = None 9448 moveWhich: Optional[base.FocalPointName] = None 9449 9450 # Figure out target context 9451 if isinstance(action[1], str): 9452 if action[1] not in get_args(base.ContextSpecifier): 9453 raise InvalidActionError( 9454 f"Action specifies {repr(action[1])} context," 9455 f" but that's not a valid context specifier." 9456 f" The valid options are:" 9457 f"\n{repr(get_args(base.ContextSpecifier))}" 9458 ) 9459 else: 9460 cSpec = cast(base.ContextSpecifier, action[1]) 9461 else: # Must be a `FocalPointSpecifier` 9462 cSpec, moveInDomain, moveWhich = cast( 9463 base.FocalPointSpecifier, 9464 action[1] 9465 ) 9466 assert moveInDomain is not None 9467 9468 # Grab target context to work in 9469 if cSpec == 'common': 9470 targetContext = newState['common'] 9471 else: 9472 targetContext = newState['contexts'][ 9473 newState['activeContext'] 9474 ] 9475 9476 # Check focalization of the target domain 9477 if moveInDomain is not None: 9478 fType = base.getDomainFocalization( 9479 targetContext, 9480 moveInDomain 9481 ) 9482 if ( 9483 ( 9484 isinstance(action[1], str) 9485 and fType == 'plural' 9486 ) or ( 9487 not isinstance(action[1], str) 9488 and fType != 'plural' 9489 ) 9490 ): 9491 raise ImpossibleActionError( 9492 f"Invalid ExplorationAction (moves in" 9493 f" plural-focalized domains must include a" 9494 f" FocalPointSpecifier, while moves in" 9495 f" non-plural-focalized domains must not." 9496 f" Domain {repr(moveInDomain)} is" 9497 f" {fType}-focalized):" 9498 f"\n{repr(action)}" 9499 ) 9500 9501 if action[0] == "warp": 9502 # It's a warp, so destination is specified directly 9503 if not isinstance(action[2], base.DecisionID): 9504 raise TypeError( 9505 f"Invalid ExplorationAction tuple (third part" 9506 f" must be a decision ID for 'warp' actions):" 9507 f"\n{repr(action)}" 9508 ) 9509 else: 9510 destID = cast(base.DecisionID, action[2]) 9511 9512 elif aLen == 4 or aLen == 7: 9513 # direct 'take' or 'explore' 9514 fromID = cast(base.DecisionID, action[2]) 9515 takeTransition, outcomes = cast( 9516 base.TransitionWithOutcomes, 9517 action[3] # type: ignore [misc] 9518 ) 9519 if ( 9520 not isinstance(fromID, base.DecisionID) 9521 or not isinstance(takeTransition, base.Transition) 9522 ): 9523 raise InvalidActionError( 9524 f"Invalid ExplorationAction tuple (for 'take' or" 9525 f" 'explore', if the length is 4/7, parts 2-4" 9526 f" must be a context specifier, a decision ID, and a" 9527 f" transition name. Got:" 9528 f"\n{repr(action)}" 9529 ) 9530 9531 try: 9532 destID = newGraph.destination(fromID, takeTransition) 9533 except MissingDecisionError: 9534 raise ImpossibleActionError( 9535 f"Invalid ExplorationAction: move from decision" 9536 f" {fromID} is invalid because there is no" 9537 f" decision with that ID in the current" 9538 f" graph." 9539 f"\nValid decisions are:" 9540 f"\n{newGraph.namesListing(newGraph)}" 9541 ) 9542 except MissingTransitionError: 9543 valid = newGraph.destinationsFrom(fromID) 9544 listing = newGraph.destinationsListing(valid) 9545 raise ImpossibleActionError( 9546 f"Invalid ExplorationAction: move from decision" 9547 f" {newGraph.identityOf(fromID)}" 9548 f" along transition {repr(takeTransition)} is" 9549 f" invalid because there is no such transition" 9550 f" at that decision." 9551 f"\nValid transitions there are:" 9552 f"\n{listing}" 9553 ) 9554 targetActive = targetContext['activeDecisions'] 9555 if moveInDomain is not None: 9556 activeInDomain = targetActive[moveInDomain] 9557 if ( 9558 ( 9559 isinstance(activeInDomain, base.DecisionID) 9560 and fromID != activeInDomain 9561 ) 9562 or ( 9563 isinstance(activeInDomain, set) 9564 and fromID not in activeInDomain 9565 ) 9566 or ( 9567 isinstance(activeInDomain, dict) 9568 and fromID not in activeInDomain.values() 9569 ) 9570 ): 9571 raise ImpossibleActionError( 9572 f"Invalid ExplorationAction: move from" 9573 f" decision {fromID} is invalid because" 9574 f" that decision is not active in domain" 9575 f" {repr(moveInDomain)} in the current" 9576 f" graph." 9577 f"\nValid decisions are:" 9578 f"\n{newGraph.namesListing(newGraph)}" 9579 ) 9580 9581 elif aLen == 3 or aLen == 6: 9582 # 'take' or 'explore' focal point 9583 # We know that moveInDomain is not None here. 9584 assert moveInDomain is not None 9585 if not isinstance(action[2], base.Transition): 9586 raise InvalidActionError( 9587 f"Invalid ExplorationAction tuple (for 'take'" 9588 f" actions if the second part is a" 9589 f" FocalPointSpecifier the third part must be a" 9590 f" transition name):" 9591 f"\n{repr(action)}" 9592 ) 9593 9594 takeTransition, outcomes = cast( 9595 base.TransitionWithOutcomes, 9596 action[2] 9597 ) 9598 targetActive = targetContext['activeDecisions'] 9599 activeInDomain = cast( 9600 Dict[base.FocalPointName, Optional[base.DecisionID]], 9601 targetActive[moveInDomain] 9602 ) 9603 if ( 9604 moveInDomain is not None 9605 and ( 9606 not isinstance(activeInDomain, dict) 9607 or moveWhich not in activeInDomain 9608 ) 9609 ): 9610 raise ImpossibleActionError( 9611 f"Invalid ExplorationAction: move of focal" 9612 f" point {repr(moveWhich)} in domain" 9613 f" {repr(moveInDomain)} is invalid because" 9614 f" that domain does not have a focal point" 9615 f" with that name." 9616 ) 9617 fromID = activeInDomain[moveWhich] 9618 if fromID is None: 9619 raise ImpossibleActionError( 9620 f"Invalid ExplorationAction: move of focal" 9621 f" point {repr(moveWhich)} in domain" 9622 f" {repr(moveInDomain)} is invalid because" 9623 f" that focal point does not have a position" 9624 f" at this step." 9625 ) 9626 try: 9627 destID = newGraph.destination(fromID, takeTransition) 9628 except MissingDecisionError: 9629 raise ImpossibleActionError( 9630 f"Invalid exploration state: focal point" 9631 f" {repr(moveWhich)} in domain" 9632 f" {repr(moveInDomain)} specifies decision" 9633 f" {fromID} as the current position, but" 9634 f" that decision does not exist!" 9635 ) 9636 except MissingTransitionError: 9637 valid = newGraph.destinationsFrom(fromID) 9638 listing = newGraph.destinationsListing(valid) 9639 raise ImpossibleActionError( 9640 f"Invalid ExplorationAction: move of focal" 9641 f" point {repr(moveWhich)} in domain" 9642 f" {repr(moveInDomain)} along transition" 9643 f" {repr(takeTransition)} is invalid because" 9644 f" that focal point is at decision" 9645 f" {newGraph.identityOf(fromID)} and that" 9646 f" decision does not have an outgoing" 9647 f" transition with that name.\nValid" 9648 f" transitions from that decision are:" 9649 f"\n{listing}" 9650 ) 9651 9652 else: 9653 raise InvalidActionError( 9654 f"Invalid ExplorationAction: unrecognized" 9655 f" 'explore', 'take' or 'warp' format:" 9656 f"\n{action}" 9657 ) 9658 9659 # If we're exploring, update information for the destination 9660 if action[0] == 'explore': 9661 zone = cast(Optional[base.Zone], action[-1]) 9662 recipName = cast(Optional[base.Transition], action[-2]) 9663 destOrName = cast( 9664 Union[base.DecisionName, base.DecisionID, None], 9665 action[-3] 9666 ) 9667 if isinstance(destOrName, base.DecisionID): 9668 destID = destOrName 9669 9670 if fromID is None or takeTransition is None: 9671 raise ImpossibleActionError( 9672 f"Invalid ExplorationAction: exploration" 9673 f" has unclear origin decision or transition." 9674 f" Got:\n{action}" 9675 ) 9676 9677 currentDest = newGraph.destination(fromID, takeTransition) 9678 if not newGraph.isConfirmed(currentDest): 9679 newGraph.replaceUnconfirmed( 9680 fromID, 9681 takeTransition, 9682 destOrName, 9683 recipName, 9684 placeInZone=zone, 9685 forceNew=not isinstance(destOrName, base.DecisionID) 9686 ) 9687 else: 9688 # Otherwise, since the destination already existed 9689 # and was hooked up at the right decision, no graph 9690 # edits need to be made, unless we need to rename 9691 # the reciprocal. 9692 # TODO: Do we care about zones here? 9693 if recipName is not None: 9694 oldReciprocal = newGraph.getReciprocal( 9695 fromID, 9696 takeTransition 9697 ) 9698 if ( 9699 oldReciprocal is not None 9700 and oldReciprocal != recipName 9701 ): 9702 newGraph.addTransition( 9703 destID, 9704 recipName, 9705 fromID, 9706 None 9707 ) 9708 newGraph.setReciprocal( 9709 destID, 9710 recipName, 9711 takeTransition, 9712 setBoth=True 9713 ) 9714 newGraph.mergeTransitions( 9715 destID, 9716 oldReciprocal, 9717 recipName 9718 ) 9719 9720 # If we are moving along a transition, check requirements 9721 # and apply transition effects *before* updating our 9722 # position, and check that they don't cancel the normal 9723 # position update 9724 finalDest = None 9725 if takeTransition is not None: 9726 assert fromID is not None # both or neither 9727 if not self.isTraversable(fromID, takeTransition): 9728 req = now.graph.getTransitionRequirement( 9729 fromID, 9730 takeTransition 9731 ) 9732 # TODO: Alter warning message if transition is 9733 # deactivated vs. requirement not satisfied 9734 warnings.warn( 9735 ( 9736 f"The requirements for transition" 9737 f" {takeTransition!r} from decision" 9738 f" {now.graph.identityOf(fromID)} are" 9739 f" not met at step {len(self) - 1} (or that" 9740 f" transition has been deactivated):\n{req}" 9741 ), 9742 TransitionBlockedWarning 9743 ) 9744 9745 # Apply transition consequences to our new state and 9746 # figure out if we need to skip our normal update or not 9747 finalDest = self.applyTransitionConsequence( 9748 fromID, 9749 (takeTransition, outcomes), 9750 moveWhich, 9751 challengePolicy 9752 ) 9753 9754 # Check moveInDomain 9755 destDomain = newGraph.domainFor(destID) 9756 if moveInDomain is not None and moveInDomain != destDomain: 9757 raise ImpossibleActionError( 9758 f"Invalid ExplorationAction: move specified" 9759 f" domain {repr(moveInDomain)} as the domain of" 9760 f" the focal point to move, but the destination" 9761 f" of the move is {now.graph.identityOf(destID)}" 9762 f" which is in domain {repr(destDomain)}, so focal" 9763 f" point {repr(moveWhich)} cannot be moved there." 9764 ) 9765 9766 # Now that we know where we're going, update position 9767 # information (assuming it wasn't already set): 9768 if finalDest is None: 9769 finalDest = destID 9770 base.updatePosition( 9771 updated, 9772 destID, 9773 cSpec, 9774 moveWhich 9775 ) 9776 9777 destIDs.add(finalDest) 9778 9779 elif action[0] == "focus": 9780 # Figure out target context 9781 action = cast( 9782 Tuple[ 9783 Literal['focus'], 9784 base.ContextSpecifier, 9785 Set[base.Domain], 9786 Set[base.Domain] 9787 ], 9788 action 9789 ) 9790 contextSpecifier: base.ContextSpecifier = action[1] 9791 if contextSpecifier == 'common': 9792 targetContext = newState['common'] 9793 else: 9794 targetContext = newState['contexts'][ 9795 newState['activeContext'] 9796 ] 9797 9798 # Just need to swap out active domains 9799 goingOut, comingIn = cast( 9800 Tuple[Set[base.Domain], Set[base.Domain]], 9801 action[2:] 9802 ) 9803 if ( 9804 not isinstance(goingOut, set) 9805 or not isinstance(comingIn, set) 9806 or not all(isinstance(d, base.Domain) for d in goingOut) 9807 or not all(isinstance(d, base.Domain) for d in comingIn) 9808 ): 9809 raise InvalidActionError( 9810 f"Invalid ExplorationAction tuple (must have 4" 9811 f" parts if the first part is 'focus' and" 9812 f" the third and fourth parts must be sets of" 9813 f" domains):" 9814 f"\n{repr(action)}" 9815 ) 9816 activeSet = targetContext['activeDomains'] 9817 for dom in goingOut: 9818 try: 9819 activeSet.remove(dom) 9820 except KeyError: 9821 warnings.warn( 9822 ( 9823 f"Domain {repr(dom)} was deactivated at" 9824 f" step {len(self)} but it was already" 9825 f" inactive at that point." 9826 ), 9827 InactiveDomainWarning 9828 ) 9829 # TODO: Also warn for doubly-activated domains? 9830 activeSet |= comingIn 9831 9832 # destIDs remains empty in this case 9833 9834 elif action[0] == 'swap': # update which `FocalContext` is active 9835 newContext = cast(base.FocalContextName, action[1]) 9836 if newContext not in newState['contexts']: 9837 raise MissingFocalContextError( 9838 f"'swap' action with target {repr(newContext)} is" 9839 f" invalid because no context with that name" 9840 f" exists." 9841 ) 9842 newState['activeContext'] = newContext 9843 9844 # destIDs remains empty in this case 9845 9846 elif action[0] == 'focalize': # create new `FocalContext` 9847 newContext = cast(base.FocalContextName, action[1]) 9848 if newContext in newState['contexts']: 9849 raise FocalContextCollisionError( 9850 f"'focalize' action with target {repr(newContext)}" 9851 f" is invalid because a context with that name" 9852 f" already exists." 9853 ) 9854 newState['contexts'][newContext] = base.emptyFocalContext() 9855 newState['activeContext'] = newContext 9856 9857 # destIDs remains empty in this case 9858 9859 # revertTo is handled above 9860 else: 9861 raise InvalidActionError( 9862 f"Invalid ExplorationAction tuple (first item must be" 9863 f" an ExplorationActionType, and tuple must be length-1" 9864 f" if the action type is 'noAction'):" 9865 f"\n{repr(action)}" 9866 ) 9867 9868 # Apply any active triggers 9869 followTo = self.applyActiveTriggers() 9870 if followTo is not None: 9871 destIDs.add(followTo) 9872 # TODO: Re-work to work with multiple position updates in 9873 # different focal contexts, domains, and/or for different 9874 # focal points in plural-focalized domains. 9875 9876 return (updated, destIDs) 9877 9878 def applyActiveTriggers(self) -> Optional[base.DecisionID]: 9879 """ 9880 Finds all actions with the 'trigger' tag attached to currently 9881 active decisions, and applies their effects if their requirements 9882 are met (ordered by decision-ID with ties broken alphabetically 9883 by action name). 9884 9885 'bounce', 'goto' and 'follow' effects may apply. However, any 9886 new triggers that would be activated because of decisions 9887 reached by such effects will not apply. Note that 'bounce' 9888 effects update position to the decision where the action was 9889 attached, which is usually a no-op. This function returns the 9890 decision ID of the decision reached by the last decision-moving 9891 effect applied, or `None` if no such effects triggered. 9892 9893 TODO: What about situations where positions are updated in 9894 multiple domains or multiple foal points in a plural domain are 9895 independently updated? 9896 9897 TODO: Tests for this! 9898 """ 9899 active = self.getActiveDecisions() 9900 now = self.getSituation() 9901 graph = now.graph 9902 finalFollow = None 9903 for decision in sorted(active): 9904 for action in graph.decisionActions(decision): 9905 if ( 9906 'trigger' in graph.transitionTags(decision, action) 9907 and self.isTraversable(decision, action) 9908 ): 9909 followTo = self.applyTransitionConsequence( 9910 decision, 9911 action 9912 ) 9913 if followTo is not None: 9914 # TODO: How will triggers interact with 9915 # plural-focalized domains? Probably need to fix 9916 # this to detect moveWhich based on which focal 9917 # points are at the decision where the transition 9918 # is, and then apply this to each of them? 9919 base.updatePosition(now, followTo) 9920 finalFollow = followTo 9921 9922 return finalFollow 9923 9924 def explore( 9925 self, 9926 transition: base.AnyTransition, 9927 destination: Union[base.DecisionName, base.DecisionID, None], 9928 reciprocal: Optional[base.Transition] = None, 9929 zone: Optional[base.Zone] = base.DefaultZone, 9930 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 9931 whichFocus: Optional[base.FocalPointSpecifier] = None, 9932 inCommon: Union[bool, Literal["auto"]] = "auto", 9933 decisionType: base.DecisionType = "active", 9934 challengePolicy: base.ChallengePolicy = "specified" 9935 ) -> base.DecisionID: 9936 """ 9937 Adds a new situation to the exploration representing the 9938 traversal of the specified transition (possibly with outcomes 9939 specified for challenges among that transitions consequences). 9940 Uses `deduceTransitionDetailsAtStep` to figure out from the 9941 transition name which specific transition is taken (and which 9942 focal point is updated if necessary). This uses the 9943 `fromDecision`, `whichFocus`, and `inCommon` optional 9944 parameters, and also determines whether to update the common or 9945 the active `FocalContext`. Sets the exploration status of the 9946 decision explored to 'exploring'. Returns the decision ID for 9947 the destination reached, accounting for goto/bounce/follow 9948 effects that might have triggered. 9949 9950 The `destination` will be used to name the newly-explored 9951 decision, except when it's a `DecisionID`, in which case that 9952 decision must be unvisited, and we'll connect the specified 9953 transition to that decision. 9954 9955 The focalization of the destination domain in the context to be 9956 updated determines how active decisions are changed: 9957 9958 - If the destination domain is focalized as 'single', then in 9959 the subsequent `Situation`, the destination decision will 9960 become the single active decision in that domain. 9961 - If it's focalized as 'plural', then one of the 9962 `FocalPointName`s for that domain will be moved to activate 9963 that decision; which one can be specified using `whichFocus` 9964 or if left unspecified, will be deduced: if the starting 9965 decision is in the same domain, then the 9966 alphabetically-earliest focal point which is at the starting 9967 decision will be moved. If the starting position is in a 9968 different domain, then the alphabetically earliest focal 9969 point among all focal points in the destination domain will 9970 be moved. 9971 - If it's focalized as 'spreading', then the destination 9972 decision will be added to the set of active decisions in 9973 that domain, without removing any. 9974 9975 The transition named must have been pointing to an unvisited 9976 decision (see `hasBeenVisited`), and the name of that decision 9977 will be updated if a `destination` value is given (a 9978 `DecisionCollisionWarning` will be issued if the destination 9979 name is a duplicate of another name in the graph, although this 9980 is not an error). Additionally: 9981 9982 - If a `reciprocal` name is specified, the reciprocal transition 9983 will be renamed using that name, or created with that name if 9984 it didn't already exist. If reciprocal is left as `None` (the 9985 default) then no change will be made to the reciprocal 9986 transition, and it will not be created if it doesn't exist. 9987 - If a `zone` is specified, the newly-explored decision will be 9988 added to that zone (and that zone will be created at level 0 9989 if it didn't already exist). If `zone` is set to `None` then 9990 it will not be added to any new zones. If `zone` is left as 9991 the default (the `base.DefaultZone` value) then the explored 9992 decision will be added to each zone that the decision it was 9993 explored from is a part of. If a zone needs to be created, 9994 that zone will be added as a sub-zone of each zone which is a 9995 parent of a zone that directly contains the origin decision. 9996 - An `ExplorationStatusError` will be raised if the specified 9997 transition leads to a decision whose `ExplorationStatus` is 9998 'exploring' or higher (i.e., `hasBeenVisited`). (Use 9999 `returnTo` instead to adjust things when a transition to an 10000 unknown destination turns out to lead to an already-known 10001 destination.) 10002 - A `TransitionBlockedWarning` will be issued if the specified 10003 transition is not traversable given the current game state 10004 (but in that last case the step will still be taken). 10005 - By default, the decision type for the new step will be 10006 'active', but a `decisionType` value can be specified to 10007 override that. 10008 - By default, the 'mostLikely' `ChallengePolicy` will be used to 10009 resolve challenges in the consequence of the transition 10010 taken, but an alternate policy can be supplied using the 10011 `challengePolicy` argument. 10012 """ 10013 now = self.getSituation() 10014 10015 transitionName, outcomes = base.nameAndOutcomes(transition) 10016 10017 # Deduce transition details from the name + optional specifiers 10018 ( 10019 using, 10020 fromID, 10021 destID, 10022 whichFocus 10023 ) = self.deduceTransitionDetailsAtStep( 10024 -1, 10025 transitionName, 10026 fromDecision, 10027 whichFocus, 10028 inCommon 10029 ) 10030 10031 # Issue a warning if the destination name is already in use 10032 if destination is not None: 10033 if isinstance(destination, base.DecisionName): 10034 try: 10035 existingID = now.graph.resolveDecision(destination) 10036 collision = existingID != destID 10037 except MissingDecisionError: 10038 collision = False 10039 except AmbiguousDecisionSpecifierError: 10040 collision = True 10041 10042 if collision and WARN_OF_NAME_COLLISIONS: 10043 warnings.warn( 10044 ( 10045 f"The destination name {repr(destination)} is" 10046 f" already in use when exploring transition" 10047 f" {repr(transition)} from decision" 10048 f" {now.graph.identityOf(fromID)} at step" 10049 f" {len(self) - 1}." 10050 ), 10051 DecisionCollisionWarning 10052 ) 10053 10054 # TODO: Different terminology for "exploration state above 10055 # noticed" vs. "DG thinks it's been visited"... 10056 if ( 10057 self.hasBeenVisited(destID) 10058 ): 10059 raise ExplorationStatusError( 10060 f"Cannot explore to decision" 10061 f" {now.graph.identityOf(destID)} because it has" 10062 f" already been visited. Use returnTo instead of" 10063 f" explore when discovering a connection back to a" 10064 f" previously-explored decision." 10065 ) 10066 10067 if ( 10068 isinstance(destination, base.DecisionID) 10069 and self.hasBeenVisited(destination) 10070 ): 10071 raise ExplorationStatusError( 10072 f"Cannot explore to decision" 10073 f" {now.graph.identityOf(destination)} because it has" 10074 f" already been visited. Use returnTo instead of" 10075 f" explore when discovering a connection back to a" 10076 f" previously-explored decision." 10077 ) 10078 10079 actionTaken: base.ExplorationAction = ( 10080 'explore', 10081 using, 10082 fromID, 10083 (transitionName, outcomes), 10084 destination, 10085 reciprocal, 10086 zone 10087 ) 10088 if whichFocus is not None: 10089 # A move-from-specific-focal-point action 10090 actionTaken = ( 10091 'explore', 10092 whichFocus, 10093 (transitionName, outcomes), 10094 destination, 10095 reciprocal, 10096 zone 10097 ) 10098 10099 # Advance the situation, applying transition effects and 10100 # updating the destination decision. 10101 _, finalDest = self.advanceSituation( 10102 actionTaken, 10103 decisionType, 10104 challengePolicy 10105 ) 10106 10107 # TODO: Is this assertion always valid? 10108 assert len(finalDest) == 1 10109 return next(x for x in finalDest) 10110 10111 def returnTo( 10112 self, 10113 transition: base.AnyTransition, 10114 destination: base.AnyDecisionSpecifier, 10115 reciprocal: Optional[base.Transition] = None, 10116 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 10117 whichFocus: Optional[base.FocalPointSpecifier] = None, 10118 inCommon: Union[bool, Literal["auto"]] = "auto", 10119 decisionType: base.DecisionType = "active", 10120 challengePolicy: base.ChallengePolicy = "specified" 10121 ) -> base.DecisionID: 10122 """ 10123 Adds a new graph to the exploration that replaces the given 10124 transition at the current position (which must lead to an unknown 10125 node, or a `MissingDecisionError` will result). The new 10126 transition will connect back to the specified destination, which 10127 must already exist (or a different `ValueError` will be raised). 10128 Returns the decision ID for the destination reached. 10129 10130 Deduces transition details using the optional `fromDecision`, 10131 `whichFocus`, and `inCommon` arguments in addition to the 10132 `transition` value; see `deduceTransitionDetailsAtStep`. 10133 10134 If a `reciprocal` transition is specified, that transition must 10135 either not already exist in the destination decision or lead to 10136 an unknown region; it will be replaced (or added) as an edge 10137 leading back to the current position. 10138 10139 The `decisionType` and `challengePolicy` optional arguments are 10140 used for `advanceSituation`. 10141 10142 A `TransitionBlockedWarning` will be issued if the requirements 10143 for the transition are not met, but the step will still be taken. 10144 Raises a `MissingDecisionError` if there is no current 10145 transition. 10146 """ 10147 now = self.getSituation() 10148 10149 transitionName, outcomes = base.nameAndOutcomes(transition) 10150 10151 # Deduce transition details from the name + optional specifiers 10152 ( 10153 using, 10154 fromID, 10155 destID, 10156 whichFocus 10157 ) = self.deduceTransitionDetailsAtStep( 10158 -1, 10159 transitionName, 10160 fromDecision, 10161 whichFocus, 10162 inCommon 10163 ) 10164 10165 # Replace with connection to existing destination 10166 destID = now.graph.resolveDecision(destination) 10167 if not self.hasBeenVisited(destID): 10168 raise ExplorationStatusError( 10169 f"Cannot return to decision" 10170 f" {now.graph.identityOf(destID)} because it has NOT" 10171 f" already been at least partially explored. Use" 10172 f" explore instead of returnTo when discovering a" 10173 f" connection to a previously-unexplored decision." 10174 ) 10175 10176 now.graph.replaceUnconfirmed( 10177 fromID, 10178 transitionName, 10179 destID, 10180 reciprocal 10181 ) 10182 10183 # A move-from-decision action 10184 actionTaken: base.ExplorationAction = ( 10185 'take', 10186 using, 10187 fromID, 10188 (transitionName, outcomes) 10189 ) 10190 if whichFocus is not None: 10191 # A move-from-specific-focal-point action 10192 actionTaken = ('take', whichFocus, (transitionName, outcomes)) 10193 10194 # Next, advance the situation, applying transition effects 10195 _, finalDest = self.advanceSituation( 10196 actionTaken, 10197 decisionType, 10198 challengePolicy 10199 ) 10200 10201 assert len(finalDest) == 1 10202 return next(x for x in finalDest) 10203 10204 def takeAction( 10205 self, 10206 action: base.AnyTransition, 10207 requires: Optional[base.Requirement] = None, 10208 consequence: Optional[base.Consequence] = None, 10209 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 10210 whichFocus: Optional[base.FocalPointSpecifier] = None, 10211 inCommon: Union[bool, Literal["auto"]] = "auto", 10212 decisionType: base.DecisionType = "active", 10213 challengePolicy: base.ChallengePolicy = "specified" 10214 ) -> base.DecisionID: 10215 """ 10216 Adds a new graph to the exploration based on taking the given 10217 action, which must be a self-transition in the graph. If the 10218 action does not already exist in the graph, it will be created. 10219 Either way if requirements and/or a consequence are supplied, 10220 the requirements and consequence of the action will be updated 10221 to match them, and those are the requirements/consequence that 10222 will count. 10223 10224 Returns the decision ID for the decision reached, which normally 10225 is the same action you were just at, but which might be altered 10226 by goto, bounce, and/or follow effects. 10227 10228 Issues a `TransitionBlockedWarning` if the current game state 10229 doesn't satisfy the requirements for the action. 10230 10231 The `fromDecision`, `whichFocus`, and `inCommon` arguments are 10232 used for `deduceTransitionDetailsAtStep`, while `decisionType` 10233 and `challengePolicy` are used for `advanceSituation`. 10234 10235 When an action is being created, `fromDecision` (or 10236 `whichFocus`) must be specified, since the source decision won't 10237 be deducible from the transition name. Note that if a transition 10238 with the given name exists from *any* active decision, it will 10239 be used instead of creating a new action (possibly resulting in 10240 an error if it's not a self-loop transition). Also, you may get 10241 an `AmbiguousTransitionError` if several transitions with that 10242 name exist; in that case use `fromDecision` and/or `whichFocus` 10243 to disambiguate. 10244 """ 10245 now = self.getSituation() 10246 graph = now.graph 10247 10248 actionName, outcomes = base.nameAndOutcomes(action) 10249 10250 try: 10251 ( 10252 using, 10253 fromID, 10254 destID, 10255 whichFocus 10256 ) = self.deduceTransitionDetailsAtStep( 10257 -1, 10258 actionName, 10259 fromDecision, 10260 whichFocus, 10261 inCommon 10262 ) 10263 10264 if destID != fromID: 10265 raise ValueError( 10266 f"Cannot take action {repr(action)} because it's a" 10267 f" transition to another decision, not an action" 10268 f" (use explore, returnTo, and/or retrace instead)." 10269 ) 10270 10271 except MissingTransitionError: 10272 using = 'active' 10273 if inCommon is True: 10274 using = 'common' 10275 10276 if fromDecision is not None: 10277 fromID = graph.resolveDecision(fromDecision) 10278 elif whichFocus is not None: 10279 maybeFromID = base.resolvePosition(now, whichFocus) 10280 if maybeFromID is None: 10281 raise MissingDecisionError( 10282 f"Focal point {repr(whichFocus)} was specified" 10283 f" in takeAction but that focal point doesn't" 10284 f" have a position." 10285 ) 10286 else: 10287 fromID = maybeFromID 10288 else: 10289 raise AmbiguousTransitionError( 10290 f"Taking action {repr(action)} is ambiguous because" 10291 f" the source decision has not been specified via" 10292 f" either fromDecision or whichFocus, and we" 10293 f" couldn't find an existing action with that name." 10294 ) 10295 10296 # Since the action doesn't exist, add it: 10297 graph.addAction(fromID, actionName, requires, consequence) 10298 10299 # Update the transition requirement/consequence if requested 10300 # (before the action is taken) 10301 if requires is not None: 10302 graph.setTransitionRequirement(fromID, actionName, requires) 10303 if consequence is not None: 10304 graph.setConsequence(fromID, actionName, consequence) 10305 10306 # A move-from-decision action 10307 actionTaken: base.ExplorationAction = ( 10308 'take', 10309 using, 10310 fromID, 10311 (actionName, outcomes) 10312 ) 10313 if whichFocus is not None: 10314 # A move-from-specific-focal-point action 10315 actionTaken = ('take', whichFocus, (actionName, outcomes)) 10316 10317 _, finalDest = self.advanceSituation( 10318 actionTaken, 10319 decisionType, 10320 challengePolicy 10321 ) 10322 10323 assert len(finalDest) in (0, 1) 10324 if len(finalDest) == 1: 10325 return next(x for x in finalDest) 10326 else: 10327 return fromID 10328 10329 def retrace( 10330 self, 10331 transition: base.AnyTransition, 10332 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 10333 whichFocus: Optional[base.FocalPointSpecifier] = None, 10334 inCommon: Union[bool, Literal["auto"]] = "auto", 10335 decisionType: base.DecisionType = "active", 10336 challengePolicy: base.ChallengePolicy = "specified" 10337 ) -> base.DecisionID: 10338 """ 10339 Adds a new graph to the exploration based on taking the given 10340 transition, which must already exist and which must not lead to 10341 an unknown region. Returns the ID of the destination decision, 10342 accounting for goto, bounce, and/or follow effects. 10343 10344 Issues a `TransitionBlockedWarning` if the current game state 10345 doesn't satisfy the requirements for the transition. 10346 10347 The `fromDecision`, `whichFocus`, and `inCommon` arguments are 10348 used for `deduceTransitionDetailsAtStep`, while `decisionType` 10349 and `challengePolicy` are used for `advanceSituation`. 10350 """ 10351 now = self.getSituation() 10352 10353 transitionName, outcomes = base.nameAndOutcomes(transition) 10354 10355 ( 10356 using, 10357 fromID, 10358 destID, 10359 whichFocus 10360 ) = self.deduceTransitionDetailsAtStep( 10361 -1, 10362 transitionName, 10363 fromDecision, 10364 whichFocus, 10365 inCommon 10366 ) 10367 10368 visited = self.hasBeenVisited(destID) 10369 confirmed = now.graph.isConfirmed(destID) 10370 if not confirmed: 10371 raise ExplorationStatusError( 10372 f"Cannot retrace transition {transition!r} from" 10373 f" decision {now.graph.identityOf(fromID)} because it" 10374 f" leads to an unconfirmed decision.\nUse" 10375 f" `DiscreteExploration.explore` and provide" 10376 f" destination decision details instead." 10377 ) 10378 if not visited: 10379 raise ExplorationStatusError( 10380 f"Cannot retrace transition {transition!r} from" 10381 f" decision {now.graph.identityOf(fromID)} because it" 10382 f" leads to an unvisited decision.\nUse" 10383 f" `DiscreteExploration.explore` and provide" 10384 f" destination decision details instead." 10385 ) 10386 10387 # A move-from-decision action 10388 actionTaken: base.ExplorationAction = ( 10389 'take', 10390 using, 10391 fromID, 10392 (transitionName, outcomes) 10393 ) 10394 if whichFocus is not None: 10395 # A move-from-specific-focal-point action 10396 actionTaken = ('take', whichFocus, (transitionName, outcomes)) 10397 10398 _, finalDest = self.advanceSituation( 10399 actionTaken, 10400 decisionType, 10401 challengePolicy 10402 ) 10403 10404 assert len(finalDest) == 1 10405 return next(x for x in finalDest) 10406 10407 def warp( 10408 self, 10409 destination: base.AnyDecisionSpecifier, 10410 consequence: Optional[base.Consequence] = None, 10411 domain: Optional[base.Domain] = None, 10412 zone: Optional[base.Zone] = base.DefaultZone, 10413 whichFocus: Optional[base.FocalPointSpecifier] = None, 10414 inCommon: Union[bool] = False, 10415 decisionType: base.DecisionType = "active", 10416 challengePolicy: base.ChallengePolicy = "specified" 10417 ) -> base.DecisionID: 10418 """ 10419 Adds a new graph to the exploration that's a copy of the current 10420 graph, with the position updated to be at the destination without 10421 actually creating a transition from the old position to the new 10422 one. Returns the ID of the decision warped to (accounting for 10423 any goto or follow effects triggered). 10424 10425 Any provided consequences are applied, but are not associated 10426 with any transition (so any delays and charges are ignored, and 10427 'bounce' effects don't actually cancel the warp). 'goto' or 10428 'follow' effects might change the warp destination; 'follow' 10429 effects take the original destination as their starting point. 10430 Any mechanisms mentioned in extra consequences will be found 10431 based on the destination. Outcomes in supplied challenges should 10432 be pre-specified, or else they will be resolved with the 10433 `challengePolicy`. 10434 10435 `whichFocus` may be specified when the destination domain's 10436 focalization is 'plural' but for 'singular' or 'spreading' 10437 destination domains it is not allowed. `inCommon` determines 10438 whether the common or the active focal context is updated 10439 (default is to update the active context). The `decisionType` 10440 and `challengePolicy` are used for `advanceSituation`. 10441 10442 - If the destination did not already exist, it will be created. 10443 Initially, it will be disconnected from all other decisions. 10444 In this case, the `domain` value can be used to put it in a 10445 non-default domain. 10446 - The position is set to the specified destination, and if a 10447 `consequence` is specified it is applied. Note that 10448 'deactivate' effects are NOT allowed, and 'edit' effects 10449 must establish their own transition target because there is 10450 no transition that the effects are being applied to. 10451 - If the destination had been unexplored, its exploration status 10452 will be set to 'exploring'. 10453 - If a `zone` is specified, the destination will be added to that 10454 zone (even if the destination already existed) and that zone 10455 will be created (as a level-0 zone) if need be. If `zone` is 10456 set to `None`, then no zone will be applied. If `zone` is 10457 left as the default (`base.DefaultZone`) and the 10458 focalization of the destination domain is 'singular' or 10459 'plural' and the destination is newly created and there is 10460 an origin and the origin is in the same domain as the 10461 destination, then the destination will be added to all zones 10462 that the origin was a part of if the destination is newly 10463 created, but otherwise the destination will not be added to 10464 any zones. If the specified zone has to be created and 10465 there's an origin decision, it will be added as a sub-zone 10466 to all parents of zones directly containing the origin, as 10467 long as the origin is in the same domain as the destination. 10468 """ 10469 now = self.getSituation() 10470 graph = now.graph 10471 10472 fromID: Optional[base.DecisionID] 10473 10474 new = False 10475 try: 10476 destID = graph.resolveDecision(destination) 10477 except MissingDecisionError: 10478 if isinstance(destination, tuple): 10479 # just the name; ignore zone/domain 10480 destination = destination[-1] 10481 10482 if not isinstance(destination, base.DecisionName): 10483 raise TypeError( 10484 f"Warp destination {repr(destination)} does not" 10485 f" exist, and cannot be created as it is not a" 10486 f" decision name." 10487 ) 10488 destID = graph.addDecision(destination, domain) 10489 graph.tagDecision(destID, 'unconfirmed') 10490 self.setExplorationStatus(destID, 'unknown') 10491 new = True 10492 10493 using: base.ContextSpecifier 10494 if inCommon: 10495 targetContext = self.getCommonContext() 10496 using = "common" 10497 else: 10498 targetContext = self.getActiveContext() 10499 using = "active" 10500 10501 destDomain = graph.domainFor(destID) 10502 targetFocalization = base.getDomainFocalization( 10503 targetContext, 10504 destDomain 10505 ) 10506 if targetFocalization == 'singular': 10507 targetActive = targetContext['activeDecisions'] 10508 if destDomain in targetActive: 10509 fromID = cast( 10510 base.DecisionID, 10511 targetContext['activeDecisions'][destDomain] 10512 ) 10513 else: 10514 fromID = None 10515 elif targetFocalization == 'plural': 10516 if whichFocus is None: 10517 raise AmbiguousTransitionError( 10518 f"Warping to {repr(destination)} is ambiguous" 10519 f" becuase domain {repr(destDomain)} has plural" 10520 f" focalization, and no whichFocus value was" 10521 f" specified." 10522 ) 10523 10524 fromID = base.resolvePosition( 10525 self.getSituation(), 10526 whichFocus 10527 ) 10528 else: 10529 fromID = None 10530 10531 # Handle zones 10532 if zone == base.DefaultZone: 10533 if ( 10534 new 10535 and fromID is not None 10536 and graph.domainFor(fromID) == destDomain 10537 ): 10538 for prevZone in graph.zoneParents(fromID): 10539 graph.addDecisionToZone(destination, prevZone) 10540 # Otherwise don't update zones 10541 elif zone is not None: 10542 # Newness is ignored when a zone is specified 10543 zone = cast(base.Zone, zone) 10544 # Create the zone at level 0 if it didn't already exist 10545 if graph.getZoneInfo(zone) is None: 10546 graph.createZone(zone, 0) 10547 # Add the newly created zone to each 2nd-level parent of 10548 # the previous decision if there is one and it's in the 10549 # same domain 10550 if ( 10551 fromID is not None 10552 and graph.domainFor(fromID) == destDomain 10553 ): 10554 for prevZone in graph.zoneParents(fromID): 10555 for prevUpper in graph.zoneParents(prevZone): 10556 graph.addZoneToZone(zone, prevUpper) 10557 # Finally add the destination to the (maybe new) zone 10558 graph.addDecisionToZone(destID, zone) 10559 # else don't touch zones 10560 10561 # Encode the action taken 10562 actionTaken: base.ExplorationAction 10563 if whichFocus is None: 10564 actionTaken = ( 10565 'warp', 10566 using, 10567 destID 10568 ) 10569 else: 10570 actionTaken = ( 10571 'warp', 10572 whichFocus, 10573 destID 10574 ) 10575 10576 # Advance the situation 10577 _, finalDests = self.advanceSituation( 10578 actionTaken, 10579 decisionType, 10580 challengePolicy 10581 ) 10582 now = self.getSituation() # updating just in case 10583 10584 assert len(finalDests) == 1 10585 finalDest = next(x for x in finalDests) 10586 10587 # Apply additional consequences: 10588 if consequence is not None: 10589 altDest = self.applyExtraneousConsequence( 10590 consequence, 10591 where=(destID, None), 10592 # TODO: Mechanism search from both ends? 10593 moveWhich=( 10594 whichFocus[-1] 10595 if whichFocus is not None 10596 else None 10597 ) 10598 ) 10599 if altDest is not None: 10600 finalDest = altDest 10601 now = self.getSituation() # updating just in case 10602 10603 return finalDest 10604 10605 def wait( 10606 self, 10607 consequence: Optional[base.Consequence] = None, 10608 decisionType: base.DecisionType = "active", 10609 challengePolicy: base.ChallengePolicy = "specified" 10610 ) -> Optional[base.DecisionID]: 10611 """ 10612 Adds a wait step. If a consequence is specified, it is applied, 10613 although it will not have any position/transition information 10614 available during resolution/application. 10615 10616 A decision type other than "active" and/or a challenge policy 10617 other than "specified" can be included (see `advanceSituation`). 10618 10619 The "pending" decision type may not be used, a `ValueError` will 10620 result. This allows None as the action for waiting while 10621 preserving the pending/None type/action combination for 10622 unresolved situations. 10623 10624 If a goto or follow effect in the applied consequence implies a 10625 position update, this will return the new destination ID; 10626 otherwise it will return `None`. Triggering a 'bounce' effect 10627 will be an error, because there is no position information for 10628 the effect. 10629 """ 10630 if decisionType == "pending": 10631 raise ValueError( 10632 "The 'pending' decision type may not be used for" 10633 " wait actions." 10634 ) 10635 self.advanceSituation(('noAction',), decisionType, challengePolicy) 10636 now = self.getSituation() 10637 if consequence is not None: 10638 if challengePolicy != "specified": 10639 base.resetChallengeOutcomes(consequence) 10640 observed = base.observeChallengeOutcomes( 10641 base.RequirementContext( 10642 state=now.state, 10643 graph=now.graph, 10644 searchFrom=set() 10645 ), 10646 consequence, 10647 location=None, # No position info 10648 policy=challengePolicy, 10649 knownOutcomes=None # bake outcomes into the consequence 10650 ) 10651 # No location information since we might have multiple 10652 # active decisions and there's no indication of which one 10653 # we're "waiting at." 10654 finalDest = self.applyExtraneousConsequence(observed) 10655 now = self.getSituation() # updating just in case 10656 10657 return finalDest 10658 else: 10659 return None 10660 10661 def revert( 10662 self, 10663 slot: base.SaveSlot = base.DEFAULT_SAVE_SLOT, 10664 aspects: Optional[Set[str]] = None, 10665 decisionType: base.DecisionType = "active" 10666 ) -> None: 10667 """ 10668 Reverts the game state to a previously-saved game state (saved 10669 via a 'save' effect). The save slot name and set of aspects to 10670 revert are required. By default, all aspects except the graph 10671 are reverted. 10672 """ 10673 if aspects is None: 10674 aspects = set() 10675 10676 action: base.ExplorationAction = ("revertTo", slot, aspects) 10677 10678 self.advanceSituation(action, decisionType) 10679 10680 def observeAll( 10681 self, 10682 where: base.AnyDecisionSpecifier, 10683 *transitions: Union[ 10684 base.Transition, 10685 Tuple[base.Transition, base.AnyDecisionSpecifier], 10686 Tuple[ 10687 base.Transition, 10688 base.AnyDecisionSpecifier, 10689 base.Transition 10690 ] 10691 ] 10692 ) -> List[base.DecisionID]: 10693 """ 10694 Observes one or more new transitions, applying changes to the 10695 current graph. The transitions can be specified in one of three 10696 ways: 10697 10698 1. A transition name. The transition will be created and will 10699 point to a new unexplored node. 10700 2. A pair containing a transition name and a destination 10701 specifier. If the destination does not exist it will be 10702 created as an unexplored node, although in that case the 10703 decision specifier may not be an ID. 10704 3. A triple containing a transition name, a destination 10705 specifier, and a reciprocal name. Works the same as the pair 10706 case but also specifies the name for the reciprocal 10707 transition. 10708 10709 The new transitions are outgoing from specified decision. 10710 10711 Yields the ID of each decision connected to, whether those are 10712 new or existing decisions. 10713 """ 10714 now = self.getSituation() 10715 fromID = now.graph.resolveDecision(where) 10716 result = [] 10717 for entry in transitions: 10718 if isinstance(entry, base.Transition): 10719 result.append(self.observe(fromID, entry)) 10720 else: 10721 result.append(self.observe(fromID, *entry)) 10722 return result 10723 10724 def observe( 10725 self, 10726 where: base.AnyDecisionSpecifier, 10727 transition: base.Transition, 10728 destination: Optional[base.AnyDecisionSpecifier] = None, 10729 reciprocal: Optional[base.Transition] = None 10730 ) -> base.DecisionID: 10731 """ 10732 Observes a single new outgoing transition from the specified 10733 decision. If specified the transition connects to a specific 10734 destination and/or has a specific reciprocal. The specified 10735 destination will be created if it doesn't exist, or where no 10736 destination is specified, a new unexplored decision will be 10737 added. The ID of the decision connected to is returned. 10738 10739 Sets the exploration status of the observed destination to 10740 "noticed" if a destination is specified and needs to be created 10741 (but not when no destination is specified). 10742 10743 For example: 10744 10745 >>> e = DiscreteExploration() 10746 >>> e.start('start') 10747 0 10748 >>> e.observe('start', 'up') 10749 1 10750 >>> g = e.getSituation().graph 10751 >>> g.destinationsFrom('start') 10752 {'up': 1} 10753 >>> e.getExplorationStatus(1) # not given a name: assumed unknown 10754 'unknown' 10755 >>> e.observe('start', 'left', 'A') 10756 2 10757 >>> g.destinationsFrom('start') 10758 {'up': 1, 'left': 2} 10759 >>> g.nameFor(2) 10760 'A' 10761 >>> e.getExplorationStatus(2) # given a name: noticed 10762 'noticed' 10763 >>> e.observe('start', 'up2', 1) 10764 1 10765 >>> g.destinationsFrom('start') 10766 {'up': 1, 'left': 2, 'up2': 1} 10767 >>> e.getExplorationStatus(1) # existing decision: status unchanged 10768 'unknown' 10769 >>> e.observe('start', 'right', 'B', 'left') 10770 3 10771 >>> g.destinationsFrom('start') 10772 {'up': 1, 'left': 2, 'up2': 1, 'right': 3} 10773 >>> g.nameFor(3) 10774 'B' 10775 >>> e.getExplorationStatus(3) # new + name -> noticed 10776 'noticed' 10777 >>> e.observe('start', 'right') # repeat transition name 10778 Traceback (most recent call last): 10779 ... 10780 exploration.core.TransitionCollisionError... 10781 >>> e.observe('start', 'right2', 'B', 'left') # repeat reciprocal 10782 Traceback (most recent call last): 10783 ... 10784 exploration.core.TransitionCollisionError... 10785 >>> g = e.getSituation().graph 10786 >>> g.createZone('Z', 0) 10787 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 10788 annotations=[]) 10789 >>> g.addDecisionToZone('start', 'Z') 10790 >>> e.observe('start', 'down', 'C', 'up') 10791 4 10792 >>> g.destinationsFrom('start') 10793 {'up': 1, 'left': 2, 'up2': 1, 'right': 3, 'down': 4} 10794 >>> g.identityOf('C') 10795 '4 (C)' 10796 >>> g.zoneParents(4) # not in any zones, 'cause still unexplored 10797 set() 10798 >>> e.observe( 10799 ... 'C', 10800 ... 'right', 10801 ... base.DecisionSpecifier('main', 'Z2', 'D'), 10802 ... ) # creates zone 10803 5 10804 >>> g.destinationsFrom('C') 10805 {'up': 0, 'right': 5} 10806 >>> g.destinationsFrom('D') # default reciprocal name 10807 {'return': 4} 10808 >>> g.identityOf('D') 10809 '5 (Z2::D)' 10810 >>> g.zoneParents(5) 10811 {'Z2'} 10812 """ 10813 now = self.getSituation() 10814 fromID = now.graph.resolveDecision(where) 10815 10816 kwargs: Dict[ 10817 str, 10818 Union[base.Transition, base.DecisionName, None] 10819 ] = {} 10820 if reciprocal is not None: 10821 kwargs['reciprocal'] = reciprocal 10822 10823 if destination is not None: 10824 try: 10825 destID = now.graph.resolveDecision(destination) 10826 now.graph.addTransition( 10827 fromID, 10828 transition, 10829 destID, 10830 reciprocal 10831 ) 10832 return destID 10833 except MissingDecisionError: 10834 if isinstance(destination, base.DecisionSpecifier): 10835 kwargs['toDomain'] = destination.domain 10836 kwargs['placeInZone'] = destination.zone 10837 kwargs['destinationName'] = destination.name 10838 elif isinstance(destination, base.DecisionName): 10839 kwargs['destinationName'] = destination 10840 else: 10841 assert isinstance(destination, base.DecisionID) 10842 # We got to except by failing to resolve, so it's an 10843 # invalid ID 10844 raise 10845 10846 result = now.graph.addUnexploredEdge( 10847 fromID, 10848 transition, 10849 **kwargs # type: ignore [arg-type] 10850 ) 10851 if 'destinationName' in kwargs: 10852 self.setExplorationStatus(result, 'noticed', upgradeOnly=True) 10853 return result 10854 10855 def observeMechanisms( 10856 self, 10857 where: Optional[base.AnyDecisionSpecifier], 10858 *mechanisms: Union[ 10859 base.MechanismName, 10860 Tuple[base.MechanismName, base.MechanismState] 10861 ] 10862 ) -> List[base.MechanismID]: 10863 """ 10864 Adds one or more mechanisms to the exploration's current graph, 10865 located at the specified decision. Global mechanisms can be 10866 added by using `None` for the location. Mechanisms are named, or 10867 a (name, state) tuple can be used to set them into a specific 10868 state. Mechanisms not set to a state will be in the 10869 `base.DEFAULT_MECHANISM_STATE`. 10870 """ 10871 now = self.getSituation() 10872 result = [] 10873 for mSpec in mechanisms: 10874 setState = None 10875 if isinstance(mSpec, base.MechanismName): 10876 result.append(now.graph.addMechanism(mSpec, where)) 10877 elif ( 10878 isinstance(mSpec, tuple) 10879 and len(mSpec) == 2 10880 and isinstance(mSpec[0], base.MechanismName) 10881 and isinstance(mSpec[1], base.MechanismState) 10882 ): 10883 result.append(now.graph.addMechanism(mSpec[0], where)) 10884 setState = mSpec[1] 10885 else: 10886 raise TypeError( 10887 f"Invalid mechanism: {repr(mSpec)} (must be a" 10888 f" mechanism name or a (name, state) tuple." 10889 ) 10890 10891 if setState: 10892 self.setMechanismStateNow(result[-1], setState) 10893 10894 return result 10895 10896 def reZone( 10897 self, 10898 zone: base.Zone, 10899 where: base.AnyDecisionSpecifier, 10900 replace: Union[base.Zone, int] = 0 10901 ) -> None: 10902 """ 10903 Alters the current graph without adding a new exploration step. 10904 10905 Calls `DecisionGraph.replaceZonesInHierarchy` targeting the 10906 specified decision. Note that per the logic of that method, ALL 10907 zones at the specified hierarchy level are replaced, even if a 10908 specific zone to replace is specified here. 10909 10910 TODO: not that? 10911 10912 The level value is either specified via `replace` (default 0) or 10913 deduced from the zone provided as the `replace` value using 10914 `DecisionGraph.zoneHierarchyLevel`. 10915 """ 10916 now = self.getSituation() 10917 10918 if isinstance(replace, int): 10919 level = replace 10920 else: 10921 level = now.graph.zoneHierarchyLevel(replace) 10922 10923 now.graph.replaceZonesInHierarchy(where, zone, level) 10924 10925 def runCommand( 10926 self, 10927 command: commands.Command, 10928 scope: Optional[commands.Scope] = None, 10929 line: int = -1 10930 ) -> commands.CommandResult: 10931 """ 10932 Runs a single `Command` applying effects to the exploration, its 10933 current graph, and the provided execution context, and returning 10934 a command result, which contains the modified scope plus 10935 optional skip and label values (see `CommandResult`). This 10936 function also directly modifies the scope you give it. Variable 10937 references in the command are resolved via entries in the 10938 provided scope. If no scope is given, an empty one is created. 10939 10940 A line number may be supplied for use in error messages; if left 10941 out line -1 will be used. 10942 10943 Raises an error if the command is invalid. 10944 10945 For commands that establish a value as the 'current value', that 10946 value will be stored in the '_' variable. When this happens, the 10947 old contents of '_' are stored in '__' first, and the old 10948 contents of '__' are discarded. Note that non-automatic 10949 assignment to '_' does not move the old value to '__'. 10950 """ 10951 try: 10952 if scope is None: 10953 scope = {} 10954 10955 skip: Union[int, str, None] = None 10956 label: Optional[str] = None 10957 10958 if command.command == 'val': 10959 command = cast(commands.LiteralValue, command) 10960 result = commands.resolveValue(command.value, scope) 10961 commands.pushCurrentValue(scope, result) 10962 10963 elif command.command == 'empty': 10964 command = cast(commands.EstablishCollection, command) 10965 collection = commands.resolveVarName(command.collection, scope) 10966 commands.pushCurrentValue( 10967 scope, 10968 { 10969 'list': [], 10970 'tuple': (), 10971 'set': set(), 10972 'dict': {}, 10973 }[collection] 10974 ) 10975 10976 elif command.command == 'append': 10977 command = cast(commands.AppendValue, command) 10978 target = scope['_'] 10979 addIt = commands.resolveValue(command.value, scope) 10980 if isinstance(target, list): 10981 target.append(addIt) 10982 elif isinstance(target, tuple): 10983 scope['_'] = target + (addIt,) 10984 elif isinstance(target, set): 10985 target.add(addIt) 10986 elif isinstance(target, dict): 10987 raise TypeError( 10988 "'append' command cannot be used with a" 10989 " dictionary. Use 'set' instead." 10990 ) 10991 else: 10992 raise TypeError( 10993 f"Invalid current value for 'append' command." 10994 f" The current value must be a list, tuple, or" 10995 f" set, but it was a '{type(target).__name__}'." 10996 ) 10997 10998 elif command.command == 'set': 10999 command = cast(commands.SetValue, command) 11000 target = scope['_'] 11001 where = commands.resolveValue(command.location, scope) 11002 what = commands.resolveValue(command.value, scope) 11003 if isinstance(target, list): 11004 if not isinstance(where, int): 11005 raise TypeError( 11006 f"Cannot set item in list: index {where!r}" 11007 f" is not an integer." 11008 ) 11009 target[where] = what 11010 elif isinstance(target, tuple): 11011 if not isinstance(where, int): 11012 raise TypeError( 11013 f"Cannot set item in tuple: index {where!r}" 11014 f" is not an integer." 11015 ) 11016 if not ( 11017 0 <= where < len(target) 11018 or -1 >= where >= -len(target) 11019 ): 11020 raise IndexError( 11021 f"Cannot set item in tuple at index" 11022 f" {where}: Tuple has length {len(target)}." 11023 ) 11024 scope['_'] = target[:where] + (what,) + target[where + 1:] 11025 elif isinstance(target, set): 11026 if what: 11027 target.add(where) 11028 else: 11029 try: 11030 target.remove(where) 11031 except KeyError: 11032 pass 11033 elif isinstance(target, dict): 11034 target[where] = what 11035 11036 elif command.command == 'pop': 11037 command = cast(commands.PopValue, command) 11038 target = scope['_'] 11039 if isinstance(target, list): 11040 result = target.pop() 11041 commands.pushCurrentValue(scope, result) 11042 elif isinstance(target, tuple): 11043 result = target[-1] 11044 updated = target[:-1] 11045 scope['__'] = updated 11046 scope['_'] = result 11047 else: 11048 raise TypeError( 11049 f"Cannot 'pop' from a {type(target).__name__}" 11050 f" (current value must be a list or tuple)." 11051 ) 11052 11053 elif command.command == 'get': 11054 command = cast(commands.GetValue, command) 11055 target = scope['_'] 11056 where = commands.resolveValue(command.location, scope) 11057 if isinstance(target, list): 11058 if not isinstance(where, int): 11059 raise TypeError( 11060 f"Cannot get item from list: index" 11061 f" {where!r} is not an integer." 11062 ) 11063 elif isinstance(target, tuple): 11064 if not isinstance(where, int): 11065 raise TypeError( 11066 f"Cannot get item from tuple: index" 11067 f" {where!r} is not an integer." 11068 ) 11069 elif isinstance(target, set): 11070 result = where in target 11071 commands.pushCurrentValue(scope, result) 11072 elif isinstance(target, dict): 11073 result = target[where] 11074 commands.pushCurrentValue(scope, result) 11075 else: 11076 result = getattr(target, where) 11077 commands.pushCurrentValue(scope, result) 11078 11079 elif command.command == 'remove': 11080 command = cast(commands.RemoveValue, command) 11081 target = scope['_'] 11082 where = commands.resolveValue(command.location, scope) 11083 if isinstance(target, (list, tuple)): 11084 # this cast is not correct but suppresses warnings 11085 # given insufficient narrowing by MyPy 11086 target = cast(Tuple[Any, ...], target) 11087 if not isinstance(where, int): 11088 raise TypeError( 11089 f"Cannot remove item from list or tuple:" 11090 f" index {where!r} is not an integer." 11091 ) 11092 scope['_'] = target[:where] + target[where + 1:] 11093 elif isinstance(target, set): 11094 target.remove(where) 11095 elif isinstance(target, dict): 11096 del target[where] 11097 else: 11098 raise TypeError( 11099 f"Cannot use 'remove' on a/an" 11100 f" {type(target).__name__}." 11101 ) 11102 11103 elif command.command == 'op': 11104 command = cast(commands.ApplyOperator, command) 11105 left = commands.resolveValue(command.left, scope) 11106 right = commands.resolveValue(command.right, scope) 11107 op = command.op 11108 if op == '+': 11109 result = left + right 11110 elif op == '-': 11111 result = left - right 11112 elif op == '*': 11113 result = left * right 11114 elif op == '/': 11115 result = left / right 11116 elif op == '//': 11117 result = left // right 11118 elif op == '**': 11119 result = left ** right 11120 elif op == '%': 11121 result = left % right 11122 elif op == '^': 11123 result = left ^ right 11124 elif op == '|': 11125 result = left | right 11126 elif op == '&': 11127 result = left & right 11128 elif op == 'and': 11129 result = left and right 11130 elif op == 'or': 11131 result = left or right 11132 elif op == '<': 11133 result = left < right 11134 elif op == '>': 11135 result = left > right 11136 elif op == '<=': 11137 result = left <= right 11138 elif op == '>=': 11139 result = left >= right 11140 elif op == '==': 11141 result = left == right 11142 elif op == 'is': 11143 result = left is right 11144 else: 11145 raise RuntimeError("Invalid operator '{op}'.") 11146 11147 commands.pushCurrentValue(scope, result) 11148 11149 elif command.command == 'unary': 11150 command = cast(commands.ApplyUnary, command) 11151 value = commands.resolveValue(command.value, scope) 11152 op = command.op 11153 if op == '-': 11154 result = -value 11155 elif op == '~': 11156 result = ~value 11157 elif op == 'not': 11158 result = not value 11159 11160 commands.pushCurrentValue(scope, result) 11161 11162 elif command.command == 'assign': 11163 command = cast(commands.VariableAssignment, command) 11164 varname = commands.resolveVarName(command.varname, scope) 11165 value = commands.resolveValue(command.value, scope) 11166 scope[varname] = value 11167 11168 elif command.command == 'delete': 11169 command = cast(commands.VariableDeletion, command) 11170 varname = commands.resolveVarName(command.varname, scope) 11171 del scope[varname] 11172 11173 elif command.command == 'load': 11174 command = cast(commands.LoadVariable, command) 11175 varname = commands.resolveVarName(command.varname, scope) 11176 commands.pushCurrentValue(scope, scope[varname]) 11177 11178 elif command.command == 'call': 11179 command = cast(commands.FunctionCall, command) 11180 function = command.function 11181 if function.startswith('$'): 11182 function = commands.resolveValue(function, scope) 11183 11184 toCall: Callable 11185 args: Tuple[str, ...] 11186 kwargs: Dict[str, Any] 11187 11188 if command.target == 'builtin': 11189 toCall = commands.COMMAND_BUILTINS[function] 11190 args = (scope['_'],) 11191 kwargs = {} 11192 if toCall == round: 11193 if 'ndigits' in scope: 11194 kwargs['ndigits'] = scope['ndigits'] 11195 elif toCall == range and args[0] is None: 11196 start = scope.get('start', 0) 11197 stop = scope['stop'] 11198 step = scope.get('step', 1) 11199 args = (start, stop, step) 11200 11201 else: 11202 if command.target == 'stored': 11203 toCall = function 11204 elif command.target == 'graph': 11205 toCall = getattr(self.getSituation().graph, function) 11206 elif command.target == 'exploration': 11207 toCall = getattr(self, function) 11208 else: 11209 raise TypeError( 11210 f"Invalid call target '{command.target}'" 11211 f" (must be one of 'builtin', 'stored'," 11212 f" 'graph', or 'exploration'." 11213 ) 11214 11215 # Fill in arguments via kwargs defined in scope 11216 args = () 11217 kwargs = {} 11218 signature = inspect.signature(toCall) 11219 # TODO: Maybe try some type-checking here? 11220 for argName, param in signature.parameters.items(): 11221 if param.kind == inspect.Parameter.VAR_POSITIONAL: 11222 if argName in scope: 11223 args = args + tuple(scope[argName]) 11224 # Else leave args as-is 11225 elif param.kind == inspect.Parameter.KEYWORD_ONLY: 11226 # These must have a default 11227 if argName in scope: 11228 kwargs[argName] = scope[argName] 11229 elif param.kind == inspect.Parameter.VAR_KEYWORD: 11230 # treat as a dictionary 11231 if argName in scope: 11232 argsToUse = scope[argName] 11233 if not isinstance(argsToUse, dict): 11234 raise TypeError( 11235 f"Variable '{argName}' must" 11236 f" hold a dictionary when" 11237 f" calling function" 11238 f" '{toCall.__name__} which" 11239 f" uses that argument as a" 11240 f" keyword catchall." 11241 ) 11242 kwargs.update(scope[argName]) 11243 else: # a normal parameter 11244 if argName in scope: 11245 args = args + (scope[argName],) 11246 elif param.default == inspect.Parameter.empty: 11247 raise TypeError( 11248 f"No variable named '{argName}' has" 11249 f" been defined to supply the" 11250 f" required parameter with that" 11251 f" name for function" 11252 f" '{toCall.__name__}'." 11253 ) 11254 11255 result = toCall(*args, **kwargs) 11256 commands.pushCurrentValue(scope, result) 11257 11258 elif command.command == 'skip': 11259 command = cast(commands.SkipCommands, command) 11260 doIt = commands.resolveValue(command.condition, scope) 11261 if doIt: 11262 skip = commands.resolveValue(command.amount, scope) 11263 if not isinstance(skip, (int, str)): 11264 raise TypeError( 11265 f"Skip amount must be an integer or a label" 11266 f" name (got {skip!r})." 11267 ) 11268 11269 elif command.command == 'label': 11270 command = cast(commands.Label, command) 11271 label = commands.resolveValue(command.name, scope) 11272 if not isinstance(label, str): 11273 raise TypeError( 11274 f"Label name must be a string (got {label!r})." 11275 ) 11276 11277 else: 11278 raise ValueError( 11279 f"Invalid command type: {command.command!r}" 11280 ) 11281 except ValueError as e: 11282 raise commands.CommandValueError(command, line, e) 11283 except TypeError as e: 11284 raise commands.CommandTypeError(command, line, e) 11285 except IndexError as e: 11286 raise commands.CommandIndexError(command, line, e) 11287 except KeyError as e: 11288 raise commands.CommandKeyError(command, line, e) 11289 except Exception as e: 11290 raise commands.CommandOtherError(command, line, e) 11291 11292 return (scope, skip, label) 11293 11294 def runCommandBlock( 11295 self, 11296 block: List[commands.Command], 11297 scope: Optional[commands.Scope] = None 11298 ) -> commands.Scope: 11299 """ 11300 Runs a list of commands, using the given scope (or creating a new 11301 empty scope if none was provided). Returns the scope after 11302 running all of the commands, which may also edit the exploration 11303 and/or the current graph of course. 11304 11305 Note that if a skip command would skip past the end of the 11306 block, execution will end. If a skip command would skip before 11307 the beginning of the block, execution will start from the first 11308 command. 11309 11310 Example: 11311 11312 >>> e = DiscreteExploration() 11313 >>> scope = e.runCommandBlock([ 11314 ... commands.command('assign', 'decision', "'START'"), 11315 ... commands.command('call', 'exploration', 'start'), 11316 ... commands.command('assign', 'where', '$decision'), 11317 ... commands.command('assign', 'transition', "'left'"), 11318 ... commands.command('call', 'exploration', 'observe'), 11319 ... commands.command('assign', 'transition', "'right'"), 11320 ... commands.command('call', 'exploration', 'observe'), 11321 ... commands.command('call', 'graph', 'destinationsFrom'), 11322 ... commands.command('call', 'builtin', 'print'), 11323 ... commands.command('assign', 'transition', "'right'"), 11324 ... commands.command('assign', 'destination', "'EastRoom'"), 11325 ... commands.command('call', 'exploration', 'explore'), 11326 ... ]) 11327 {'left': 1, 'right': 2} 11328 >>> scope['decision'] 11329 'START' 11330 >>> scope['where'] 11331 'START' 11332 >>> scope['_'] # result of 'explore' call is dest ID 11333 2 11334 >>> scope['transition'] 11335 'right' 11336 >>> scope['destination'] 11337 'EastRoom' 11338 >>> g = e.getSituation().graph 11339 >>> len(e) 11340 3 11341 >>> len(g) 11342 3 11343 >>> g.namesListing(g) 11344 ' 0 (START)\\n 1 (_u.0)\\n 2 (EastRoom)\\n' 11345 """ 11346 if scope is None: 11347 scope = {} 11348 11349 labelPositions: Dict[str, List[int]] = {} 11350 11351 # Keep going until we've exhausted the commands list 11352 index = 0 11353 while index < len(block): 11354 11355 # Execute the next command 11356 scope, skip, label = self.runCommand( 11357 block[index], 11358 scope, 11359 index + 1 11360 ) 11361 11362 # Increment our index, or apply a skip 11363 if skip is None: 11364 index = index + 1 11365 11366 elif isinstance(skip, int): # Integer skip value 11367 if skip < 0: 11368 index += skip 11369 if index < 0: # can't skip before the start 11370 index = 0 11371 else: 11372 index += skip + 1 # may end loop if we skip too far 11373 11374 else: # must be a label name 11375 if skip in labelPositions: # an established label 11376 # We jump to the last previous index, or if there 11377 # are none, to the first future index. 11378 prevIndices = [ 11379 x 11380 for x in labelPositions[skip] 11381 if x < index 11382 ] 11383 futureIndices = [ 11384 x 11385 for x in labelPositions[skip] 11386 if x >= index 11387 ] 11388 if len(prevIndices) > 0: 11389 index = max(prevIndices) 11390 else: 11391 index = min(futureIndices) 11392 else: # must be a forward-reference 11393 for future in range(index + 1, len(block)): 11394 inspect = block[future] 11395 if inspect.command == 'label': 11396 inspect = cast(commands.Label, inspect) 11397 if inspect.name == skip: 11398 index = future 11399 break 11400 else: 11401 raise KeyError( 11402 f"Skip command indicated a jump to label" 11403 f" {skip!r} but that label had not already" 11404 f" been defined and there is no future" 11405 f" label with that name either (future" 11406 f" labels based on variables cannot be" 11407 f" skipped to from above as their names" 11408 f" are not known yet)." 11409 ) 11410 11411 # If there's a label, record it 11412 if label is not None: 11413 labelPositions.setdefault(label, []).append(index) 11414 11415 # And now the while loop continues, or ends if we're at the 11416 # end of the commands list. 11417 11418 # Return the scope object. 11419 return scope 11420 11421 @staticmethod 11422 def example() -> 'DiscreteExploration': 11423 """ 11424 Returns a little example exploration. Has a few decisions 11425 including one that's unexplored, and uses a few steps to explore 11426 them. 11427 11428 >>> e = DiscreteExploration.example() 11429 >>> len(e) 11430 7 11431 >>> def pg(n): 11432 ... print(e[n].graph.namesListing(e[n].graph)) 11433 >>> pg(0) 11434 0 (House) 11435 <BLANKLINE> 11436 >>> pg(1) 11437 0 (House) 11438 1 (_u.0) 11439 2 (_u.1) 11440 3 (_u.2) 11441 <BLANKLINE> 11442 >>> pg(2) 11443 0 (House) 11444 1 (_u.0) 11445 2 (_u.1) 11446 3 (Yard) 11447 4 (_u.3) 11448 5 (_u.4) 11449 <BLANKLINE> 11450 >>> pg(3) 11451 0 (House) 11452 1 (_u.0) 11453 2 (_u.1) 11454 3 (Yard) 11455 4 (_u.3) 11456 5 (_u.4) 11457 <BLANKLINE> 11458 >>> pg(4) 11459 0 (House) 11460 1 (_u.0) 11461 2 (Cellar) 11462 3 (Yard) 11463 5 (_u.4) 11464 <BLANKLINE> 11465 >>> pg(5) 11466 0 (House) 11467 1 (_u.0) 11468 2 (Cellar) 11469 3 (Yard) 11470 5 (_u.4) 11471 <BLANKLINE> 11472 >>> pg(6) 11473 0 (House) 11474 1 (_u.0) 11475 2 (Cellar) 11476 3 (Yard) 11477 5 (Lane) 11478 <BLANKLINE> 11479 """ 11480 result = DiscreteExploration() 11481 result.start("House") 11482 result.observeAll("House", "ladder", "stairsDown", "frontDoor") 11483 result.explore("frontDoor", "Yard", "frontDoor") 11484 result.observe("Yard", "cellarDoors") 11485 result.observe("Yard", "frontGate") 11486 result.retrace("frontDoor") 11487 result.explore("stairsDown", "Cellar", "stairsUp") 11488 result.observe("Cellar", "stairsOut") 11489 result.returnTo("stairsOut", "Yard", "cellarDoors") 11490 result.explore("frontGate", "Lane", "redGate") 11491 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 beReqNothing
if nothing special is required.'consequence'
: TheConsequence
of 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
ReqNothing
instance as its requirement, we use the other requirement. If both have complex requirements, we create a newReqAll
which 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 MissingMechanismError(KeyError): 231 """ 232 An error raised when attempting to use a mechanism that does not 233 exist. 234 """ 235 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
238class MissingZoneError(KeyError): 239 """ 240 An error raised when attempting to use a zone that does not exist. 241 """ 242 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
245class InvalidLevelError(ValueError): 246 """ 247 An error raised when an operation fails because of an invalid zone 248 level. 249 """ 250 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
253class InvalidDestinationError(ValueError): 254 """ 255 An error raised when attempting to perform an operation with a 256 transition but that transition does not lead to a destination that's 257 compatible with the operation. 258 """ 259 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
262class ExplorationStatusError(ValueError): 263 """ 264 An error raised when attempting to perform an operation that 265 requires a previously-visited destination with a decision that 266 represents a not-yet-visited decision, or vice versa. For 267 `Situation`s, Exploration states 'unknown', 'hypothesized', and 268 'noticed' count as "not-yet-visited" while 'exploring' and 'explored' 269 count as "visited" (see `base.hasBeenVisited`) Meanwhile, in a 270 `DecisionGraph` where exploration statuses are not present, the 271 presence or absence of the 'unconfirmed' tag is used to determine 272 whether something has been confirmed or not. 273 """ 274 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
Situation
s, 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.
283class DecisionCollisionWarning(Warning): 284 """ 285 A warning raised when attempting to create a new decision using the 286 name of a decision that already exists. 287 """ 288 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
291class TransitionCollisionError(ValueError): 292 """ 293 An error raised when attempting to re-use a transition name for a 294 new transition, or otherwise when a transition name conflicts with 295 an already-established transition. 296 """ 297 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
300class MechanismCollisionError(ValueError): 301 """ 302 An error raised when attempting to re-use a mechanism name at the 303 same decision where a mechanism with that name already exists. 304 """ 305 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
308class DomainCollisionError(KeyError): 309 """ 310 An error raised when attempting to create a domain with the same 311 name as an existing domain. 312 """ 313 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
316class MissingFocalContextError(KeyError): 317 """ 318 An error raised when attempting to pick out a focal context with a 319 name that doesn't exist. 320 """ 321 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
324class FocalContextCollisionError(KeyError): 325 """ 326 An error raised when attempting to create a focal context with the 327 same name as an existing focal context. 328 """ 329 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
332class InvalidActionError(TypeError): 333 """ 334 An error raised when attempting to take an exploration action which 335 is not correctly formed. 336 """ 337 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
340class ImpossibleActionError(ValueError): 341 """ 342 An error raised when attempting to take an exploration action which 343 is correctly formed but which specifies an action that doesn't match 344 up with the graph state. 345 """ 346 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
349class DoubleActionError(ValueError): 350 """ 351 An error raised when attempting to set up an `ExplorationAction` 352 when the current situation already has an action specified. 353 """ 354 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
357class InactiveDomainWarning(Warning): 358 """ 359 A warning used when an inactive domain is referenced but the 360 operation in progress can still succeed (for example when 361 deactivating an already-inactive domain). 362 """
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
365class ZoneCollisionError(ValueError): 366 """ 367 An error raised when attempting to re-use a zone name for a new zone, 368 or otherwise when a zone name conflicts with an already-established 369 zone. 370 """ 371 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
378class DecisionGraph( 379 graphs.UniqueExitsGraph[base.DecisionID, base.Transition] 380): 381 """ 382 Represents a view of the world as a topological graph at a moment in 383 time. It derives from `networkx.MultiDiGraph`. 384 385 Each node (a `Decision`) represents a place in the world where there 386 are multiple opportunities for travel/action, or a dead end where 387 you must turn around and go back; typically this is a single room in 388 a game, but sometimes one room has multiple decision points. Edges 389 (`Transition`s) represent choices that can be made to travel to 390 other decision points (e.g., taking the left door), or when they are 391 self-edges, they represent actions that can be taken within a 392 location that affect the world or the game state. 393 394 Each `Transition` includes a `Effects` dictionary 395 indicating the effects that it has. Other effects of the transition 396 that are not simple enough to be included in this format may be 397 represented in an `DiscreteExploration` by changing the graph in the 398 next step to reflect further effects of a transition. 399 400 In addition to normal transitions between decisions, a 401 `DecisionGraph` can represent potential transitions which lead to 402 unknown destinations. These are represented by adding decisions with 403 the `'unconfirmed'` tag (whose names where not specified begin with 404 `'_u.'`) with a separate unconfirmed decision for each transition 405 (although where it's known that two transitions lead to the same 406 unconfirmed decision, this can be represented as well). 407 408 Both nodes and edges can have `Annotation`s associated with them that 409 include extra details about the explorer's perception of the 410 situation. They can also have `Tag`s, which represent specific 411 categories a transition or decision falls into. 412 413 Nodes can also be part of one or more `Zones`, and zones can also be 414 part of other zones, allowing for a hierarchical description of the 415 underlying space. 416 417 Equivalences can be specified to mark that some combination of 418 capabilities can stand in for another capability. 419 """ 420 def __init__(self) -> None: 421 super().__init__() 422 423 self.zones: Dict[base.Zone, base.ZoneInfo] = {} 424 """ 425 Mapping from zone names to zone info 426 """ 427 428 self.unknownCount: int = 0 429 """ 430 Number of unknown decisions that have been created (not number 431 of current unknown decisions, which is likely lower) 432 """ 433 434 self.equivalences: base.Equivalences = {} 435 """ 436 See `base.Equivalences`. Determines what capabilities and/or 437 mechanism states can count as active based on alternate 438 requirements. 439 """ 440 441 self.reversionTypes: Dict[str, Set[str]] = {} 442 """ 443 This tracks shorthand reversion types. See `base.revertedState` 444 for how these are applied. Keys are custom names and values are 445 reversion type strings that `base.revertedState` could access. 446 """ 447 448 self.nextID: base.DecisionID = 0 449 """ 450 The ID to use for the next new decision we create. 451 """ 452 453 self.nextMechanismID: base.MechanismID = 0 454 """ 455 ID for the next mechanism. 456 """ 457 458 self.mechanisms: Dict[ 459 base.MechanismID, 460 Tuple[Optional[base.DecisionID], base.MechanismName] 461 ] = {} 462 """ 463 Mapping from `MechanismID`s to (`DecisionID`, `MechanismName`) 464 pairs. For global mechanisms, the `DecisionID` is None. 465 """ 466 467 self.globalMechanisms: Dict[ 468 base.MechanismName, 469 base.MechanismID 470 ] = {} 471 """ 472 Global mechanisms 473 """ 474 475 self.nameLookup: Dict[base.DecisionName, List[base.DecisionID]] = {} 476 """ 477 A cache for name -> ID lookups 478 """ 479 480 # Note: not hashable 481 482 def __eq__(self, other): 483 """ 484 Equality checker. `DecisionGraph`s can only be equal to other 485 `DecisionGraph`s, not to other kinds of things. 486 """ 487 if not isinstance(other, DecisionGraph): 488 return False 489 else: 490 # Checks nodes, edges, and all attached data 491 if not super().__eq__(other): 492 return False 493 494 # Check unknown count 495 if self.unknownCount != other.unknownCount: 496 return False 497 498 # Check zones 499 if self.zones != other.zones: 500 return False 501 502 # Check equivalences 503 if self.equivalences != other.equivalences: 504 return False 505 506 # Check reversion types 507 if self.reversionTypes != other.reversionTypes: 508 return False 509 510 # Check mechanisms 511 if self.nextMechanismID != other.nextMechanismID: 512 return False 513 514 if self.mechanisms != other.mechanisms: 515 return False 516 517 if self.globalMechanisms != other.globalMechanisms: 518 return False 519 520 # Check names: 521 if self.nameLookup != other.nameLookup: 522 return False 523 524 return True 525 526 def listDifferences( 527 self, 528 other: 'DecisionGraph' 529 ) -> Generator[str, None, None]: 530 """ 531 Generates strings describing differences between this graph and 532 another graph. This does NOT perform graph matching, so it will 533 consider graphs different even if they have identical structures 534 but use different IDs for the nodes in those structures. 535 """ 536 if not isinstance(other, DecisionGraph): 537 yield "other is not a graph" 538 else: 539 suppress = False 540 myNodes = set(self.nodes) 541 theirNodes = set(other.nodes) 542 for n in myNodes: 543 if n not in theirNodes: 544 suppress = True 545 yield ( 546 f"other graph missing node {n}" 547 ) 548 else: 549 if self.nodes[n] != other.nodes[n]: 550 suppress = True 551 yield ( 552 f"other graph has differences at node {n}:" 553 f"\n Ours: {self.nodes[n]}" 554 f"\nTheirs: {other.nodes[n]}" 555 ) 556 myDests = self.destinationsFrom(n) 557 theirDests = other.destinationsFrom(n) 558 for tr in myDests: 559 myTo = myDests[tr] 560 if tr not in theirDests: 561 suppress = True 562 yield ( 563 f"at {self.identityOf(n)}: other graph" 564 f" missing transition {tr!r}" 565 ) 566 else: 567 theirTo = theirDests[tr] 568 if myTo != theirTo: 569 suppress = True 570 yield ( 571 f"at {self.identityOf(n)}: other" 572 f" graph transition {tr!r} leads to" 573 f" {theirTo} instead of {myTo}" 574 ) 575 else: 576 myProps = self.edges[n, myTo, tr] # type:ignore [index] # noqa 577 theirProps = other.edges[n, myTo, tr] # type:ignore [index] # noqa 578 if myProps != theirProps: 579 suppress = True 580 yield ( 581 f"at {self.identityOf(n)}: other" 582 f" graph transition {tr!r} has" 583 f" different properties:" 584 f"\n Ours: {myProps}" 585 f"\nTheirs: {theirProps}" 586 ) 587 for extra in theirNodes - myNodes: 588 suppress = True 589 yield ( 590 f"other graph has extra node {extra}" 591 ) 592 593 # TODO: Fix networkx stubs! 594 if self.graph != other.graph: # type:ignore [attr-defined] 595 suppress = True 596 yield ( 597 " different graph attributes:" # type:ignore [attr-defined] # noqa 598 f"\n Ours: {self.graph}" 599 f"\nTheirs: {other.graph}" 600 ) 601 602 # Checks any other graph data we might have missed 603 if not super().__eq__(other) and not suppress: 604 for attr in dir(self): 605 if attr.startswith('__') and attr.endswith('__'): 606 continue 607 if not hasattr(other, attr): 608 yield f"other graph missing attribute: {attr!r}" 609 else: 610 myVal = getattr(self, attr) 611 theirVal = getattr(other, attr) 612 if ( 613 myVal != theirVal 614 and not ((callable(myVal) and callable(theirVal))) 615 ): 616 yield ( 617 f"other has different val for {attr!r}:" 618 f"\n Ours: {myVal}" 619 f"\nTheirs: {theirVal}" 620 ) 621 for attr in sorted(set(dir(other)) - set(dir(self))): 622 yield f"other has extra attribute: {attr!r}" 623 yield "graph data is different" 624 # TODO: More detail here! 625 626 # Check unknown count 627 if self.unknownCount != other.unknownCount: 628 yield "unknown count is different" 629 630 # Check zones 631 if self.zones != other.zones: 632 yield "zones are different" 633 634 # Check equivalences 635 if self.equivalences != other.equivalences: 636 yield "equivalences are different" 637 638 # Check reversion types 639 if self.reversionTypes != other.reversionTypes: 640 yield "reversionTypes are different" 641 642 # Check mechanisms 643 if self.nextMechanismID != other.nextMechanismID: 644 yield "nextMechanismID is different" 645 646 if self.mechanisms != other.mechanisms: 647 yield "mechanisms are different" 648 649 if self.globalMechanisms != other.globalMechanisms: 650 yield "global mechanisms are different" 651 652 # Check names: 653 if self.nameLookup != other.nameLookup: 654 for name in self.nameLookup: 655 if name not in other.nameLookup: 656 yield ( 657 f"other graph is missing name lookup entry" 658 f" for {name!r}" 659 ) 660 else: 661 mine = self.nameLookup[name] 662 theirs = other.nameLookup[name] 663 if theirs != mine: 664 yield ( 665 f"name lookup for {name!r} is {theirs}" 666 f" instead of {mine}" 667 ) 668 extras = set(other.nameLookup) - set(self.nameLookup) 669 if extras: 670 yield ( 671 f"other graph has extra name lookup entries:" 672 f" {extras}" 673 ) 674 675 def _assignID(self) -> base.DecisionID: 676 """ 677 Returns the next `base.DecisionID` to use and increments the 678 next ID counter. 679 """ 680 result = self.nextID 681 self.nextID += 1 682 return result 683 684 def _assignMechanismID(self) -> base.MechanismID: 685 """ 686 Returns the next `base.MechanismID` to use and increments the 687 next ID counter. 688 """ 689 result = self.nextMechanismID 690 self.nextMechanismID += 1 691 return result 692 693 def decisionInfo(self, dID: base.DecisionID) -> DecisionInfo: 694 """ 695 Retrieves the decision info for the specified decision, as a 696 live editable dictionary. 697 698 For example: 699 700 >>> g = DecisionGraph() 701 >>> g.addDecision('A') 702 0 703 >>> g.annotateDecision('A', 'note') 704 >>> g.decisionInfo(0) 705 {'name': 'A', 'domain': 'main', 'tags': {}, 'annotations': ['note']} 706 """ 707 return cast(DecisionInfo, self.nodes[dID]) 708 709 def resolveDecision( 710 self, 711 spec: base.AnyDecisionSpecifier, 712 zoneHint: Optional[base.Zone] = None, 713 domainHint: Optional[base.Domain] = None 714 ) -> base.DecisionID: 715 """ 716 Given a decision specifier returns the ID associated with that 717 decision, or raises an `AmbiguousDecisionSpecifierError` or a 718 `MissingDecisionError` if the specified decision is either 719 missing or ambiguous. Cannot handle strings that contain domain 720 and/or zone parts; use 721 `parsing.ParseFormat.parseDecisionSpecifier` to turn such 722 strings into `DecisionSpecifier`s if you need to first. 723 724 Examples: 725 726 >>> g = DecisionGraph() 727 >>> g.addDecision('A') 728 0 729 >>> g.addDecision('B') 730 1 731 >>> g.addDecision('C') 732 2 733 >>> g.addDecision('A') 734 3 735 >>> g.addDecision('B', 'menu') 736 4 737 >>> g.createZone('Z', 0) 738 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 739 annotations=[]) 740 >>> g.createZone('Z2', 0) 741 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 742 annotations=[]) 743 >>> g.createZone('Zup', 1) 744 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 745 annotations=[]) 746 >>> g.addDecisionToZone(0, 'Z') 747 >>> g.addDecisionToZone(1, 'Z') 748 >>> g.addDecisionToZone(2, 'Z') 749 >>> g.addDecisionToZone(3, 'Z2') 750 >>> g.addZoneToZone('Z', 'Zup') 751 >>> g.addZoneToZone('Z2', 'Zup') 752 >>> g.resolveDecision(1) 753 1 754 >>> g.resolveDecision('A') 755 Traceback (most recent call last): 756 ... 757 exploration.core.AmbiguousDecisionSpecifierError... 758 >>> g.resolveDecision('B') 759 Traceback (most recent call last): 760 ... 761 exploration.core.AmbiguousDecisionSpecifierError... 762 >>> g.resolveDecision('C') 763 2 764 >>> g.resolveDecision('A', 'Z') 765 0 766 >>> g.resolveDecision('A', zoneHint='Z2') 767 3 768 >>> g.resolveDecision('B', domainHint='main') 769 1 770 >>> g.resolveDecision('B', None, 'menu') 771 4 772 >>> g.resolveDecision('B', zoneHint='Z2') 773 Traceback (most recent call last): 774 ... 775 exploration.core.MissingDecisionError... 776 >>> g.resolveDecision('A', domainHint='menu') 777 Traceback (most recent call last): 778 ... 779 exploration.core.MissingDecisionError... 780 >>> g.resolveDecision('A', domainHint='madeup') 781 Traceback (most recent call last): 782 ... 783 exploration.core.MissingDecisionError... 784 >>> g.resolveDecision('A', zoneHint='madeup') 785 Traceback (most recent call last): 786 ... 787 exploration.core.MissingDecisionError... 788 """ 789 # Parse it to either an ID or specifier if it's a string: 790 if isinstance(spec, str): 791 try: 792 spec = int(spec) 793 except ValueError: 794 pass 795 796 # If it's an ID, check for existence: 797 if isinstance(spec, base.DecisionID): 798 if spec in self: 799 return spec 800 else: 801 raise MissingDecisionError( 802 f"There is no decision with ID {spec!r}." 803 ) 804 else: 805 if isinstance(spec, base.DecisionName): 806 spec = base.DecisionSpecifier( 807 domain=None, 808 zone=None, 809 name=spec 810 ) 811 elif not isinstance(spec, base.DecisionSpecifier): 812 raise TypeError( 813 f"Specification is not provided as a" 814 f" DecisionSpecifier or other valid type. (got type" 815 f" {type(spec)})." 816 ) 817 818 # Merge domain hints from spec/args 819 if ( 820 spec.domain is not None 821 and domainHint is not None 822 and spec.domain != domainHint 823 ): 824 raise ValueError( 825 f"Specifier {repr(spec)} includes domain hint" 826 f" {repr(spec.domain)} which is incompatible with" 827 f" explicit domain hint {repr(domainHint)}." 828 ) 829 else: 830 domainHint = spec.domain or domainHint 831 832 # Merge zone hints from spec/args 833 if ( 834 spec.zone is not None 835 and zoneHint is not None 836 and spec.zone != zoneHint 837 ): 838 raise ValueError( 839 f"Specifier {repr(spec)} includes zone hint" 840 f" {repr(spec.zone)} which is incompatible with" 841 f" explicit zone hint {repr(zoneHint)}." 842 ) 843 else: 844 zoneHint = spec.zone or zoneHint 845 846 if spec.name not in self.nameLookup: 847 raise MissingDecisionError( 848 f"No decision named {repr(spec.name)}." 849 ) 850 else: 851 options = self.nameLookup[spec.name] 852 if len(options) == 0: 853 raise MissingDecisionError( 854 f"No decision named {repr(spec.name)}." 855 ) 856 filtered = [ 857 opt 858 for opt in options 859 if ( 860 domainHint is None 861 or self.domainFor(opt) == domainHint 862 ) and ( 863 zoneHint is None 864 or zoneHint in self.zoneAncestors(opt) 865 ) 866 ] 867 if len(filtered) == 1: 868 return filtered[0] 869 else: 870 filterDesc = "" 871 if domainHint is not None: 872 filterDesc += f" in domain {repr(domainHint)}" 873 if zoneHint is not None: 874 filterDesc += f" in zone {repr(zoneHint)}" 875 if len(filtered) == 0: 876 raise MissingDecisionError( 877 f"No decisions named" 878 f" {repr(spec.name)}{filterDesc}." 879 ) 880 else: 881 raise AmbiguousDecisionSpecifierError( 882 f"There are {len(filtered)} decisions" 883 f" named {repr(spec.name)}{filterDesc}." 884 ) 885 886 def getDecision( 887 self, 888 decision: base.AnyDecisionSpecifier, 889 zoneHint: Optional[base.Zone] = None, 890 domainHint: Optional[base.Domain] = None 891 ) -> Optional[base.DecisionID]: 892 """ 893 Works like `resolveDecision` but returns None instead of raising 894 a `MissingDecisionError` if the specified decision isn't listed. 895 May still raise an `AmbiguousDecisionSpecifierError`. 896 """ 897 try: 898 return self.resolveDecision( 899 decision, 900 zoneHint, 901 domainHint 902 ) 903 except MissingDecisionError: 904 return None 905 906 def nameFor( 907 self, 908 decision: base.AnyDecisionSpecifier 909 ) -> base.DecisionName: 910 """ 911 Returns the name of the specified decision. Note that names are 912 not necessarily unique. 913 914 Example: 915 916 >>> d = DecisionGraph() 917 >>> d.addDecision('A') 918 0 919 >>> d.addDecision('B') 920 1 921 >>> d.addDecision('B') 922 2 923 >>> d.nameFor(0) 924 'A' 925 >>> d.nameFor(1) 926 'B' 927 >>> d.nameFor(2) 928 'B' 929 >>> d.nameFor(3) 930 Traceback (most recent call last): 931 ... 932 exploration.core.MissingDecisionError... 933 """ 934 dID = self.resolveDecision(decision) 935 return self.nodes[dID]['name'] 936 937 def shortIdentity( 938 self, 939 decision: Optional[base.AnyDecisionSpecifier], 940 includeZones: bool = True, 941 alwaysDomain: Optional[bool] = None 942 ): 943 """ 944 Returns a string containing the name for the given decision, 945 prefixed by its level-0 zone(s) and domain. If the value provided 946 is `None`, it returns the string "(nowhere)". 947 948 If `includeZones` is true (the default) then zone information 949 is included before the decision name. 950 951 If `alwaysDomain` is true or false, then the domain information 952 will always (or never) be included. If it's `None` (the default) 953 then domain info will only be included for decisions which are 954 not in the default domain. 955 """ 956 if decision is None: 957 return "(nowhere)" 958 else: 959 dID = self.resolveDecision(decision) 960 thisDomain = self.domainFor(dID) 961 dSpec = '' 962 zSpec = '' 963 if ( 964 alwaysDomain is True 965 or ( 966 alwaysDomain is None 967 and thisDomain != base.DEFAULT_DOMAIN 968 ) 969 ): 970 dSpec = thisDomain + '//' # TODO: Don't hardcode this? 971 if includeZones: 972 zones = [ 973 z 974 for z in self.zoneParents(dID) 975 if self.zones[z].level == 0 976 ] 977 if len(zones) == 1: 978 zSpec = zones[0] + '::' # TODO: Don't hardcode this? 979 elif len(zones) > 1: 980 zSpec = '[' + ', '.join(sorted(zones)) + ']::' 981 # else leave zSpec empty 982 983 return f"{dSpec}{zSpec}{self.nameFor(dID)}" 984 985 def identityOf( 986 self, 987 decision: Optional[base.AnyDecisionSpecifier], 988 includeZones: bool = True, 989 alwaysDomain: Optional[bool] = None 990 ) -> str: 991 """ 992 Returns the given node's ID, plus its `shortIdentity` in 993 parentheses. Arguments are passed through to `shortIdentity`. 994 """ 995 if decision is None: 996 return "(nowhere)" 997 else: 998 dID = self.resolveDecision(decision) 999 short = self.shortIdentity(decision, includeZones, alwaysDomain) 1000 return f"{dID} ({short})" 1001 1002 def namesListing( 1003 self, 1004 decisions: Collection[base.DecisionID], 1005 includeZones: bool = True, 1006 indent: int = 2 1007 ) -> str: 1008 """ 1009 Returns a multi-line string containing an indented listing of 1010 the provided decision IDs with their names in parentheses after 1011 each. Useful for debugging & error messages. 1012 1013 Includes level-0 zones where applicable, with a zone separator 1014 before the decision, unless `includeZones` is set to False. Where 1015 there are multiple level-0 zones, they're listed together in 1016 brackets. 1017 1018 Uses the string '(none)' when there are no decisions are in the 1019 list. 1020 1021 Set `indent` to something other than 2 to control how much 1022 indentation is added. 1023 1024 For example: 1025 1026 >>> g = DecisionGraph() 1027 >>> g.addDecision('A') 1028 0 1029 >>> g.addDecision('B') 1030 1 1031 >>> g.addDecision('C') 1032 2 1033 >>> g.namesListing(['A', 'C', 'B']) 1034 ' 0 (A)\\n 2 (C)\\n 1 (B)\\n' 1035 >>> g.namesListing([]) 1036 ' (none)\\n' 1037 >>> g.createZone('zone', 0) 1038 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1039 annotations=[]) 1040 >>> g.createZone('zone2', 0) 1041 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1042 annotations=[]) 1043 >>> g.createZone('zoneUp', 1) 1044 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 1045 annotations=[]) 1046 >>> g.addDecisionToZone(0, 'zone') 1047 >>> g.addDecisionToZone(1, 'zone') 1048 >>> g.addDecisionToZone(1, 'zone2') 1049 >>> g.addDecisionToZone(2, 'zoneUp') # won't be listed: it's level-1 1050 >>> g.namesListing(['A', 'C', 'B']) 1051 ' 0 (zone::A)\\n 2 (C)\\n 1 ([zone, zone2]::B)\\n' 1052 """ 1053 ind = ' ' * indent 1054 if len(decisions) == 0: 1055 return ind + '(none)\n' 1056 else: 1057 result = '' 1058 for dID in decisions: 1059 result += ind + self.identityOf(dID, includeZones) + '\n' 1060 return result 1061 1062 def destinationsListing( 1063 self, 1064 destinations: Dict[base.Transition, base.DecisionID], 1065 includeZones: bool = True, 1066 indent: int = 2 1067 ) -> str: 1068 """ 1069 Returns a multi-line string containing an indented listing of 1070 the provided transitions along with their destinations and the 1071 names of those destinations in parentheses. Useful for debugging 1072 & error messages. (Use e.g., `destinationsFrom` to get a 1073 transitions -> destinations dictionary in the required format.) 1074 1075 Uses the string '(no transitions)' when there are no transitions 1076 in the dictionary. 1077 1078 Set `indent` to something other than 2 to control how much 1079 indentation is added. 1080 1081 For example: 1082 1083 >>> g = DecisionGraph() 1084 >>> g.addDecision('A') 1085 0 1086 >>> g.addDecision('B') 1087 1 1088 >>> g.addDecision('C') 1089 2 1090 >>> g.addTransition('A', 'north', 'B', 'south') 1091 >>> g.addTransition('B', 'east', 'C', 'west') 1092 >>> g.addTransition('C', 'southwest', 'A', 'northeast') 1093 >>> g.destinationsListing(g.destinationsFrom('A')) 1094 ' north to 1 (B)\\n northeast to 2 (C)\\n' 1095 >>> g.destinationsListing(g.destinationsFrom('B')) 1096 ' south to 0 (A)\\n east to 2 (C)\\n' 1097 >>> g.destinationsListing({}) 1098 ' (none)\\n' 1099 >>> g.createZone('zone', 0) 1100 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1101 annotations=[]) 1102 >>> g.addDecisionToZone(0, 'zone') 1103 >>> g.destinationsListing(g.destinationsFrom('B')) 1104 ' south to 0 (zone::A)\\n east to 2 (C)\\n' 1105 """ 1106 ind = ' ' * indent 1107 if len(destinations) == 0: 1108 return ind + '(none)\n' 1109 else: 1110 result = '' 1111 for transition, dID in destinations.items(): 1112 line = f"{transition} to {self.identityOf(dID, includeZones)}" 1113 result += ind + line + '\n' 1114 return result 1115 1116 def domainFor(self, decision: base.AnyDecisionSpecifier) -> base.Domain: 1117 """ 1118 Returns the domain that a decision belongs to. 1119 """ 1120 dID = self.resolveDecision(decision) 1121 return self.nodes[dID]['domain'] 1122 1123 def allDecisionsInDomain( 1124 self, 1125 domain: base.Domain 1126 ) -> Set[base.DecisionID]: 1127 """ 1128 Returns the set of all `DecisionID`s for decisions in the 1129 specified domain. 1130 """ 1131 return set(dID for dID in self if self.nodes[dID]['domain'] == domain) 1132 1133 def destination( 1134 self, 1135 decision: base.AnyDecisionSpecifier, 1136 transition: base.Transition 1137 ) -> base.DecisionID: 1138 """ 1139 Overrides base `UniqueExitsGraph.destination` to raise 1140 `MissingDecisionError` or `MissingTransitionError` as 1141 appropriate, and to work with an `AnyDecisionSpecifier`. 1142 """ 1143 dID = self.resolveDecision(decision) 1144 try: 1145 return super().destination(dID, transition) 1146 except KeyError: 1147 raise MissingTransitionError( 1148 f"Transition {transition!r} does not exist at decision" 1149 f" {self.identityOf(dID)}." 1150 ) 1151 1152 def getDestination( 1153 self, 1154 decision: base.AnyDecisionSpecifier, 1155 transition: base.Transition, 1156 default: Any = None 1157 ) -> Optional[base.DecisionID]: 1158 """ 1159 Overrides base `UniqueExitsGraph.getDestination` with different 1160 argument names, since those matter for the edit DSL. 1161 """ 1162 dID = self.resolveDecision(decision) 1163 return super().getDestination(dID, transition) 1164 1165 def destinationsFrom( 1166 self, 1167 decision: base.AnyDecisionSpecifier 1168 ) -> Dict[base.Transition, base.DecisionID]: 1169 """ 1170 Override that just changes the type of the exception from a 1171 `KeyError` to a `MissingDecisionError` when the source does not 1172 exist. 1173 """ 1174 dID = self.resolveDecision(decision) 1175 return super().destinationsFrom(dID) 1176 1177 def bothEnds( 1178 self, 1179 decision: base.AnyDecisionSpecifier, 1180 transition: base.Transition 1181 ) -> Set[base.DecisionID]: 1182 """ 1183 Returns a set containing the `DecisionID`(s) for both the start 1184 and end of the specified transition. Raises a 1185 `MissingDecisionError` or `MissingTransitionError`if the 1186 specified decision and/or transition do not exist. 1187 1188 Note that for actions since the source and destination are the 1189 same, the set will have only one element. 1190 """ 1191 dID = self.resolveDecision(decision) 1192 result = {dID} 1193 dest = self.destination(dID, transition) 1194 if dest is not None: 1195 result.add(dest) 1196 return result 1197 1198 def decisionActions( 1199 self, 1200 decision: base.AnyDecisionSpecifier 1201 ) -> Set[base.Transition]: 1202 """ 1203 Retrieves the set of self-edges at a decision. Editing the set 1204 will not affect the graph. 1205 1206 Example: 1207 1208 >>> g = DecisionGraph() 1209 >>> g.addDecision('A') 1210 0 1211 >>> g.addDecision('B') 1212 1 1213 >>> g.addDecision('C') 1214 2 1215 >>> g.addAction('A', 'action1') 1216 >>> g.addAction('A', 'action2') 1217 >>> g.addAction('B', 'action3') 1218 >>> sorted(g.decisionActions('A')) 1219 ['action1', 'action2'] 1220 >>> g.decisionActions('B') 1221 {'action3'} 1222 >>> g.decisionActions('C') 1223 set() 1224 """ 1225 result = set() 1226 dID = self.resolveDecision(decision) 1227 for transition, dest in self.destinationsFrom(dID).items(): 1228 if dest == dID: 1229 result.add(transition) 1230 return result 1231 1232 def getTransitionProperties( 1233 self, 1234 decision: base.AnyDecisionSpecifier, 1235 transition: base.Transition 1236 ) -> TransitionProperties: 1237 """ 1238 Returns a dictionary containing transition properties for the 1239 specified transition from the specified decision. The properties 1240 included are: 1241 1242 - 'requirement': The requirement for the transition. 1243 - 'consequence': Any consequence of the transition. 1244 - 'tags': Any tags applied to the transition. 1245 - 'annotations': Any annotations on the transition. 1246 1247 The reciprocal of the transition is not included. 1248 1249 The result is a clone of the stored properties; edits to the 1250 dictionary will NOT modify the graph. 1251 """ 1252 dID = self.resolveDecision(decision) 1253 dest = self.destination(dID, transition) 1254 1255 info: TransitionProperties = copy.deepcopy( 1256 self.edges[dID, dest, transition] # type:ignore 1257 ) 1258 return { 1259 'requirement': info.get('requirement', base.ReqNothing()), 1260 'consequence': info.get('consequence', []), 1261 'tags': info.get('tags', {}), 1262 'annotations': info.get('annotations', []) 1263 } 1264 1265 def setTransitionProperties( 1266 self, 1267 decision: base.AnyDecisionSpecifier, 1268 transition: base.Transition, 1269 requirement: Optional[base.Requirement] = None, 1270 consequence: Optional[base.Consequence] = None, 1271 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 1272 annotations: Optional[List[base.Annotation]] = None 1273 ) -> None: 1274 """ 1275 Sets one or more transition properties all at once. Can be used 1276 to set the requirement, consequence, tags, and/or annotations. 1277 Old values are overwritten, although if `None`s are provided (or 1278 arguments are omitted), corresponding properties are not 1279 updated. 1280 1281 To add tags or annotations to existing tags/annotations instead 1282 of replacing them, use `tagTransition` or `annotateTransition` 1283 instead. 1284 """ 1285 dID = self.resolveDecision(decision) 1286 if requirement is not None: 1287 self.setTransitionRequirement(dID, transition, requirement) 1288 if consequence is not None: 1289 self.setConsequence(dID, transition, consequence) 1290 if tags is not None: 1291 dest = self.destination(dID, transition) 1292 # TODO: Submit pull request to update MultiDiGraph stubs in 1293 # types-networkx to include OutMultiEdgeView that accepts 1294 # from/to/key tuples as indices. 1295 info = cast( 1296 TransitionProperties, 1297 self.edges[dID, dest, transition] # type:ignore 1298 ) 1299 info['tags'] = tags 1300 if annotations is not None: 1301 dest = self.destination(dID, transition) 1302 info = cast( 1303 TransitionProperties, 1304 self.edges[dID, dest, transition] # type:ignore 1305 ) 1306 info['annotations'] = annotations 1307 1308 def getTransitionRequirement( 1309 self, 1310 decision: base.AnyDecisionSpecifier, 1311 transition: base.Transition 1312 ) -> base.Requirement: 1313 """ 1314 Returns the `Requirement` for accessing a specific transition at 1315 a specific decision. For transitions which don't have 1316 requirements, returns a `ReqNothing` instance. 1317 """ 1318 dID = self.resolveDecision(decision) 1319 dest = self.destination(dID, transition) 1320 1321 info = cast( 1322 TransitionProperties, 1323 self.edges[dID, dest, transition] # type:ignore 1324 ) 1325 1326 return info.get('requirement', base.ReqNothing()) 1327 1328 def setTransitionRequirement( 1329 self, 1330 decision: base.AnyDecisionSpecifier, 1331 transition: base.Transition, 1332 requirement: Optional[base.Requirement] 1333 ) -> None: 1334 """ 1335 Sets the `Requirement` for accessing a specific transition at 1336 a specific decision. Raises a `KeyError` if the decision or 1337 transition does not exist. 1338 1339 Deletes the requirement if `None` is given as the requirement. 1340 1341 Use `parsing.ParseFormat.parseRequirement` first if you have a 1342 requirement in string format. 1343 1344 Does not raise an error if deletion is requested for a 1345 non-existent requirement, and silently overwrites any previous 1346 requirement. 1347 """ 1348 dID = self.resolveDecision(decision) 1349 1350 dest = self.destination(dID, transition) 1351 1352 info = cast( 1353 TransitionProperties, 1354 self.edges[dID, dest, transition] # type:ignore 1355 ) 1356 1357 if requirement is None: 1358 try: 1359 del info['requirement'] 1360 except KeyError: 1361 pass 1362 else: 1363 if not isinstance(requirement, base.Requirement): 1364 raise TypeError( 1365 f"Invalid requirement type: {type(requirement)}" 1366 ) 1367 1368 info['requirement'] = requirement 1369 1370 def getConsequence( 1371 self, 1372 decision: base.AnyDecisionSpecifier, 1373 transition: base.Transition 1374 ) -> base.Consequence: 1375 """ 1376 Retrieves the consequence of a transition. 1377 1378 A `KeyError` is raised if the specified decision/transition 1379 combination doesn't exist. 1380 """ 1381 dID = self.resolveDecision(decision) 1382 1383 dest = self.destination(dID, transition) 1384 1385 info = cast( 1386 TransitionProperties, 1387 self.edges[dID, dest, transition] # type:ignore 1388 ) 1389 1390 return info.get('consequence', []) 1391 1392 def addConsequence( 1393 self, 1394 decision: base.AnyDecisionSpecifier, 1395 transition: base.Transition, 1396 consequence: base.Consequence 1397 ) -> Tuple[int, int]: 1398 """ 1399 Adds the given `Consequence` to the consequence list for the 1400 specified transition, extending that list at the end. Note that 1401 this does NOT make a copy of the consequence, so it should not 1402 be used to copy consequences from one transition to another 1403 without making a deep copy first. 1404 1405 A `MissingDecisionError` or a `MissingTransitionError` is raised 1406 if the specified decision/transition combination doesn't exist. 1407 1408 Returns a pair of integers indicating the minimum and maximum 1409 depth-first-traversal-indices of the added consequence part(s). 1410 The outer consequence list itself (index 0) is not counted. 1411 1412 >>> d = DecisionGraph() 1413 >>> d.addDecision('A') 1414 0 1415 >>> d.addDecision('B') 1416 1 1417 >>> d.addTransition('A', 'fwd', 'B', 'rev') 1418 >>> d.addConsequence('A', 'fwd', [base.effect(gain='sword')]) 1419 (1, 1) 1420 >>> d.addConsequence('B', 'rev', [base.effect(lose='sword')]) 1421 (1, 1) 1422 >>> ef = d.getConsequence('A', 'fwd') 1423 >>> er = d.getConsequence('B', 'rev') 1424 >>> ef == [base.effect(gain='sword')] 1425 True 1426 >>> er == [base.effect(lose='sword')] 1427 True 1428 >>> d.addConsequence('A', 'fwd', [base.effect(deactivate=True)]) 1429 (2, 2) 1430 >>> ef = d.getConsequence('A', 'fwd') 1431 >>> ef == [base.effect(gain='sword'), base.effect(deactivate=True)] 1432 True 1433 >>> d.addConsequence( 1434 ... 'A', 1435 ... 'fwd', # adding to consequence with 3 parts already 1436 ... [ # outer list not counted because it merges 1437 ... base.challenge( # 1 part 1438 ... None, 1439 ... 0, 1440 ... [base.effect(gain=('flowers', 3))], # 2 parts 1441 ... [base.effect(gain=('flowers', 1))] # 2 parts 1442 ... ) 1443 ... ] 1444 ... ) # note indices below are inclusive; indices are 3, 4, 5, 6, 7 1445 (3, 7) 1446 """ 1447 dID = self.resolveDecision(decision) 1448 1449 dest = self.destination(dID, transition) 1450 1451 info = cast( 1452 TransitionProperties, 1453 self.edges[dID, dest, transition] # type:ignore 1454 ) 1455 1456 existing = info.setdefault('consequence', []) 1457 startIndex = base.countParts(existing) 1458 existing.extend(consequence) 1459 endIndex = base.countParts(existing) - 1 1460 return (startIndex, endIndex) 1461 1462 def setConsequence( 1463 self, 1464 decision: base.AnyDecisionSpecifier, 1465 transition: base.Transition, 1466 consequence: base.Consequence 1467 ) -> None: 1468 """ 1469 Replaces the transition consequence for the given transition at 1470 the given decision. Any previous consequence is discarded. See 1471 `Consequence` for the structure of these. Note that this does 1472 NOT make a copy of the consequence, do that first to avoid 1473 effect-entanglement if you're copying a consequence. 1474 1475 A `MissingDecisionError` or a `MissingTransitionError` is raised 1476 if the specified decision/transition combination doesn't exist. 1477 """ 1478 dID = self.resolveDecision(decision) 1479 1480 dest = self.destination(dID, transition) 1481 1482 info = cast( 1483 TransitionProperties, 1484 self.edges[dID, dest, transition] # type:ignore 1485 ) 1486 1487 info['consequence'] = consequence 1488 1489 def addEquivalence( 1490 self, 1491 requirement: base.Requirement, 1492 capabilityOrMechanismState: Union[ 1493 base.Capability, 1494 Tuple[base.MechanismID, base.MechanismState] 1495 ] 1496 ) -> None: 1497 """ 1498 Adds the given requirement as an equivalence for the given 1499 capability or the given mechanism state. Note that having a 1500 capability via an equivalence does not count as actually having 1501 that capability; it only counts for the purpose of satisfying 1502 `Requirement`s. 1503 1504 Note also that because a mechanism-based requirement looks up 1505 the specific mechanism locally based on a name, an equivalence 1506 defined in one location may affect mechanism requirements in 1507 other locations unless the mechanism name in the requirement is 1508 zone-qualified to be specific. But in such situations the base 1509 mechanism would have caused issues in any case. 1510 """ 1511 self.equivalences.setdefault( 1512 capabilityOrMechanismState, 1513 set() 1514 ).add(requirement) 1515 1516 def removeEquivalence( 1517 self, 1518 requirement: base.Requirement, 1519 capabilityOrMechanismState: Union[ 1520 base.Capability, 1521 Tuple[base.MechanismID, base.MechanismState] 1522 ] 1523 ) -> None: 1524 """ 1525 Removes an equivalence. Raises a `KeyError` if no such 1526 equivalence existed. 1527 """ 1528 self.equivalences[capabilityOrMechanismState].remove(requirement) 1529 1530 def hasAnyEquivalents( 1531 self, 1532 capabilityOrMechanismState: Union[ 1533 base.Capability, 1534 Tuple[base.MechanismID, base.MechanismState] 1535 ] 1536 ) -> bool: 1537 """ 1538 Returns `True` if the given capability or mechanism state has at 1539 least one equivalence. 1540 """ 1541 return capabilityOrMechanismState in self.equivalences 1542 1543 def allEquivalents( 1544 self, 1545 capabilityOrMechanismState: Union[ 1546 base.Capability, 1547 Tuple[base.MechanismID, base.MechanismState] 1548 ] 1549 ) -> Set[base.Requirement]: 1550 """ 1551 Returns the set of equivalences for the given capability. This is 1552 a live set which may be modified (it's probably better to use 1553 `addEquivalence` and `removeEquivalence` instead...). 1554 """ 1555 return self.equivalences.setdefault( 1556 capabilityOrMechanismState, 1557 set() 1558 ) 1559 1560 def reversionType(self, name: str, equivalentTo: Set[str]) -> None: 1561 """ 1562 Specifies a new reversion type, so that when used in a reversion 1563 aspects set with a colon before the name, all items in the 1564 `equivalentTo` value will be added to that set. These may 1565 include other custom reversion type names (with the colon) but 1566 take care not to create an equivalence loop which would result 1567 in a crash. 1568 1569 If you re-use the same name, it will override the old equivalence 1570 for that name. 1571 """ 1572 self.reversionTypes[name] = equivalentTo 1573 1574 def addAction( 1575 self, 1576 decision: base.AnyDecisionSpecifier, 1577 action: base.Transition, 1578 requires: Optional[base.Requirement] = None, 1579 consequence: Optional[base.Consequence] = None, 1580 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 1581 annotations: Optional[List[base.Annotation]] = None, 1582 ) -> None: 1583 """ 1584 Adds the given action as a possibility at the given decision. An 1585 action is just a self-edge, which can have requirements like any 1586 edge, and which can have consequences like any edge. 1587 The optional arguments are given to `setTransitionRequirement` 1588 and `setConsequence`; see those functions for descriptions 1589 of what they mean. 1590 1591 Raises a `KeyError` if a transition with the given name already 1592 exists at the given decision. 1593 """ 1594 if tags is None: 1595 tags = {} 1596 if annotations is None: 1597 annotations = [] 1598 1599 dID = self.resolveDecision(decision) 1600 1601 self.add_edge( 1602 dID, 1603 dID, 1604 key=action, 1605 tags=tags, 1606 annotations=annotations 1607 ) 1608 self.setTransitionRequirement(dID, action, requires) 1609 if consequence is not None: 1610 self.setConsequence(dID, action, consequence) 1611 1612 def tagDecision( 1613 self, 1614 decision: base.AnyDecisionSpecifier, 1615 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 1616 tagValue: Union[ 1617 base.TagValue, 1618 type[base.NoTagValue] 1619 ] = base.NoTagValue 1620 ) -> None: 1621 """ 1622 Adds a tag (or many tags from a dictionary of tags) to a 1623 decision, using `1` as the value if no value is provided. It's 1624 a `ValueError` to provide a value when a dictionary of tags is 1625 provided to set multiple tags at once. 1626 1627 Note that certain tags have special meanings: 1628 1629 - 'unconfirmed' is used for decisions that represent unconfirmed 1630 parts of the graph (this is separate from the 'unknown' 1631 and/or 'hypothesized' exploration statuses, which are only 1632 tracked in a `DiscreteExploration`, not in a `DecisionGraph`). 1633 Various methods require this tag and many also add or remove 1634 it. 1635 """ 1636 if isinstance(tagOrTags, base.Tag): 1637 if tagValue is base.NoTagValue: 1638 tagValue = 1 1639 1640 # Not sure why this cast is necessary given the `if` above... 1641 tagValue = cast(base.TagValue, tagValue) 1642 1643 tagOrTags = {tagOrTags: tagValue} 1644 1645 elif tagValue is not base.NoTagValue: 1646 raise ValueError( 1647 "Provided a dictionary to update multiple tags, but" 1648 " also a tag value." 1649 ) 1650 1651 dID = self.resolveDecision(decision) 1652 1653 tagsAlready = self.nodes[dID].setdefault('tags', {}) 1654 tagsAlready.update(tagOrTags) 1655 1656 def untagDecision( 1657 self, 1658 decision: base.AnyDecisionSpecifier, 1659 tag: base.Tag 1660 ) -> Union[base.TagValue, type[base.NoTagValue]]: 1661 """ 1662 Removes a tag from a decision. Returns the tag's old value if 1663 the tag was present and got removed, or `NoTagValue` if the tag 1664 wasn't present. 1665 """ 1666 dID = self.resolveDecision(decision) 1667 1668 target = self.nodes[dID]['tags'] 1669 try: 1670 return target.pop(tag) 1671 except KeyError: 1672 return base.NoTagValue 1673 1674 def decisionTags( 1675 self, 1676 decision: base.AnyDecisionSpecifier 1677 ) -> Dict[base.Tag, base.TagValue]: 1678 """ 1679 Returns the dictionary of tags for a decision. Edits to the 1680 returned value will be applied to the graph. 1681 """ 1682 dID = self.resolveDecision(decision) 1683 1684 return self.nodes[dID]['tags'] 1685 1686 def annotateDecision( 1687 self, 1688 decision: base.AnyDecisionSpecifier, 1689 annotationOrAnnotations: Union[ 1690 base.Annotation, 1691 Sequence[base.Annotation] 1692 ] 1693 ) -> None: 1694 """ 1695 Adds an annotation to a decision's annotations list. 1696 """ 1697 dID = self.resolveDecision(decision) 1698 1699 if isinstance(annotationOrAnnotations, base.Annotation): 1700 annotationOrAnnotations = [annotationOrAnnotations] 1701 self.nodes[dID]['annotations'].extend(annotationOrAnnotations) 1702 1703 def decisionAnnotations( 1704 self, 1705 decision: base.AnyDecisionSpecifier 1706 ) -> List[base.Annotation]: 1707 """ 1708 Returns the list of annotations for the specified decision. 1709 Modifying the list affects the graph. 1710 """ 1711 dID = self.resolveDecision(decision) 1712 1713 return self.nodes[dID]['annotations'] 1714 1715 def tagTransition( 1716 self, 1717 decision: base.AnyDecisionSpecifier, 1718 transition: base.Transition, 1719 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 1720 tagValue: Union[ 1721 base.TagValue, 1722 type[base.NoTagValue] 1723 ] = base.NoTagValue 1724 ) -> None: 1725 """ 1726 Adds a tag (or each tag from a dictionary) to a transition 1727 coming out of a specific decision. `1` will be used as the 1728 default value if a single tag is supplied; supplying a tag value 1729 when providing a dictionary of multiple tags to update is a 1730 `ValueError`. 1731 1732 Note that certain transition tags have special meanings: 1733 - 'trigger' causes any actions (but not normal transitions) that 1734 it applies to to be automatically triggered when 1735 `advanceSituation` is called and the decision they're 1736 attached to is active in the new situation (as long as the 1737 action's requirements are met). This happens once per 1738 situation; use 'wait' steps to re-apply triggers. 1739 """ 1740 dID = self.resolveDecision(decision) 1741 1742 dest = self.destination(dID, transition) 1743 if isinstance(tagOrTags, base.Tag): 1744 if tagValue is base.NoTagValue: 1745 tagValue = 1 1746 1747 # Not sure why this is necessary given the `if` above... 1748 tagValue = cast(base.TagValue, tagValue) 1749 1750 tagOrTags = {tagOrTags: tagValue} 1751 elif tagValue is not base.NoTagValue: 1752 raise ValueError( 1753 "Provided a dictionary to update multiple tags, but" 1754 " also a tag value." 1755 ) 1756 1757 info = cast( 1758 TransitionProperties, 1759 self.edges[dID, dest, transition] # type:ignore 1760 ) 1761 1762 info.setdefault('tags', {}).update(tagOrTags) 1763 1764 def untagTransition( 1765 self, 1766 decision: base.AnyDecisionSpecifier, 1767 transition: base.Transition, 1768 tagOrTags: Union[base.Tag, Set[base.Tag]] 1769 ) -> None: 1770 """ 1771 Removes a tag (or each tag in a set) from a transition coming out 1772 of a specific decision. Raises a `KeyError` if (one of) the 1773 specified tag(s) is not currently applied to the specified 1774 transition. 1775 """ 1776 dID = self.resolveDecision(decision) 1777 1778 dest = self.destination(dID, transition) 1779 if isinstance(tagOrTags, base.Tag): 1780 tagOrTags = {tagOrTags} 1781 1782 info = cast( 1783 TransitionProperties, 1784 self.edges[dID, dest, transition] # type:ignore 1785 ) 1786 tagsAlready = info.setdefault('tags', {}) 1787 1788 for tag in tagOrTags: 1789 tagsAlready.pop(tag) 1790 1791 def transitionTags( 1792 self, 1793 decision: base.AnyDecisionSpecifier, 1794 transition: base.Transition 1795 ) -> Dict[base.Tag, base.TagValue]: 1796 """ 1797 Returns the dictionary of tags for a transition. Edits to the 1798 returned dictionary will be applied to the graph. 1799 """ 1800 dID = self.resolveDecision(decision) 1801 1802 dest = self.destination(dID, transition) 1803 info = cast( 1804 TransitionProperties, 1805 self.edges[dID, dest, transition] # type:ignore 1806 ) 1807 return info.setdefault('tags', {}) 1808 1809 def annotateTransition( 1810 self, 1811 decision: base.AnyDecisionSpecifier, 1812 transition: base.Transition, 1813 annotations: Union[base.Annotation, Sequence[base.Annotation]] 1814 ) -> None: 1815 """ 1816 Adds an annotation (or a sequence of annotations) to a 1817 transition's annotations list. 1818 """ 1819 dID = self.resolveDecision(decision) 1820 1821 dest = self.destination(dID, transition) 1822 if isinstance(annotations, base.Annotation): 1823 annotations = [annotations] 1824 info = cast( 1825 TransitionProperties, 1826 self.edges[dID, dest, transition] # type:ignore 1827 ) 1828 info['annotations'].extend(annotations) 1829 1830 def transitionAnnotations( 1831 self, 1832 decision: base.AnyDecisionSpecifier, 1833 transition: base.Transition 1834 ) -> List[base.Annotation]: 1835 """ 1836 Returns the annotation list for a specific transition at a 1837 specific decision. Editing the list affects the graph. 1838 """ 1839 dID = self.resolveDecision(decision) 1840 1841 dest = self.destination(dID, transition) 1842 info = cast( 1843 TransitionProperties, 1844 self.edges[dID, dest, transition] # type:ignore 1845 ) 1846 return info['annotations'] 1847 1848 def annotateZone( 1849 self, 1850 zone: base.Zone, 1851 annotations: Union[base.Annotation, Sequence[base.Annotation]] 1852 ) -> None: 1853 """ 1854 Adds an annotation (or many annotations from a sequence) to a 1855 zone. 1856 1857 Raises a `MissingZoneError` if the specified zone does not exist. 1858 """ 1859 if zone not in self.zones: 1860 raise MissingZoneError( 1861 f"Can't add annotation(s) to zone {zone!r} because that" 1862 f" zone doesn't exist yet." 1863 ) 1864 1865 if isinstance(annotations, base.Annotation): 1866 annotations = [ annotations ] 1867 1868 self.zones[zone].annotations.extend(annotations) 1869 1870 def zoneAnnotations(self, zone: base.Zone) -> List[base.Annotation]: 1871 """ 1872 Returns the list of annotations for the specified zone (empty if 1873 none have been added yet). 1874 """ 1875 return self.zones[zone].annotations 1876 1877 def tagZone( 1878 self, 1879 zone: base.Zone, 1880 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 1881 tagValue: Union[ 1882 base.TagValue, 1883 type[base.NoTagValue] 1884 ] = base.NoTagValue 1885 ) -> None: 1886 """ 1887 Adds a tag (or many tags from a dictionary of tags) to a 1888 zone, using `1` as the value if no value is provided. It's 1889 a `ValueError` to provide a value when a dictionary of tags is 1890 provided to set multiple tags at once. 1891 1892 Raises a `MissingZoneError` if the specified zone does not exist. 1893 """ 1894 if zone not in self.zones: 1895 raise MissingZoneError( 1896 f"Can't add tag(s) to zone {zone!r} because that zone" 1897 f" doesn't exist yet." 1898 ) 1899 1900 if isinstance(tagOrTags, base.Tag): 1901 if tagValue is base.NoTagValue: 1902 tagValue = 1 1903 1904 # Not sure why this cast is necessary given the `if` above... 1905 tagValue = cast(base.TagValue, tagValue) 1906 1907 tagOrTags = {tagOrTags: tagValue} 1908 1909 elif tagValue is not base.NoTagValue: 1910 raise ValueError( 1911 "Provided a dictionary to update multiple tags, but" 1912 " also a tag value." 1913 ) 1914 1915 tagsAlready = self.zones[zone].tags 1916 tagsAlready.update(tagOrTags) 1917 1918 def untagZone( 1919 self, 1920 zone: base.Zone, 1921 tag: base.Tag 1922 ) -> Union[base.TagValue, type[base.NoTagValue]]: 1923 """ 1924 Removes a tag from a zone. Returns the tag's old value if the 1925 tag was present and got removed, or `NoTagValue` if the tag 1926 wasn't present. 1927 1928 Raises a `MissingZoneError` if the specified zone does not exist. 1929 """ 1930 if zone not in self.zones: 1931 raise MissingZoneError( 1932 f"Can't remove tag {tag!r} from zone {zone!r} because" 1933 f" that zone doesn't exist yet." 1934 ) 1935 target = self.zones[zone].tags 1936 try: 1937 return target.pop(tag) 1938 except KeyError: 1939 return base.NoTagValue 1940 1941 def zoneTags( 1942 self, 1943 zone: base.Zone 1944 ) -> Dict[base.Tag, base.TagValue]: 1945 """ 1946 Returns the dictionary of tags for a zone. Edits to the returned 1947 value will be applied to the graph. Returns an empty tags 1948 dictionary if called on a zone that didn't have any tags 1949 previously, but raises a `MissingZoneError` if attempting to get 1950 tags for a zone which does not exist. 1951 1952 For example: 1953 1954 >>> g = DecisionGraph() 1955 >>> g.addDecision('A') 1956 0 1957 >>> g.addDecision('B') 1958 1 1959 >>> g.createZone('Zone') 1960 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1961 annotations=[]) 1962 >>> g.tagZone('Zone', 'color', 'blue') 1963 >>> g.tagZone( 1964 ... 'Zone', 1965 ... {'shape': 'square', 'color': 'red', 'sound': 'loud'} 1966 ... ) 1967 >>> g.untagZone('Zone', 'sound') 1968 'loud' 1969 >>> g.zoneTags('Zone') 1970 {'color': 'red', 'shape': 'square'} 1971 """ 1972 if zone in self.zones: 1973 return self.zones[zone].tags 1974 else: 1975 raise MissingZoneError( 1976 f"Tags for zone {zone!r} don't exist because that" 1977 f" zone has not been created yet." 1978 ) 1979 1980 def createZone(self, zone: base.Zone, level: int = 0) -> base.ZoneInfo: 1981 """ 1982 Creates an empty zone with the given name at the given level 1983 (default 0). Raises a `ZoneCollisionError` if that zone name is 1984 already in use (at any level), including if it's in use by a 1985 decision. 1986 1987 Raises an `InvalidLevelError` if the level value is less than 0. 1988 1989 Returns the `ZoneInfo` for the new blank zone. 1990 1991 For example: 1992 1993 >>> d = DecisionGraph() 1994 >>> d.createZone('Z', 0) 1995 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1996 annotations=[]) 1997 >>> d.getZoneInfo('Z') 1998 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1999 annotations=[]) 2000 >>> d.createZone('Z2', 0) 2001 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2002 annotations=[]) 2003 >>> d.createZone('Z3', -1) # level -1 is not valid (must be >= 0) 2004 Traceback (most recent call last): 2005 ... 2006 exploration.core.InvalidLevelError... 2007 >>> d.createZone('Z2') # Name Z2 is already in use 2008 Traceback (most recent call last): 2009 ... 2010 exploration.core.ZoneCollisionError... 2011 """ 2012 if level < 0: 2013 raise InvalidLevelError( 2014 "Cannot create a zone with a negative level." 2015 ) 2016 if zone in self.zones: 2017 raise ZoneCollisionError(f"Zone {zone!r} already exists.") 2018 if zone in self: 2019 raise ZoneCollisionError( 2020 f"A decision named {zone!r} already exists, so a zone" 2021 f" with that name cannot be created." 2022 ) 2023 info: base.ZoneInfo = base.ZoneInfo( 2024 level=level, 2025 parents=set(), 2026 contents=set(), 2027 tags={}, 2028 annotations=[] 2029 ) 2030 self.zones[zone] = info 2031 return info 2032 2033 def getZoneInfo(self, zone: base.Zone) -> Optional[base.ZoneInfo]: 2034 """ 2035 Returns the `ZoneInfo` (level, parents, and contents) for the 2036 specified zone, or `None` if that zone does not exist. 2037 2038 For example: 2039 2040 >>> d = DecisionGraph() 2041 >>> d.createZone('Z', 0) 2042 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2043 annotations=[]) 2044 >>> d.getZoneInfo('Z') 2045 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2046 annotations=[]) 2047 >>> d.createZone('Z2', 0) 2048 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2049 annotations=[]) 2050 >>> d.getZoneInfo('Z2') 2051 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2052 annotations=[]) 2053 """ 2054 return self.zones.get(zone) 2055 2056 def deleteZone(self, zone: base.Zone) -> base.ZoneInfo: 2057 """ 2058 Deletes the specified zone, returning a `ZoneInfo` object with 2059 the information on the level, parents, and contents of that zone. 2060 2061 Raises a `MissingZoneError` if the zone in question does not 2062 exist. 2063 2064 For example: 2065 2066 >>> d = DecisionGraph() 2067 >>> d.createZone('Z', 0) 2068 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2069 annotations=[]) 2070 >>> d.getZoneInfo('Z') 2071 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2072 annotations=[]) 2073 >>> d.deleteZone('Z') 2074 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2075 annotations=[]) 2076 >>> d.getZoneInfo('Z') is None # no info any more 2077 True 2078 >>> d.deleteZone('Z') # can't re-delete 2079 Traceback (most recent call last): 2080 ... 2081 exploration.core.MissingZoneError... 2082 """ 2083 info = self.getZoneInfo(zone) 2084 if info is None: 2085 raise MissingZoneError( 2086 f"Cannot delete zone {zone!r}: it does not exist." 2087 ) 2088 for sub in info.contents: 2089 if 'zones' in self.nodes[sub]: 2090 try: 2091 self.nodes[sub]['zones'].remove(zone) 2092 except KeyError: 2093 pass 2094 del self.zones[zone] 2095 return info 2096 2097 def addDecisionToZone( 2098 self, 2099 decision: base.AnyDecisionSpecifier, 2100 zone: base.Zone 2101 ) -> None: 2102 """ 2103 Adds a decision directly to a zone. Should normally only be used 2104 with level-0 zones. Raises a `MissingZoneError` if the specified 2105 zone did not already exist. 2106 2107 For example: 2108 2109 >>> d = DecisionGraph() 2110 >>> d.addDecision('A') 2111 0 2112 >>> d.addDecision('B') 2113 1 2114 >>> d.addDecision('C') 2115 2 2116 >>> d.createZone('Z', 0) 2117 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2118 annotations=[]) 2119 >>> d.addDecisionToZone('A', 'Z') 2120 >>> d.getZoneInfo('Z') 2121 ZoneInfo(level=0, parents=set(), contents={0}, tags={},\ 2122 annotations=[]) 2123 >>> d.addDecisionToZone('B', 'Z') 2124 >>> d.getZoneInfo('Z') 2125 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2126 annotations=[]) 2127 """ 2128 dID = self.resolveDecision(decision) 2129 2130 if zone not in self.zones: 2131 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2132 2133 self.zones[zone].contents.add(dID) 2134 self.nodes[dID].setdefault('zones', set()).add(zone) 2135 2136 def removeDecisionFromZone( 2137 self, 2138 decision: base.AnyDecisionSpecifier, 2139 zone: base.Zone 2140 ) -> bool: 2141 """ 2142 Removes a decision from a zone if it had been in it, returning 2143 True if that decision had been in that zone, and False if it was 2144 not in that zone, including if that zone didn't exist. 2145 2146 Note that this only removes a decision from direct zone 2147 membership. If the decision is a member of one or more zones 2148 which are (directly or indirectly) sub-zones of the target zone, 2149 the decision will remain in those zones, and will still be 2150 indirectly part of the target zone afterwards. 2151 2152 Examples: 2153 2154 >>> g = DecisionGraph() 2155 >>> g.addDecision('A') 2156 0 2157 >>> g.addDecision('B') 2158 1 2159 >>> g.createZone('level0', 0) 2160 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2161 annotations=[]) 2162 >>> g.createZone('level1', 1) 2163 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2164 annotations=[]) 2165 >>> g.createZone('level2', 2) 2166 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2167 annotations=[]) 2168 >>> g.createZone('level3', 3) 2169 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2170 annotations=[]) 2171 >>> g.addDecisionToZone('A', 'level0') 2172 >>> g.addDecisionToZone('B', 'level0') 2173 >>> g.addZoneToZone('level0', 'level1') 2174 >>> g.addZoneToZone('level1', 'level2') 2175 >>> g.addZoneToZone('level2', 'level3') 2176 >>> g.addDecisionToZone('B', 'level2') # Direct w/ skips 2177 >>> g.removeDecisionFromZone('A', 'level1') 2178 False 2179 >>> g.zoneParents(0) 2180 {'level0'} 2181 >>> g.removeDecisionFromZone('A', 'level0') 2182 True 2183 >>> g.zoneParents(0) 2184 set() 2185 >>> g.removeDecisionFromZone('A', 'level0') 2186 False 2187 >>> g.removeDecisionFromZone('B', 'level0') 2188 True 2189 >>> g.zoneParents(1) 2190 {'level2'} 2191 >>> g.removeDecisionFromZone('B', 'level0') 2192 False 2193 >>> g.removeDecisionFromZone('B', 'level2') 2194 True 2195 >>> g.zoneParents(1) 2196 set() 2197 """ 2198 dID = self.resolveDecision(decision) 2199 2200 if zone not in self.zones: 2201 return False 2202 2203 info = self.zones[zone] 2204 if dID not in info.contents: 2205 return False 2206 else: 2207 info.contents.remove(dID) 2208 try: 2209 self.nodes[dID]['zones'].remove(zone) 2210 except KeyError: 2211 pass 2212 return True 2213 2214 def addZoneToZone( 2215 self, 2216 addIt: base.Zone, 2217 addTo: base.Zone 2218 ) -> None: 2219 """ 2220 Adds a zone to another zone. The `addIt` one must be at a 2221 strictly lower level than the `addTo` zone, or an 2222 `InvalidLevelError` will be raised. 2223 2224 If the zone to be added didn't already exist, it is created at 2225 one level below the target zone. Similarly, if the zone being 2226 added to didn't already exist, it is created at one level above 2227 the target zone. If neither existed, a `MissingZoneError` will 2228 be raised. 2229 2230 For example: 2231 2232 >>> d = DecisionGraph() 2233 >>> d.addDecision('A') 2234 0 2235 >>> d.addDecision('B') 2236 1 2237 >>> d.addDecision('C') 2238 2 2239 >>> d.createZone('Z', 0) 2240 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2241 annotations=[]) 2242 >>> d.addDecisionToZone('A', 'Z') 2243 >>> d.addDecisionToZone('B', 'Z') 2244 >>> d.getZoneInfo('Z') 2245 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2246 annotations=[]) 2247 >>> d.createZone('Z2', 0) 2248 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2249 annotations=[]) 2250 >>> d.addDecisionToZone('B', 'Z2') 2251 >>> d.addDecisionToZone('C', 'Z2') 2252 >>> d.getZoneInfo('Z2') 2253 ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\ 2254 annotations=[]) 2255 >>> d.createZone('l1Z', 1) 2256 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2257 annotations=[]) 2258 >>> d.createZone('l2Z', 2) 2259 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2260 annotations=[]) 2261 >>> d.addZoneToZone('Z', 'l1Z') 2262 >>> d.getZoneInfo('Z') 2263 ZoneInfo(level=0, parents={'l1Z'}, contents={0, 1}, tags={},\ 2264 annotations=[]) 2265 >>> d.getZoneInfo('l1Z') 2266 ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\ 2267 annotations=[]) 2268 >>> d.addZoneToZone('l1Z', 'l2Z') 2269 >>> d.getZoneInfo('l1Z') 2270 ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\ 2271 annotations=[]) 2272 >>> d.getZoneInfo('l2Z') 2273 ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\ 2274 annotations=[]) 2275 >>> d.addZoneToZone('Z2', 'l2Z') 2276 >>> d.getZoneInfo('Z2') 2277 ZoneInfo(level=0, parents={'l2Z'}, contents={1, 2}, tags={},\ 2278 annotations=[]) 2279 >>> l2i = d.getZoneInfo('l2Z') 2280 >>> l2i.level 2281 2 2282 >>> l2i.parents 2283 set() 2284 >>> sorted(l2i.contents) 2285 ['Z2', 'l1Z'] 2286 >>> d.addZoneToZone('NZ', 'NZ2') 2287 Traceback (most recent call last): 2288 ... 2289 exploration.core.MissingZoneError... 2290 >>> d.addZoneToZone('Z', 'l1Z2') 2291 >>> zi = d.getZoneInfo('Z') 2292 >>> zi.level 2293 0 2294 >>> sorted(zi.parents) 2295 ['l1Z', 'l1Z2'] 2296 >>> sorted(zi.contents) 2297 [0, 1] 2298 >>> d.getZoneInfo('l1Z2') 2299 ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\ 2300 annotations=[]) 2301 >>> d.addZoneToZone('NZ', 'l1Z') 2302 >>> d.getZoneInfo('NZ') 2303 ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\ 2304 annotations=[]) 2305 >>> zi = d.getZoneInfo('l1Z') 2306 >>> zi.level 2307 1 2308 >>> zi.parents 2309 {'l2Z'} 2310 >>> sorted(zi.contents) 2311 ['NZ', 'Z'] 2312 """ 2313 # Create one or the other (but not both) if they're missing 2314 addInfo = self.getZoneInfo(addIt) 2315 toInfo = self.getZoneInfo(addTo) 2316 if addInfo is None and toInfo is None: 2317 raise MissingZoneError( 2318 f"Cannot add zone {addIt!r} to zone {addTo!r}: neither" 2319 f" exists already." 2320 ) 2321 2322 # Create missing addIt 2323 elif addInfo is None: 2324 toInfo = cast(base.ZoneInfo, toInfo) 2325 newLevel = toInfo.level - 1 2326 if newLevel < 0: 2327 raise InvalidLevelError( 2328 f"Zone {addTo!r} is at level {toInfo.level} and so" 2329 f" a new zone cannot be added underneath it." 2330 ) 2331 addInfo = self.createZone(addIt, newLevel) 2332 2333 # Create missing addTo 2334 elif toInfo is None: 2335 addInfo = cast(base.ZoneInfo, addInfo) 2336 newLevel = addInfo.level + 1 2337 if newLevel < 0: 2338 raise InvalidLevelError( 2339 f"Zone {addIt!r} is at level {addInfo.level} (!!!)" 2340 f" and so a new zone cannot be added above it." 2341 ) 2342 toInfo = self.createZone(addTo, newLevel) 2343 2344 # Now both addInfo and toInfo are defined 2345 if addInfo.level >= toInfo.level: 2346 raise InvalidLevelError( 2347 f"Cannot add zone {addIt!r} at level {addInfo.level}" 2348 f" to zone {addTo!r} at level {toInfo.level}: zones can" 2349 f" only contain zones of lower levels." 2350 ) 2351 2352 # Now both addInfo and toInfo are defined 2353 toInfo.contents.add(addIt) 2354 addInfo.parents.add(addTo) 2355 2356 def removeZoneFromZone( 2357 self, 2358 removeIt: base.Zone, 2359 removeFrom: base.Zone 2360 ) -> bool: 2361 """ 2362 Removes a zone from a zone if it had been in it, returning True 2363 if that zone had been in that zone, and False if it was not in 2364 that zone, including if either zone did not exist. 2365 2366 For example: 2367 2368 >>> d = DecisionGraph() 2369 >>> d.createZone('Z', 0) 2370 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2371 annotations=[]) 2372 >>> d.createZone('Z2', 0) 2373 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2374 annotations=[]) 2375 >>> d.createZone('l1Z', 1) 2376 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2377 annotations=[]) 2378 >>> d.createZone('l2Z', 2) 2379 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2380 annotations=[]) 2381 >>> d.addZoneToZone('Z', 'l1Z') 2382 >>> d.addZoneToZone('l1Z', 'l2Z') 2383 >>> d.getZoneInfo('Z') 2384 ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\ 2385 annotations=[]) 2386 >>> d.getZoneInfo('l1Z') 2387 ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\ 2388 annotations=[]) 2389 >>> d.getZoneInfo('l2Z') 2390 ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\ 2391 annotations=[]) 2392 >>> d.removeZoneFromZone('l1Z', 'l2Z') 2393 True 2394 >>> d.getZoneInfo('l1Z') 2395 ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\ 2396 annotations=[]) 2397 >>> d.getZoneInfo('l2Z') 2398 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2399 annotations=[]) 2400 >>> d.removeZoneFromZone('Z', 'l1Z') 2401 True 2402 >>> d.getZoneInfo('Z') 2403 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2404 annotations=[]) 2405 >>> d.getZoneInfo('l1Z') 2406 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2407 annotations=[]) 2408 >>> d.removeZoneFromZone('Z', 'l1Z') 2409 False 2410 >>> d.removeZoneFromZone('Z', 'madeup') 2411 False 2412 >>> d.removeZoneFromZone('nope', 'madeup') 2413 False 2414 >>> d.removeZoneFromZone('nope', 'l1Z') 2415 False 2416 """ 2417 remInfo = self.getZoneInfo(removeIt) 2418 fromInfo = self.getZoneInfo(removeFrom) 2419 2420 if remInfo is None or fromInfo is None: 2421 return False 2422 2423 if removeIt not in fromInfo.contents: 2424 return False 2425 2426 remInfo.parents.remove(removeFrom) 2427 fromInfo.contents.remove(removeIt) 2428 return True 2429 2430 def decisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]: 2431 """ 2432 Returns a set of all decisions included directly in the given 2433 zone, not counting decisions included via intermediate 2434 sub-zones (see `allDecisionsInZone` to include those). 2435 2436 Raises a `MissingZoneError` if the specified zone does not 2437 exist. 2438 2439 The returned set is a copy, not a live editable set. 2440 2441 For example: 2442 2443 >>> d = DecisionGraph() 2444 >>> d.addDecision('A') 2445 0 2446 >>> d.addDecision('B') 2447 1 2448 >>> d.addDecision('C') 2449 2 2450 >>> d.createZone('Z', 0) 2451 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2452 annotations=[]) 2453 >>> d.addDecisionToZone('A', 'Z') 2454 >>> d.addDecisionToZone('B', 'Z') 2455 >>> d.getZoneInfo('Z') 2456 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2457 annotations=[]) 2458 >>> d.decisionsInZone('Z') 2459 {0, 1} 2460 >>> d.createZone('Z2', 0) 2461 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2462 annotations=[]) 2463 >>> d.addDecisionToZone('B', 'Z2') 2464 >>> d.addDecisionToZone('C', 'Z2') 2465 >>> d.getZoneInfo('Z2') 2466 ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\ 2467 annotations=[]) 2468 >>> d.decisionsInZone('Z') 2469 {0, 1} 2470 >>> d.decisionsInZone('Z2') 2471 {1, 2} 2472 >>> d.createZone('l1Z', 1) 2473 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2474 annotations=[]) 2475 >>> d.addZoneToZone('Z', 'l1Z') 2476 >>> d.decisionsInZone('Z') 2477 {0, 1} 2478 >>> d.decisionsInZone('l1Z') 2479 set() 2480 >>> d.decisionsInZone('madeup') 2481 Traceback (most recent call last): 2482 ... 2483 exploration.core.MissingZoneError... 2484 >>> zDec = d.decisionsInZone('Z') 2485 >>> zDec.add(2) # won't affect the zone 2486 >>> zDec 2487 {0, 1, 2} 2488 >>> d.decisionsInZone('Z') 2489 {0, 1} 2490 """ 2491 info = self.getZoneInfo(zone) 2492 if info is None: 2493 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2494 2495 # Everything that's not a zone must be a decision 2496 return { 2497 item 2498 for item in info.contents 2499 if isinstance(item, base.DecisionID) 2500 } 2501 2502 def subZones(self, zone: base.Zone) -> Set[base.Zone]: 2503 """ 2504 Returns the set of all immediate sub-zones of the given zone. 2505 Will be an empty set if there are no sub-zones; raises a 2506 `MissingZoneError` if the specified zone does not exit. 2507 2508 The returned set is a copy, not a live editable set. 2509 2510 For example: 2511 2512 >>> d = DecisionGraph() 2513 >>> d.createZone('Z', 0) 2514 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2515 annotations=[]) 2516 >>> d.subZones('Z') 2517 set() 2518 >>> d.createZone('l1Z', 1) 2519 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2520 annotations=[]) 2521 >>> d.addZoneToZone('Z', 'l1Z') 2522 >>> d.subZones('Z') 2523 set() 2524 >>> d.subZones('l1Z') 2525 {'Z'} 2526 >>> s = d.subZones('l1Z') 2527 >>> s.add('Q') # doesn't affect the zone 2528 >>> sorted(s) 2529 ['Q', 'Z'] 2530 >>> d.subZones('l1Z') 2531 {'Z'} 2532 >>> d.subZones('madeup') 2533 Traceback (most recent call last): 2534 ... 2535 exploration.core.MissingZoneError... 2536 """ 2537 info = self.getZoneInfo(zone) 2538 if info is None: 2539 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2540 2541 # Sub-zones will appear in self.zones 2542 return { 2543 item 2544 for item in info.contents 2545 if isinstance(item, base.Zone) 2546 } 2547 2548 def allDecisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]: 2549 """ 2550 Returns a set containing all decisions in the given zone, 2551 including those included via sub-zones. 2552 2553 Raises a `MissingZoneError` if the specified zone does not 2554 exist.` 2555 2556 For example: 2557 2558 >>> d = DecisionGraph() 2559 >>> d.addDecision('A') 2560 0 2561 >>> d.addDecision('B') 2562 1 2563 >>> d.addDecision('C') 2564 2 2565 >>> d.createZone('Z', 0) 2566 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2567 annotations=[]) 2568 >>> d.addDecisionToZone('A', 'Z') 2569 >>> d.addDecisionToZone('B', 'Z') 2570 >>> d.getZoneInfo('Z') 2571 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2572 annotations=[]) 2573 >>> d.decisionsInZone('Z') 2574 {0, 1} 2575 >>> d.allDecisionsInZone('Z') 2576 {0, 1} 2577 >>> d.createZone('Z2', 0) 2578 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2579 annotations=[]) 2580 >>> d.addDecisionToZone('B', 'Z2') 2581 >>> d.addDecisionToZone('C', 'Z2') 2582 >>> d.getZoneInfo('Z2') 2583 ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\ 2584 annotations=[]) 2585 >>> d.decisionsInZone('Z') 2586 {0, 1} 2587 >>> d.decisionsInZone('Z2') 2588 {1, 2} 2589 >>> d.allDecisionsInZone('Z2') 2590 {1, 2} 2591 >>> d.createZone('l1Z', 1) 2592 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2593 annotations=[]) 2594 >>> d.createZone('l2Z', 2) 2595 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2596 annotations=[]) 2597 >>> d.addZoneToZone('Z', 'l1Z') 2598 >>> d.addZoneToZone('l1Z', 'l2Z') 2599 >>> d.addZoneToZone('Z2', 'l2Z') 2600 >>> d.decisionsInZone('Z') 2601 {0, 1} 2602 >>> d.decisionsInZone('Z2') 2603 {1, 2} 2604 >>> d.decisionsInZone('l1Z') 2605 set() 2606 >>> d.allDecisionsInZone('l1Z') 2607 {0, 1} 2608 >>> d.allDecisionsInZone('l2Z') 2609 {0, 1, 2} 2610 """ 2611 result: Set[base.DecisionID] = set() 2612 info = self.getZoneInfo(zone) 2613 if info is None: 2614 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2615 2616 for item in info.contents: 2617 if isinstance(item, base.Zone): 2618 # This can't be an error because of the condition above 2619 result |= self.allDecisionsInZone(item) 2620 else: # it's a decision 2621 result.add(item) 2622 2623 return result 2624 2625 def zoneHierarchyLevel(self, zone: base.Zone) -> int: 2626 """ 2627 Returns the hierarchy level of the given zone, as stored in its 2628 zone info. 2629 2630 By convention, level-0 zones contain decisions directly, and 2631 higher-level zones contain zones of lower levels. This 2632 convention is not enforced, and there could be exceptions to it. 2633 2634 Raises a `MissingZoneError` if the specified zone does not 2635 exist. 2636 2637 For example: 2638 2639 >>> d = DecisionGraph() 2640 >>> d.createZone('Z', 0) 2641 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2642 annotations=[]) 2643 >>> d.createZone('l1Z', 1) 2644 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2645 annotations=[]) 2646 >>> d.createZone('l5Z', 5) 2647 ZoneInfo(level=5, parents=set(), contents=set(), tags={},\ 2648 annotations=[]) 2649 >>> d.zoneHierarchyLevel('Z') 2650 0 2651 >>> d.zoneHierarchyLevel('l1Z') 2652 1 2653 >>> d.zoneHierarchyLevel('l5Z') 2654 5 2655 >>> d.zoneHierarchyLevel('madeup') 2656 Traceback (most recent call last): 2657 ... 2658 exploration.core.MissingZoneError... 2659 """ 2660 info = self.getZoneInfo(zone) 2661 if info is None: 2662 raise MissingZoneError(f"Zone {zone!r} dose not exist.") 2663 2664 return info.level 2665 2666 def zoneParents( 2667 self, 2668 zoneOrDecision: Union[base.Zone, base.DecisionID] 2669 ) -> Set[base.Zone]: 2670 """ 2671 Returns the set of all zones which directly contain the target 2672 zone or decision. 2673 2674 Raises a `MissingDecisionError` if the target is neither a valid 2675 zone nor a valid decision. 2676 2677 Returns a copy, not a live editable set. 2678 2679 Example: 2680 2681 >>> g = DecisionGraph() 2682 >>> g.addDecision('A') 2683 0 2684 >>> g.addDecision('B') 2685 1 2686 >>> g.createZone('level0', 0) 2687 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2688 annotations=[]) 2689 >>> g.createZone('level1', 1) 2690 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2691 annotations=[]) 2692 >>> g.createZone('level2', 2) 2693 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2694 annotations=[]) 2695 >>> g.createZone('level3', 3) 2696 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2697 annotations=[]) 2698 >>> g.addDecisionToZone('A', 'level0') 2699 >>> g.addDecisionToZone('B', 'level0') 2700 >>> g.addZoneToZone('level0', 'level1') 2701 >>> g.addZoneToZone('level1', 'level2') 2702 >>> g.addZoneToZone('level2', 'level3') 2703 >>> g.addDecisionToZone('B', 'level2') # Direct w/ skips 2704 >>> sorted(g.zoneParents(0)) 2705 ['level0'] 2706 >>> sorted(g.zoneParents(1)) 2707 ['level0', 'level2'] 2708 """ 2709 if zoneOrDecision in self.zones: 2710 zoneOrDecision = cast(base.Zone, zoneOrDecision) 2711 info = cast(base.ZoneInfo, self.getZoneInfo(zoneOrDecision)) 2712 return copy.copy(info.parents) 2713 elif zoneOrDecision in self: 2714 return self.nodes[zoneOrDecision].get('zones', set()) 2715 else: 2716 raise MissingDecisionError( 2717 f"Name {zoneOrDecision!r} is neither a valid zone nor a" 2718 f" valid decision." 2719 ) 2720 2721 def zoneAncestors( 2722 self, 2723 zoneOrDecision: Union[base.Zone, base.DecisionID], 2724 exclude: Set[base.Zone] = set(), 2725 atLevel: Optional[int] = None 2726 ) -> Set[base.Zone]: 2727 """ 2728 Returns the set of zones which contain the target zone or 2729 decision, either directly or indirectly. The target is not 2730 included in the set. 2731 2732 Any ones listed in the `exclude` set are also excluded, as are 2733 any of their ancestors which are not also ancestors of the 2734 target zone via another path of inclusion. 2735 2736 If `atLevel` is not `None`, then only zones at that hierarchy 2737 level will be included. 2738 2739 Raises a `MissingDecisionError` if the target is nether a valid 2740 zone nor a valid decision. 2741 2742 Example: 2743 2744 >>> g = DecisionGraph() 2745 >>> g.addDecision('A') 2746 0 2747 >>> g.addDecision('B') 2748 1 2749 >>> g.createZone('level0', 0) 2750 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2751 annotations=[]) 2752 >>> g.createZone('level1', 1) 2753 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2754 annotations=[]) 2755 >>> g.createZone('level2', 2) 2756 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2757 annotations=[]) 2758 >>> g.createZone('level3', 3) 2759 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2760 annotations=[]) 2761 >>> g.addDecisionToZone('A', 'level0') 2762 >>> g.addDecisionToZone('B', 'level0') 2763 >>> g.addZoneToZone('level0', 'level1') 2764 >>> g.addZoneToZone('level1', 'level2') 2765 >>> g.addZoneToZone('level2', 'level3') 2766 >>> g.addDecisionToZone('B', 'level2') # Direct w/ skips 2767 >>> sorted(g.zoneAncestors(0)) 2768 ['level0', 'level1', 'level2', 'level3'] 2769 >>> sorted(g.zoneAncestors(1)) 2770 ['level0', 'level1', 'level2', 'level3'] 2771 >>> sorted(g.zoneParents(0)) 2772 ['level0'] 2773 >>> sorted(g.zoneParents(1)) 2774 ['level0', 'level2'] 2775 >>> sorted(g.zoneAncestors(0, atLevel=2)) 2776 ['level2'] 2777 >>> sorted(g.zoneAncestors(0, exclude={'level2'})) 2778 ['level0', 'level1'] 2779 """ 2780 # Copy is important here! 2781 result = set(self.zoneParents(zoneOrDecision)) 2782 result -= exclude 2783 for parent in copy.copy(result): 2784 # Recursively dig up ancestors, but exclude 2785 # results-so-far to avoid re-enumerating when there are 2786 # multiple braided inclusion paths. 2787 result |= self.zoneAncestors(parent, result | exclude, atLevel) 2788 2789 if atLevel is not None: 2790 return {z for z in result if self.zoneHierarchyLevel(z) == atLevel} 2791 else: 2792 return result 2793 2794 def region( 2795 self, 2796 decision: base.DecisionID, 2797 useLevel: int=1 2798 ) -> Optional[base.Zone]: 2799 """ 2800 Returns the 'region' that this decision belongs to. 'Regions' 2801 are level-1 zones, but when a decision is in multiple level-1 2802 zones, its region counts as the smallest of those zones in terms 2803 of total decisions contained, breaking ties by the one with the 2804 alphabetically earlier name. 2805 2806 Always returns a single zone name string, unless the target 2807 decision is not in any level-1 zones, in which case it returns 2808 `None`. 2809 2810 If `useLevel` is specified, then zones of the specified level 2811 will be used instead of level-1 zones. 2812 2813 Example: 2814 2815 >>> g = DecisionGraph() 2816 >>> g.addDecision('A') 2817 0 2818 >>> g.addDecision('B') 2819 1 2820 >>> g.addDecision('C') 2821 2 2822 >>> g.addDecision('D') 2823 3 2824 >>> g.createZone('zoneX', 0) 2825 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2826 annotations=[]) 2827 >>> g.createZone('regionA', 1) 2828 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2829 annotations=[]) 2830 >>> g.createZone('zoneY', 0) 2831 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2832 annotations=[]) 2833 >>> g.createZone('regionB', 1) 2834 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2835 annotations=[]) 2836 >>> g.createZone('regionC', 1) 2837 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2838 annotations=[]) 2839 >>> g.createZone('quadrant', 2) 2840 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2841 annotations=[]) 2842 >>> g.addDecisionToZone('A', 'zoneX') 2843 >>> g.addDecisionToZone('B', 'zoneY') 2844 >>> # C is not in any level-1 zones 2845 >>> g.addDecisionToZone('D', 'zoneX') 2846 >>> g.addDecisionToZone('D', 'zoneY') # D is in both 2847 >>> g.addZoneToZone('zoneX', 'regionA') 2848 >>> g.addZoneToZone('zoneY', 'regionB') 2849 >>> g.addZoneToZone('zoneX', 'regionC') # includes both 2850 >>> g.addZoneToZone('zoneY', 'regionC') 2851 >>> g.addZoneToZone('regionA', 'quadrant') 2852 >>> g.addZoneToZone('regionB', 'quadrant') 2853 >>> g.addDecisionToZone('C', 'regionC') # Direct in level-2 2854 >>> sorted(g.allDecisionsInZone('zoneX')) 2855 [0, 3] 2856 >>> sorted(g.allDecisionsInZone('zoneY')) 2857 [1, 3] 2858 >>> sorted(g.allDecisionsInZone('regionA')) 2859 [0, 3] 2860 >>> sorted(g.allDecisionsInZone('regionB')) 2861 [1, 3] 2862 >>> sorted(g.allDecisionsInZone('regionC')) 2863 [0, 1, 2, 3] 2864 >>> sorted(g.allDecisionsInZone('quadrant')) 2865 [0, 1, 3] 2866 >>> g.region(0) # for A; region A is smaller than region C 2867 'regionA' 2868 >>> g.region(1) # for B; region B is also smaller than C 2869 'regionB' 2870 >>> g.region(2) # for C 2871 'regionC' 2872 >>> g.region(3) # for D; tie broken alphabetically 2873 'regionA' 2874 >>> g.region(0, useLevel=0) # for A at level 0 2875 'zoneX' 2876 >>> g.region(1, useLevel=0) # for B at level 0 2877 'zoneY' 2878 >>> g.region(2, useLevel=0) is None # for C at level 0 (none) 2879 True 2880 >>> g.region(3, useLevel=0) # for D at level 0; tie 2881 'zoneX' 2882 >>> g.region(0, useLevel=2) # for A at level 2 2883 'quadrant' 2884 >>> g.region(1, useLevel=2) # for B at level 2 2885 'quadrant' 2886 >>> g.region(2, useLevel=2) is None # for C at level 2 (none) 2887 True 2888 >>> g.region(3, useLevel=2) # for D at level 2 2889 'quadrant' 2890 """ 2891 relevant = self.zoneAncestors(decision, atLevel=useLevel) 2892 if len(relevant) == 0: 2893 return None 2894 elif len(relevant) == 1: 2895 for zone in relevant: 2896 return zone 2897 return None # not really necessary but keeps mypy happy 2898 else: 2899 # more than one zone ancestor at the relevant hierarchy 2900 # level: need to measure their sizes 2901 minSize = None 2902 candidates = [] 2903 for zone in relevant: 2904 size = len(self.allDecisionsInZone(zone)) 2905 if minSize is None or size < minSize: 2906 candidates = [zone] 2907 minSize = size 2908 elif size == minSize: 2909 candidates.append(zone) 2910 return min(candidates) 2911 2912 def zoneEdges(self, zone: base.Zone) -> Optional[ 2913 Tuple[ 2914 Set[Tuple[base.DecisionID, base.Transition]], 2915 Set[Tuple[base.DecisionID, base.Transition]] 2916 ] 2917 ]: 2918 """ 2919 Given a zone to look at, finds all of the transitions which go 2920 out of and into that zone, ignoring internal transitions between 2921 decisions in the zone. This includes all decisions in sub-zones. 2922 The return value is a pair of sets for outgoing and then 2923 incoming transitions, where each transition is specified as a 2924 (sourceID, transitionName) pair. 2925 2926 Returns `None` if the target zone isn't yet fully defined. 2927 2928 Note that this takes time proportional to *all* edges plus *all* 2929 nodes in the graph no matter how large or small the zone in 2930 question is. 2931 2932 >>> g = DecisionGraph() 2933 >>> g.addDecision('A') 2934 0 2935 >>> g.addDecision('B') 2936 1 2937 >>> g.addDecision('C') 2938 2 2939 >>> g.addDecision('D') 2940 3 2941 >>> g.addTransition('A', 'up', 'B', 'down') 2942 >>> g.addTransition('B', 'right', 'C', 'left') 2943 >>> g.addTransition('C', 'down', 'D', 'up') 2944 >>> g.addTransition('D', 'left', 'A', 'right') 2945 >>> g.addTransition('A', 'tunnel', 'C', 'tunnel') 2946 >>> g.createZone('Z', 0) 2947 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2948 annotations=[]) 2949 >>> g.createZone('ZZ', 1) 2950 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2951 annotations=[]) 2952 >>> g.addZoneToZone('Z', 'ZZ') 2953 >>> g.addDecisionToZone('A', 'Z') 2954 >>> g.addDecisionToZone('B', 'Z') 2955 >>> g.addDecisionToZone('D', 'ZZ') 2956 >>> outgoing, incoming = g.zoneEdges('Z') # TODO: Sort for testing 2957 >>> sorted(outgoing) 2958 [(0, 'right'), (0, 'tunnel'), (1, 'right')] 2959 >>> sorted(incoming) 2960 [(2, 'left'), (2, 'tunnel'), (3, 'left')] 2961 >>> outgoing, incoming = g.zoneEdges('ZZ') 2962 >>> sorted(outgoing) 2963 [(0, 'tunnel'), (1, 'right'), (3, 'up')] 2964 >>> sorted(incoming) 2965 [(2, 'down'), (2, 'left'), (2, 'tunnel')] 2966 >>> g.zoneEdges('madeup') is None 2967 True 2968 """ 2969 # Find the interior nodes 2970 try: 2971 interior = self.allDecisionsInZone(zone) 2972 except MissingZoneError: 2973 return None 2974 2975 # Set up our result 2976 results: Tuple[ 2977 Set[Tuple[base.DecisionID, base.Transition]], 2978 Set[Tuple[base.DecisionID, base.Transition]] 2979 ] = (set(), set()) 2980 2981 # Because finding incoming edges requires searching the entire 2982 # graph anyways, it's more efficient to just consider each edge 2983 # once. 2984 for fromDecision in self: 2985 fromThere = self[fromDecision] 2986 for toDecision in fromThere: 2987 for transition in fromThere[toDecision]: 2988 sourceIn = fromDecision in interior 2989 destIn = toDecision in interior 2990 if sourceIn and not destIn: 2991 results[0].add((fromDecision, transition)) 2992 elif destIn and not sourceIn: 2993 results[1].add((fromDecision, transition)) 2994 2995 return results 2996 2997 def replaceZonesInHierarchy( 2998 self, 2999 target: base.AnyDecisionSpecifier, 3000 zone: base.Zone, 3001 level: int 3002 ) -> None: 3003 """ 3004 This method replaces one or more zones which contain the 3005 specified `target` decision with a specific zone, at a specific 3006 level in the zone hierarchy (see `zoneHierarchyLevel`). If the 3007 named zone doesn't yet exist, it will be created. 3008 3009 To do this, it looks at all zones which contain the target 3010 decision directly or indirectly (see `zoneAncestors`) and which 3011 are at the specified level. 3012 3013 - Any direct children of those zones which are ancestors of the 3014 target decision are removed from those zones and placed into 3015 the new zone instead, regardless of their levels. Indirect 3016 children are not affected (except perhaps indirectly via 3017 their parents' ancestors changing). 3018 - The new zone is placed into every direct parent of those 3019 zones, regardless of their levels (those parents are by 3020 definition all ancestors of the target decision). 3021 - If there were no zones at the target level, every zone at the 3022 next level down which is an ancestor of the target decision 3023 (or just that decision if the level is 0) is placed into the 3024 new zone as a direct child (and is removed from any previous 3025 parents it had). In this case, the new zone will also be 3026 added as a sub-zone to every ancestor of the target decision 3027 at the level above the specified level, if there are any. 3028 * In this case, if there are no zones at the level below the 3029 specified level, the highest level of zones smaller than 3030 that is treated as the level below, down to targeting 3031 the decision itself. 3032 * Similarly, if there are no zones at the level above the 3033 specified level but there are zones at a higher level, 3034 the new zone will be added to each of the zones in the 3035 lowest level above the target level that has zones in it. 3036 3037 A `MissingDecisionError` will be raised if the specified 3038 decision is not valid, or if the decision is left as default but 3039 there is no current decision in the exploration. 3040 3041 An `InvalidLevelError` will be raised if the level is less than 3042 zero. 3043 3044 Example: 3045 3046 >>> g = DecisionGraph() 3047 >>> g.addDecision('decision') 3048 0 3049 >>> g.addDecision('alternate') 3050 1 3051 >>> g.createZone('zone0', 0) 3052 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 3053 annotations=[]) 3054 >>> g.createZone('zone1', 1) 3055 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 3056 annotations=[]) 3057 >>> g.createZone('zone2.1', 2) 3058 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 3059 annotations=[]) 3060 >>> g.createZone('zone2.2', 2) 3061 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 3062 annotations=[]) 3063 >>> g.createZone('zone3', 3) 3064 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 3065 annotations=[]) 3066 >>> g.addDecisionToZone('decision', 'zone0') 3067 >>> g.addDecisionToZone('alternate', 'zone0') 3068 >>> g.addZoneToZone('zone0', 'zone1') 3069 >>> g.addZoneToZone('zone1', 'zone2.1') 3070 >>> g.addZoneToZone('zone1', 'zone2.2') 3071 >>> g.addZoneToZone('zone2.1', 'zone3') 3072 >>> g.addZoneToZone('zone2.2', 'zone3') 3073 >>> g.zoneHierarchyLevel('zone0') 3074 0 3075 >>> g.zoneHierarchyLevel('zone1') 3076 1 3077 >>> g.zoneHierarchyLevel('zone2.1') 3078 2 3079 >>> g.zoneHierarchyLevel('zone2.2') 3080 2 3081 >>> g.zoneHierarchyLevel('zone3') 3082 3 3083 >>> sorted(g.decisionsInZone('zone0')) 3084 [0, 1] 3085 >>> sorted(g.zoneAncestors('zone0')) 3086 ['zone1', 'zone2.1', 'zone2.2', 'zone3'] 3087 >>> g.subZones('zone1') 3088 {'zone0'} 3089 >>> g.zoneParents('zone0') 3090 {'zone1'} 3091 >>> g.replaceZonesInHierarchy('decision', 'new0', 0) 3092 >>> g.zoneParents('zone0') 3093 {'zone1'} 3094 >>> g.zoneParents('new0') 3095 {'zone1'} 3096 >>> sorted(g.zoneAncestors('zone0')) 3097 ['zone1', 'zone2.1', 'zone2.2', 'zone3'] 3098 >>> sorted(g.zoneAncestors('new0')) 3099 ['zone1', 'zone2.1', 'zone2.2', 'zone3'] 3100 >>> g.decisionsInZone('zone0') 3101 {1} 3102 >>> g.decisionsInZone('new0') 3103 {0} 3104 >>> sorted(g.subZones('zone1')) 3105 ['new0', 'zone0'] 3106 >>> g.zoneParents('new0') 3107 {'zone1'} 3108 >>> g.replaceZonesInHierarchy('decision', 'new1', 1) 3109 >>> sorted(g.zoneAncestors(0)) 3110 ['new0', 'new1', 'zone2.1', 'zone2.2', 'zone3'] 3111 >>> g.subZones('zone1') 3112 {'zone0'} 3113 >>> g.subZones('new1') 3114 {'new0'} 3115 >>> g.zoneParents('new0') 3116 {'new1'} 3117 >>> sorted(g.zoneParents('zone1')) 3118 ['zone2.1', 'zone2.2'] 3119 >>> sorted(g.zoneParents('new1')) 3120 ['zone2.1', 'zone2.2'] 3121 >>> g.zoneParents('zone2.1') 3122 {'zone3'} 3123 >>> g.zoneParents('zone2.2') 3124 {'zone3'} 3125 >>> sorted(g.subZones('zone2.1')) 3126 ['new1', 'zone1'] 3127 >>> sorted(g.subZones('zone2.2')) 3128 ['new1', 'zone1'] 3129 >>> sorted(g.allDecisionsInZone('zone2.1')) 3130 [0, 1] 3131 >>> sorted(g.allDecisionsInZone('zone2.2')) 3132 [0, 1] 3133 >>> g.replaceZonesInHierarchy('decision', 'new2', 2) 3134 >>> g.zoneParents('zone2.1') 3135 {'zone3'} 3136 >>> g.zoneParents('zone2.2') 3137 {'zone3'} 3138 >>> g.subZones('zone2.1') 3139 {'zone1'} 3140 >>> g.subZones('zone2.2') 3141 {'zone1'} 3142 >>> g.subZones('new2') 3143 {'new1'} 3144 >>> g.zoneParents('new2') 3145 {'zone3'} 3146 >>> g.allDecisionsInZone('zone2.1') 3147 {1} 3148 >>> g.allDecisionsInZone('zone2.2') 3149 {1} 3150 >>> g.allDecisionsInZone('new2') 3151 {0} 3152 >>> sorted(g.subZones('zone3')) 3153 ['new2', 'zone2.1', 'zone2.2'] 3154 >>> g.zoneParents('zone3') 3155 set() 3156 >>> sorted(g.allDecisionsInZone('zone3')) 3157 [0, 1] 3158 >>> g.replaceZonesInHierarchy('decision', 'new3', 3) 3159 >>> sorted(g.subZones('zone3')) 3160 ['zone2.1', 'zone2.2'] 3161 >>> g.subZones('new3') 3162 {'new2'} 3163 >>> g.zoneParents('zone3') 3164 set() 3165 >>> g.zoneParents('new3') 3166 set() 3167 >>> g.allDecisionsInZone('zone3') 3168 {1} 3169 >>> g.allDecisionsInZone('new3') 3170 {0} 3171 >>> g.replaceZonesInHierarchy('decision', 'new4', 5) 3172 >>> g.subZones('new4') 3173 {'new3'} 3174 >>> g.zoneHierarchyLevel('new4') 3175 5 3176 3177 Another example of level collapse when trying to replace a zone 3178 at a level above : 3179 3180 >>> g = DecisionGraph() 3181 >>> g.addDecision('A') 3182 0 3183 >>> g.addDecision('B') 3184 1 3185 >>> g.createZone('level0', 0) 3186 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 3187 annotations=[]) 3188 >>> g.createZone('level1', 1) 3189 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 3190 annotations=[]) 3191 >>> g.createZone('level2', 2) 3192 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 3193 annotations=[]) 3194 >>> g.createZone('level3', 3) 3195 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 3196 annotations=[]) 3197 >>> g.addDecisionToZone('B', 'level0') 3198 >>> g.addZoneToZone('level0', 'level1') 3199 >>> g.addZoneToZone('level1', 'level2') 3200 >>> g.addZoneToZone('level2', 'level3') 3201 >>> g.addDecisionToZone('A', 'level3') # missing some zone levels 3202 >>> g.zoneHierarchyLevel('level3') 3203 3 3204 >>> g.replaceZonesInHierarchy('A', 'newFirst', 1) 3205 >>> g.zoneHierarchyLevel('newFirst') 3206 1 3207 >>> g.decisionsInZone('newFirst') 3208 {0} 3209 >>> g.decisionsInZone('level3') 3210 set() 3211 >>> sorted(g.allDecisionsInZone('level3')) 3212 [0, 1] 3213 >>> g.subZones('newFirst') 3214 set() 3215 >>> sorted(g.subZones('level3')) 3216 ['level2', 'newFirst'] 3217 >>> g.zoneParents('newFirst') 3218 {'level3'} 3219 >>> g.replaceZonesInHierarchy('A', 'newSecond', 2) 3220 >>> g.zoneHierarchyLevel('newSecond') 3221 2 3222 >>> g.decisionsInZone('newSecond') 3223 set() 3224 >>> g.allDecisionsInZone('newSecond') 3225 {0} 3226 >>> g.subZones('newSecond') 3227 {'newFirst'} 3228 >>> g.zoneParents('newSecond') 3229 {'level3'} 3230 >>> g.zoneParents('newFirst') 3231 {'newSecond'} 3232 >>> sorted(g.subZones('level3')) 3233 ['level2', 'newSecond'] 3234 """ 3235 tID = self.resolveDecision(target) 3236 3237 if level < 0: 3238 raise InvalidLevelError( 3239 f"Target level must be positive (got {level})." 3240 ) 3241 3242 info = self.getZoneInfo(zone) 3243 if info is None: 3244 info = self.createZone(zone, level) 3245 elif level != info.level: 3246 raise InvalidLevelError( 3247 f"Target level ({level}) does not match the level of" 3248 f" the target zone ({zone!r} at level {info.level})." 3249 ) 3250 3251 # Collect both parents & ancestors 3252 parents = self.zoneParents(tID) 3253 ancestors = set(self.zoneAncestors(tID)) 3254 3255 # Map from levels to sets of zones from the ancestors pool 3256 levelMap: Dict[int, Set[base.Zone]] = {} 3257 highest = -1 3258 for ancestor in ancestors: 3259 ancestorLevel = self.zoneHierarchyLevel(ancestor) 3260 levelMap.setdefault(ancestorLevel, set()).add(ancestor) 3261 if ancestorLevel > highest: 3262 highest = ancestorLevel 3263 3264 # Figure out if we have target zones to replace or not 3265 reparentDecision = False 3266 if level in levelMap: 3267 # If there are zones at the target level, 3268 targetZones = levelMap[level] 3269 3270 above = set() 3271 below = set() 3272 3273 for replaced in targetZones: 3274 above |= self.zoneParents(replaced) 3275 below |= self.subZones(replaced) 3276 if replaced in parents: 3277 reparentDecision = True 3278 3279 # Only ancestors should be reparented 3280 below &= ancestors 3281 3282 else: 3283 # Find levels w/ zones in them above + below 3284 levelBelow = level - 1 3285 levelAbove = level + 1 3286 below = levelMap.get(levelBelow, set()) 3287 above = levelMap.get(levelAbove, set()) 3288 3289 while len(below) == 0 and levelBelow > 0: 3290 levelBelow -= 1 3291 below = levelMap.get(levelBelow, set()) 3292 3293 if len(below) == 0: 3294 reparentDecision = True 3295 3296 while len(above) == 0 and levelAbove < highest: 3297 levelAbove += 1 3298 above = levelMap.get(levelAbove, set()) 3299 3300 # Handle re-parenting zones below 3301 for under in below: 3302 for parent in self.zoneParents(under): 3303 if parent in ancestors: 3304 self.removeZoneFromZone(under, parent) 3305 self.addZoneToZone(under, zone) 3306 3307 # Add this zone to each parent 3308 for parent in above: 3309 self.addZoneToZone(zone, parent) 3310 3311 # Re-parent the decision itself if necessary 3312 if reparentDecision: 3313 # (using set() here to avoid size-change-during-iteration) 3314 for parent in set(parents): 3315 self.removeDecisionFromZone(tID, parent) 3316 self.addDecisionToZone(tID, zone) 3317 3318 def getReciprocal( 3319 self, 3320 decision: base.AnyDecisionSpecifier, 3321 transition: base.Transition 3322 ) -> Optional[base.Transition]: 3323 """ 3324 Returns the reciprocal edge for the specified transition from the 3325 specified decision (see `setReciprocal`). Returns 3326 `None` if no reciprocal has been established for that 3327 transition, or if that decision or transition does not exist. 3328 """ 3329 dID = self.resolveDecision(decision) 3330 3331 dest = self.getDestination(dID, transition) 3332 if dest is not None: 3333 info = cast( 3334 TransitionProperties, 3335 self.edges[dID, dest, transition] # type:ignore 3336 ) 3337 recip = info.get("reciprocal") 3338 if recip is not None and not isinstance(recip, base.Transition): 3339 raise ValueError(f"Invalid reciprocal value: {repr(recip)}") 3340 return recip 3341 else: 3342 return None 3343 3344 def setReciprocal( 3345 self, 3346 decision: base.AnyDecisionSpecifier, 3347 transition: base.Transition, 3348 reciprocal: Optional[base.Transition], 3349 setBoth: bool = True, 3350 cleanup: bool = True 3351 ) -> None: 3352 """ 3353 Sets the 'reciprocal' transition for a particular transition from 3354 a particular decision, and removes the reciprocal property from 3355 any old reciprocal transition. 3356 3357 Raises a `MissingDecisionError` or a `MissingTransitionError` if 3358 the specified decision or transition does not exist. 3359 3360 Raises an `InvalidDestinationError` if the reciprocal transition 3361 does not exist, or if it does exist but does not lead back to 3362 the decision the transition came from. 3363 3364 If `setBoth` is True (the default) then the transition which is 3365 being identified as a reciprocal will also have its reciprocal 3366 property set, pointing back to the primary transition being 3367 modified, and any old reciprocal of that transition will have its 3368 reciprocal set to None. If you want to create a situation with 3369 non-exclusive reciprocals, use `setBoth=False`. 3370 3371 If `cleanup` is True (the default) then abandoned reciprocal 3372 transitions (for both edges if `setBoth` was true) have their 3373 reciprocal properties removed. Set `cleanup` to false if you want 3374 to retain them, although this will result in non-exclusive 3375 reciprocal relationships. 3376 3377 If the `reciprocal` value is None, this deletes the reciprocal 3378 value entirely, and if `setBoth` is true, it does this for the 3379 previous reciprocal edge as well. No error is raised in this case 3380 when there was not already a reciprocal to delete. 3381 3382 Note that one should remove a reciprocal relationship before 3383 redirecting either edge of the pair in a way that gives it a new 3384 reciprocal, since otherwise, a later attempt to remove the 3385 reciprocal with `setBoth` set to True (the default) will end up 3386 deleting the reciprocal information from the other edge that was 3387 already modified. There is no way to reliably detect and avoid 3388 this, because two different decisions could (and often do in 3389 practice) have transitions with identical names, meaning that the 3390 reciprocal value will still be the same, but it will indicate a 3391 different edge in virtue of the destination of the edge changing. 3392 3393 ## Example 3394 3395 >>> g = DecisionGraph() 3396 >>> g.addDecision('G') 3397 0 3398 >>> g.addDecision('H') 3399 1 3400 >>> g.addDecision('I') 3401 2 3402 >>> g.addTransition('G', 'up', 'H', 'down') 3403 >>> g.addTransition('G', 'next', 'H', 'prev') 3404 >>> g.addTransition('H', 'next', 'I', 'prev') 3405 >>> g.addTransition('H', 'return', 'G') 3406 >>> g.setReciprocal('G', 'up', 'next') # Error w/ destinations 3407 Traceback (most recent call last): 3408 ... 3409 exploration.core.InvalidDestinationError... 3410 >>> g.setReciprocal('G', 'up', 'none') # Doesn't exist 3411 Traceback (most recent call last): 3412 ... 3413 exploration.core.MissingTransitionError... 3414 >>> g.getReciprocal('G', 'up') 3415 'down' 3416 >>> g.getReciprocal('H', 'down') 3417 'up' 3418 >>> g.getReciprocal('H', 'return') is None 3419 True 3420 >>> g.setReciprocal('G', 'up', 'return') 3421 >>> g.getReciprocal('G', 'up') 3422 'return' 3423 >>> g.getReciprocal('H', 'down') is None 3424 True 3425 >>> g.getReciprocal('H', 'return') 3426 'up' 3427 >>> g.setReciprocal('H', 'return', None) # remove the reciprocal 3428 >>> g.getReciprocal('G', 'up') is None 3429 True 3430 >>> g.getReciprocal('H', 'down') is None 3431 True 3432 >>> g.getReciprocal('H', 'return') is None 3433 True 3434 >>> g.setReciprocal('G', 'up', 'down', setBoth=False) # one-way 3435 >>> g.getReciprocal('G', 'up') 3436 'down' 3437 >>> g.getReciprocal('H', 'down') is None 3438 True 3439 >>> g.getReciprocal('H', 'return') is None 3440 True 3441 >>> g.setReciprocal('H', 'return', 'up', setBoth=False) # non-sym 3442 >>> g.getReciprocal('G', 'up') 3443 'down' 3444 >>> g.getReciprocal('H', 'down') is None 3445 True 3446 >>> g.getReciprocal('H', 'return') 3447 'up' 3448 >>> g.setReciprocal('H', 'down', 'up') # setBoth not needed 3449 >>> g.getReciprocal('G', 'up') 3450 'down' 3451 >>> g.getReciprocal('H', 'down') 3452 'up' 3453 >>> g.getReciprocal('H', 'return') # unchanged 3454 'up' 3455 >>> g.setReciprocal('G', 'up', 'return', cleanup=False) # no cleanup 3456 >>> g.getReciprocal('G', 'up') 3457 'return' 3458 >>> g.getReciprocal('H', 'down') 3459 'up' 3460 >>> g.getReciprocal('H', 'return') # unchanged 3461 'up' 3462 >>> # Cleanup only applies to reciprocal if setBoth is true 3463 >>> g.setReciprocal('H', 'down', 'up', setBoth=False) 3464 >>> g.getReciprocal('G', 'up') 3465 'return' 3466 >>> g.getReciprocal('H', 'down') 3467 'up' 3468 >>> g.getReciprocal('H', 'return') # not cleaned up w/out setBoth 3469 'up' 3470 >>> g.setReciprocal('H', 'down', 'up') # with cleanup and setBoth 3471 >>> g.getReciprocal('G', 'up') 3472 'down' 3473 >>> g.getReciprocal('H', 'down') 3474 'up' 3475 >>> g.getReciprocal('H', 'return') is None # cleaned up 3476 True 3477 """ 3478 dID = self.resolveDecision(decision) 3479 3480 dest = self.destination(dID, transition) # possible KeyError 3481 if reciprocal is None: 3482 rDest = None 3483 else: 3484 rDest = self.getDestination(dest, reciprocal) 3485 3486 # Set or delete reciprocal property 3487 if reciprocal is None: 3488 # Delete the property 3489 info = self.edges[dID, dest, transition] # type:ignore 3490 3491 old = info.pop('reciprocal') 3492 if setBoth: 3493 rDest = self.getDestination(dest, old) 3494 if rDest != dID: 3495 raise RuntimeError( 3496 f"Invalid reciprocal {old!r} for transition" 3497 f" {transition!r} from {self.identityOf(dID)}:" 3498 f" destination is {rDest}." 3499 ) 3500 rInfo = self.edges[dest, dID, old] # type:ignore 3501 if 'reciprocal' in rInfo: 3502 del rInfo['reciprocal'] 3503 else: 3504 # Set the property, checking for errors first 3505 if rDest is None: 3506 raise MissingTransitionError( 3507 f"Reciprocal transition {reciprocal!r} for" 3508 f" transition {transition!r} from decision" 3509 f" {self.identityOf(dID)} does not exist at" 3510 f" decision {self.identityOf(dest)}" 3511 ) 3512 3513 if rDest != dID: 3514 raise InvalidDestinationError( 3515 f"Reciprocal transition {reciprocal!r} from" 3516 f" decision {self.identityOf(dest)} does not lead" 3517 f" back to decision {self.identityOf(dID)}." 3518 ) 3519 3520 eProps = self.edges[dID, dest, transition] # type:ignore [index] 3521 abandoned = eProps.get('reciprocal') 3522 eProps['reciprocal'] = reciprocal 3523 if cleanup and abandoned not in (None, reciprocal): 3524 aProps = self.edges[dest, dID, abandoned] # type:ignore 3525 if 'reciprocal' in aProps: 3526 del aProps['reciprocal'] 3527 3528 if setBoth: 3529 rProps = self.edges[dest, dID, reciprocal] # type:ignore 3530 revAbandoned = rProps.get('reciprocal') 3531 rProps['reciprocal'] = transition 3532 # Sever old reciprocal relationship 3533 if cleanup and revAbandoned not in (None, transition): 3534 raProps = self.edges[ 3535 dID, # type:ignore 3536 dest, 3537 revAbandoned 3538 ] 3539 del raProps['reciprocal'] 3540 3541 def getReciprocalPair( 3542 self, 3543 decision: base.AnyDecisionSpecifier, 3544 transition: base.Transition 3545 ) -> Optional[Tuple[base.DecisionID, base.Transition]]: 3546 """ 3547 Returns a tuple containing both the destination decision ID and 3548 the transition at that decision which is the reciprocal of the 3549 specified destination & transition. Returns `None` if no 3550 reciprocal has been established for that transition, or if that 3551 decision or transition does not exist. 3552 3553 >>> g = DecisionGraph() 3554 >>> g.addDecision('A') 3555 0 3556 >>> g.addDecision('B') 3557 1 3558 >>> g.addDecision('C') 3559 2 3560 >>> g.addTransition('A', 'up', 'B', 'down') 3561 >>> g.addTransition('B', 'right', 'C', 'left') 3562 >>> g.addTransition('A', 'oneway', 'C') 3563 >>> g.getReciprocalPair('A', 'up') 3564 (1, 'down') 3565 >>> g.getReciprocalPair('B', 'down') 3566 (0, 'up') 3567 >>> g.getReciprocalPair('B', 'right') 3568 (2, 'left') 3569 >>> g.getReciprocalPair('C', 'left') 3570 (1, 'right') 3571 >>> g.getReciprocalPair('C', 'up') is None 3572 True 3573 >>> g.getReciprocalPair('Q', 'up') is None 3574 True 3575 >>> g.getReciprocalPair('A', 'tunnel') is None 3576 True 3577 """ 3578 try: 3579 dID = self.resolveDecision(decision) 3580 except MissingDecisionError: 3581 return None 3582 3583 reciprocal = self.getReciprocal(dID, transition) 3584 if reciprocal is None: 3585 return None 3586 else: 3587 destination = self.getDestination(dID, transition) 3588 if destination is None: 3589 return None 3590 else: 3591 return (destination, reciprocal) 3592 3593 def addDecision( 3594 self, 3595 name: base.DecisionName, 3596 domain: Optional[base.Domain] = None, 3597 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 3598 annotations: Optional[List[base.Annotation]] = None 3599 ) -> base.DecisionID: 3600 """ 3601 Adds a decision to the graph, without any transitions yet. Each 3602 decision will be assigned an ID so name collisions are allowed, 3603 but it's usually best to keep names unique at least within each 3604 zone. If no domain is provided, the `DEFAULT_DOMAIN` will be 3605 used for the decision's domain. A dictionary of tags and/or a 3606 list of annotations (strings in both cases) may be provided. 3607 3608 Returns the newly-assigned `DecisionID` for the decision it 3609 created. 3610 3611 Emits a `DecisionCollisionWarning` if a decision with the 3612 provided name already exists and the `WARN_OF_NAME_COLLISIONS` 3613 global variable is set to `True`. 3614 """ 3615 # Defaults 3616 if domain is None: 3617 domain = base.DEFAULT_DOMAIN 3618 if tags is None: 3619 tags = {} 3620 if annotations is None: 3621 annotations = [] 3622 3623 # Error checking 3624 if name in self.nameLookup and WARN_OF_NAME_COLLISIONS: 3625 warnings.warn( 3626 ( 3627 f"Adding decision {name!r}: Another decision with" 3628 f" that name already exists." 3629 ), 3630 DecisionCollisionWarning 3631 ) 3632 3633 dID = self._assignID() 3634 3635 # Add the decision 3636 self.add_node( 3637 dID, 3638 name=name, 3639 domain=domain, 3640 tags=tags, 3641 annotations=annotations 3642 ) 3643 #TODO: Elide tags/annotations if they're empty? 3644 3645 # Track it in our `nameLookup` dictionary 3646 self.nameLookup.setdefault(name, []).append(dID) 3647 3648 return dID 3649 3650 def addIdentifiedDecision( 3651 self, 3652 dID: base.DecisionID, 3653 name: base.DecisionName, 3654 domain: Optional[base.Domain] = None, 3655 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 3656 annotations: Optional[List[base.Annotation]] = None 3657 ) -> None: 3658 """ 3659 Adds a new decision to the graph using a specific decision ID, 3660 rather than automatically assigning a new decision ID like 3661 `addDecision` does. Otherwise works like `addDecision`. 3662 3663 Raises a `MechanismCollisionError` if the specified decision ID 3664 is already in use. 3665 """ 3666 # Defaults 3667 if domain is None: 3668 domain = base.DEFAULT_DOMAIN 3669 if tags is None: 3670 tags = {} 3671 if annotations is None: 3672 annotations = [] 3673 3674 # Error checking 3675 if dID in self.nodes: 3676 raise MechanismCollisionError( 3677 f"Cannot add a node with id {dID} and name {name!r}:" 3678 f" that ID is already used by node {self.identityOf(dID)}" 3679 ) 3680 3681 if name in self.nameLookup and WARN_OF_NAME_COLLISIONS: 3682 warnings.warn( 3683 ( 3684 f"Adding decision {name!r}: Another decision with" 3685 f" that name already exists." 3686 ), 3687 DecisionCollisionWarning 3688 ) 3689 3690 # Add the decision 3691 self.add_node( 3692 dID, 3693 name=name, 3694 domain=domain, 3695 tags=tags, 3696 annotations=annotations 3697 ) 3698 #TODO: Elide tags/annotations if they're empty? 3699 3700 # Track it in our `nameLookup` dictionary 3701 self.nameLookup.setdefault(name, []).append(dID) 3702 3703 def addTransition( 3704 self, 3705 fromDecision: base.AnyDecisionSpecifier, 3706 name: base.Transition, 3707 toDecision: base.AnyDecisionSpecifier, 3708 reciprocal: Optional[base.Transition] = None, 3709 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 3710 annotations: Optional[List[base.Annotation]] = None, 3711 revTags: Optional[Dict[base.Tag, base.TagValue]] = None, 3712 revAnnotations: Optional[List[base.Annotation]] = None, 3713 requires: Optional[base.Requirement] = None, 3714 consequence: Optional[base.Consequence] = None, 3715 revRequires: Optional[base.Requirement] = None, 3716 revConsequece: Optional[base.Consequence] = None 3717 ) -> None: 3718 """ 3719 Adds a transition connecting two decisions. A specifier for each 3720 decision is required, as is a name for the transition. If a 3721 `reciprocal` is provided, a reciprocal edge will be added in the 3722 opposite direction using that name; by default only the specified 3723 edge is added. A `TransitionCollisionError` will be raised if the 3724 `reciprocal` matches the name of an existing edge at the 3725 destination decision. 3726 3727 Both decisions must already exist, or a `MissingDecisionError` 3728 will be raised. 3729 3730 A dictionary of tags and/or a list of annotations may be 3731 provided. Tags and/or annotations for the reverse edge may also 3732 be specified if one is being added. 3733 3734 The `requires`, `consequence`, `revRequires`, and `revConsequece` 3735 arguments specify requirements and/or consequences of the new 3736 outgoing and reciprocal edges. 3737 """ 3738 # Defaults 3739 if tags is None: 3740 tags = {} 3741 if annotations is None: 3742 annotations = [] 3743 if revTags is None: 3744 revTags = {} 3745 if revAnnotations is None: 3746 revAnnotations = [] 3747 3748 # Error checking 3749 fromID = self.resolveDecision(fromDecision) 3750 toID = self.resolveDecision(toDecision) 3751 3752 # Note: have to check this first so we don't add the forward edge 3753 # and then error out after a side effect! 3754 if ( 3755 reciprocal is not None 3756 and self.getDestination(toDecision, reciprocal) is not None 3757 ): 3758 raise TransitionCollisionError( 3759 f"Cannot add a transition from" 3760 f" {self.identityOf(fromDecision)} to" 3761 f" {self.identityOf(toDecision)} with reciprocal edge" 3762 f" {reciprocal!r}: {reciprocal!r} is already used as an" 3763 f" edge name at {self.identityOf(toDecision)}." 3764 ) 3765 3766 # Add the edge 3767 self.add_edge( 3768 fromID, 3769 toID, 3770 key=name, 3771 tags=tags, 3772 annotations=annotations 3773 ) 3774 self.setTransitionRequirement(fromDecision, name, requires) 3775 if consequence is not None: 3776 self.setConsequence(fromDecision, name, consequence) 3777 if reciprocal is not None: 3778 # Add the reciprocal edge 3779 self.add_edge( 3780 toID, 3781 fromID, 3782 key=reciprocal, 3783 tags=revTags, 3784 annotations=revAnnotations 3785 ) 3786 self.setReciprocal(fromID, name, reciprocal) 3787 self.setTransitionRequirement( 3788 toDecision, 3789 reciprocal, 3790 revRequires 3791 ) 3792 if revConsequece is not None: 3793 self.setConsequence(toDecision, reciprocal, revConsequece) 3794 3795 def removeTransition( 3796 self, 3797 fromDecision: base.AnyDecisionSpecifier, 3798 transition: base.Transition, 3799 removeReciprocal=False 3800 ) -> Union[ 3801 TransitionProperties, 3802 Tuple[TransitionProperties, TransitionProperties] 3803 ]: 3804 """ 3805 Removes a transition. If `removeReciprocal` is true (False is the 3806 default) any reciprocal transition will also be removed (but no 3807 error will occur if there wasn't a reciprocal). 3808 3809 For each removed transition, *every* transition that targeted 3810 that transition as its reciprocal will have its reciprocal set to 3811 `None`, to avoid leaving any invalid reciprocal values. 3812 3813 Raises a `KeyError` if either the target decision or the target 3814 transition does not exist. 3815 3816 Returns a transition properties dictionary with the properties 3817 of the removed transition, or if `removeReciprocal` is true, 3818 returns a pair of such dictionaries for the target transition 3819 and its reciprocal. 3820 3821 ## Example 3822 3823 >>> g = DecisionGraph() 3824 >>> g.addDecision('A') 3825 0 3826 >>> g.addDecision('B') 3827 1 3828 >>> g.addTransition('A', 'up', 'B', 'down', tags={'wide'}) 3829 >>> g.addTransition('A', 'in', 'B', 'out') # we won't touch this 3830 >>> g.addTransition('A', 'next', 'B') 3831 >>> g.setReciprocal('A', 'next', 'down', setBoth=False) 3832 >>> p = g.removeTransition('A', 'up') 3833 >>> p['tags'] 3834 {'wide'} 3835 >>> g.destinationsFrom('A') 3836 {'in': 1, 'next': 1} 3837 >>> g.destinationsFrom('B') 3838 {'down': 0, 'out': 0} 3839 >>> g.getReciprocal('B', 'down') is None 3840 True 3841 >>> g.getReciprocal('A', 'next') # Asymmetrical left over 3842 'down' 3843 >>> g.getReciprocal('A', 'in') # not affected 3844 'out' 3845 >>> g.getReciprocal('B', 'out') # not affected 3846 'in' 3847 >>> # Now with removeReciprocal set to True 3848 >>> g.addTransition('A', 'up', 'B') # add this back in 3849 >>> g.setReciprocal('A', 'up', 'down') # sets both 3850 >>> p = g.removeTransition('A', 'up', removeReciprocal=True) 3851 >>> g.destinationsFrom('A') 3852 {'in': 1, 'next': 1} 3853 >>> g.destinationsFrom('B') 3854 {'out': 0} 3855 >>> g.getReciprocal('A', 'next') is None 3856 True 3857 >>> g.getReciprocal('A', 'in') # not affected 3858 'out' 3859 >>> g.getReciprocal('B', 'out') # not affected 3860 'in' 3861 >>> g.removeTransition('A', 'none') 3862 Traceback (most recent call last): 3863 ... 3864 exploration.core.MissingTransitionError... 3865 >>> g.removeTransition('Z', 'nope') 3866 Traceback (most recent call last): 3867 ... 3868 exploration.core.MissingDecisionError... 3869 """ 3870 # Resolve target ID 3871 fromID = self.resolveDecision(fromDecision) 3872 3873 # raises if either is missing: 3874 destination = self.destination(fromID, transition) 3875 reciprocal = self.getReciprocal(fromID, transition) 3876 3877 # Get dictionaries of parallel & antiparallel edges to be 3878 # checked for invalid reciprocals after removing edges 3879 # Note: these will update live as we remove edges 3880 allAntiparallel = self[destination][fromID] 3881 allParallel = self[fromID][destination] 3882 3883 # Remove the target edge 3884 fProps = self.getTransitionProperties(fromID, transition) 3885 self.remove_edge(fromID, destination, transition) 3886 3887 # Clean up any dangling reciprocal values 3888 for tProps in allAntiparallel.values(): 3889 if tProps.get('reciprocal') == transition: 3890 del tProps['reciprocal'] 3891 3892 # Remove the reciprocal if requested 3893 if removeReciprocal and reciprocal is not None: 3894 rProps = self.getTransitionProperties(destination, reciprocal) 3895 self.remove_edge(destination, fromID, reciprocal) 3896 3897 # Clean up any dangling reciprocal values 3898 for tProps in allParallel.values(): 3899 if tProps.get('reciprocal') == reciprocal: 3900 del tProps['reciprocal'] 3901 3902 return (fProps, rProps) 3903 else: 3904 return fProps 3905 3906 def addMechanism( 3907 self, 3908 name: base.MechanismName, 3909 where: Optional[base.AnyDecisionSpecifier] = None 3910 ) -> base.MechanismID: 3911 """ 3912 Creates a new mechanism with the given name at the specified 3913 decision, returning its assigned ID. If `where` is `None`, it 3914 creates a global mechanism. Raises a `MechanismCollisionError` 3915 if a mechanism with the same name already exists at a specified 3916 decision (or already exists as a global mechanism). 3917 3918 Note that if the decision is deleted, the mechanism will be as 3919 well. 3920 3921 Since `MechanismState`s are not tracked by `DecisionGraph`s but 3922 instead are part of a `State`, the mechanism won't be in any 3923 particular state, which means it will be treated as being in the 3924 `base.DEFAULT_MECHANISM_STATE`. 3925 """ 3926 if where is None: 3927 mechs = self.globalMechanisms 3928 dID = None 3929 else: 3930 dID = self.resolveDecision(where) 3931 mechs = self.nodes[dID].setdefault('mechanisms', {}) 3932 3933 if name in mechs: 3934 if dID is None: 3935 raise MechanismCollisionError( 3936 f"A global mechanism named {name!r} already exists." 3937 ) 3938 else: 3939 raise MechanismCollisionError( 3940 f"A mechanism named {name!r} already exists at" 3941 f" decision {self.identityOf(dID)}." 3942 ) 3943 3944 mID = self._assignMechanismID() 3945 mechs[name] = mID 3946 self.mechanisms[mID] = (dID, name) 3947 return mID 3948 3949 def mechanismsAt( 3950 self, 3951 decision: base.AnyDecisionSpecifier 3952 ) -> Dict[base.MechanismName, base.MechanismID]: 3953 """ 3954 Returns a dictionary mapping mechanism names to their IDs for 3955 all mechanisms at the specified decision. 3956 """ 3957 dID = self.resolveDecision(decision) 3958 3959 return self.nodes[dID]['mechanisms'] 3960 3961 def mechanismDetails( 3962 self, 3963 mID: base.MechanismID 3964 ) -> Optional[Tuple[Optional[base.DecisionID], base.MechanismName]]: 3965 """ 3966 Returns a tuple containing the decision ID and mechanism name 3967 for the specified mechanism. Returns `None` if there is no 3968 mechanism with that ID. For global mechanisms, `None` is used in 3969 place of a decision ID. 3970 """ 3971 return self.mechanisms.get(mID) 3972 3973 def deleteMechanism(self, mID: base.MechanismID) -> None: 3974 """ 3975 Deletes the specified mechanism. 3976 """ 3977 name, dID = self.mechanisms.pop(mID) 3978 3979 del self.nodes[dID]['mechanisms'][name] 3980 3981 def localLookup( 3982 self, 3983 startFrom: Union[ 3984 base.AnyDecisionSpecifier, 3985 Collection[base.AnyDecisionSpecifier] 3986 ], 3987 findAmong: Callable[ 3988 ['DecisionGraph', Union[Set[base.DecisionID], str]], 3989 Optional[LookupResult] 3990 ], 3991 fallbackLayerName: Optional[str] = "fallback", 3992 fallbackToAllDecisions: bool = True 3993 ) -> Optional[LookupResult]: 3994 """ 3995 Looks up some kind of result in the graph by starting from a 3996 base set of decisions and widening the search iteratively based 3997 on zones. This first searches for result(s) in the set of 3998 decisions given, then in the set of all decisions which are in 3999 level-0 zones containing those decisions, then in level-1 zones, 4000 etc. When it runs out of relevant zones, it will check all 4001 decisions which are in any domain that a decision from the 4002 initial search set is in, and then if `fallbackLayerName` is a 4003 string, it will provide that string instead of a set of decision 4004 IDs to the `findAmong` function as the next layer to search. 4005 After the `fallbackLayerName` is used, if 4006 `fallbackToAllDecisions` is `True` (the default) a final search 4007 will be run on all decisions in the graph. The provided 4008 `findAmong` function is called on each successive decision ID 4009 set, until it generates a non-`None` result. We stop and return 4010 that non-`None` result as soon as one is generated. But if none 4011 of the decision sets consulted generate non-`None` results, then 4012 the entire result will be `None`. 4013 """ 4014 # Normalize starting decisions to a set 4015 if isinstance(startFrom, (int, str, base.DecisionSpecifier)): 4016 startFrom = set([startFrom]) 4017 4018 # Resolve decision IDs; convert to list 4019 searchArea: Union[Set[base.DecisionID], str] = set( 4020 self.resolveDecision(spec) for spec in startFrom 4021 ) 4022 4023 # Find all ancestor zones & all relevant domains 4024 allAncestors = set() 4025 relevantDomains = set() 4026 for startingDecision in searchArea: 4027 allAncestors |= self.zoneAncestors(startingDecision) 4028 relevantDomains.add(self.domainFor(startingDecision)) 4029 4030 # Build layers dictionary 4031 ancestorLayers: Dict[int, Set[base.Zone]] = {} 4032 for zone in allAncestors: 4033 info = self.getZoneInfo(zone) 4034 assert info is not None 4035 level = info.level 4036 ancestorLayers.setdefault(level, set()).add(zone) 4037 4038 searchLayers: LookupLayersList = ( 4039 cast(LookupLayersList, [None]) 4040 + cast(LookupLayersList, sorted(ancestorLayers.keys())) 4041 + cast(LookupLayersList, ["domains"]) 4042 ) 4043 if fallbackLayerName is not None: 4044 searchLayers.append("fallback") 4045 4046 if fallbackToAllDecisions: 4047 searchLayers.append("all") 4048 4049 # Continue our search through zone layers 4050 for layer in searchLayers: 4051 # Update search area on subsequent iterations 4052 if layer == "domains": 4053 searchArea = set() 4054 for relevant in relevantDomains: 4055 searchArea |= self.allDecisionsInDomain(relevant) 4056 elif layer == "fallback": 4057 assert fallbackLayerName is not None 4058 searchArea = fallbackLayerName 4059 elif layer == "all": 4060 searchArea = set(self.nodes) 4061 elif layer is not None: 4062 layer = cast(int, layer) # must be an integer 4063 searchZones = ancestorLayers[layer] 4064 searchArea = set() 4065 for zone in searchZones: 4066 searchArea |= self.allDecisionsInZone(zone) 4067 # else it's the first iteration and we use the starting 4068 # searchArea 4069 4070 searchResult: Optional[LookupResult] = findAmong( 4071 self, 4072 searchArea 4073 ) 4074 4075 if searchResult is not None: 4076 return searchResult 4077 4078 # Didn't find any non-None results. 4079 return None 4080 4081 @staticmethod 4082 def uniqueMechanismFinder(name: base.MechanismName) -> Callable[ 4083 ['DecisionGraph', Union[Set[base.DecisionID], str]], 4084 Optional[base.MechanismID] 4085 ]: 4086 """ 4087 Returns a search function that looks for the given mechanism ID, 4088 suitable for use with `localLookup`. The finder will raise a 4089 `MechanismCollisionError` if it finds more than one mechanism 4090 with the specified name at the same level of the search. 4091 """ 4092 def namedMechanismFinder( 4093 graph: 'DecisionGraph', 4094 searchIn: Union[Set[base.DecisionID], str] 4095 ) -> Optional[base.MechanismID]: 4096 """ 4097 Generated finder function for `localLookup` to find a unique 4098 mechanism by name. 4099 """ 4100 candidates: List[base.DecisionID] = [] 4101 4102 if searchIn == "fallback": 4103 if name in graph.globalMechanisms: 4104 candidates = [graph.globalMechanisms[name]] 4105 4106 else: 4107 assert isinstance(searchIn, set) 4108 for dID in searchIn: 4109 mechs = graph.nodes[dID].get('mechanisms', {}) 4110 if name in mechs: 4111 candidates.append(mechs[name]) 4112 4113 if len(candidates) > 1: 4114 raise MechanismCollisionError( 4115 f"There are {len(candidates)} mechanisms named {name!r}" 4116 f" in the search area ({len(searchIn)} decisions(s))." 4117 ) 4118 elif len(candidates) == 1: 4119 return candidates[0] 4120 else: 4121 return None 4122 4123 return namedMechanismFinder 4124 4125 def lookupMechanism( 4126 self, 4127 startFrom: Union[ 4128 base.AnyDecisionSpecifier, 4129 Collection[base.AnyDecisionSpecifier] 4130 ], 4131 name: base.MechanismName 4132 ) -> base.MechanismID: 4133 """ 4134 Looks up the mechanism with the given name 'closest' to the 4135 given decision or set of decisions. First it looks for a 4136 mechanism with that name that's at one of those decisions. Then 4137 it starts looking in level-0 zones which contain any of them, 4138 then in level-1 zones, and so on. If it finds two mechanisms 4139 with the target name during the same search pass, it raises a 4140 `MechanismCollisionError`, but if it finds one it returns it. 4141 Raises a `MissingMechanismError` if there is no mechanisms with 4142 that name among global mechanisms (searched after the last 4143 applicable level of zones) or anywhere in the graph (which is the 4144 final level of search after checking global mechanisms). 4145 4146 For example: 4147 4148 >>> d = DecisionGraph() 4149 >>> d.addDecision('A') 4150 0 4151 >>> d.addDecision('B') 4152 1 4153 >>> d.addDecision('C') 4154 2 4155 >>> d.addDecision('D') 4156 3 4157 >>> d.addDecision('E') 4158 4 4159 >>> d.addMechanism('switch', 'A') 4160 0 4161 >>> d.addMechanism('switch', 'B') 4162 1 4163 >>> d.addMechanism('switch', 'C') 4164 2 4165 >>> d.addMechanism('lever', 'D') 4166 3 4167 >>> d.addMechanism('lever', None) # global 4168 4 4169 >>> d.createZone('Z1', 0) 4170 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 4171 annotations=[]) 4172 >>> d.createZone('Z2', 0) 4173 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 4174 annotations=[]) 4175 >>> d.createZone('Zup', 1) 4176 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 4177 annotations=[]) 4178 >>> d.addDecisionToZone('A', 'Z1') 4179 >>> d.addDecisionToZone('B', 'Z1') 4180 >>> d.addDecisionToZone('C', 'Z2') 4181 >>> d.addDecisionToZone('D', 'Z2') 4182 >>> d.addDecisionToZone('E', 'Z1') 4183 >>> d.addZoneToZone('Z1', 'Zup') 4184 >>> d.addZoneToZone('Z2', 'Zup') 4185 >>> d.lookupMechanism(set(), 'switch') # 3x among all decisions 4186 Traceback (most recent call last): 4187 ... 4188 exploration.core.MechanismCollisionError... 4189 >>> d.lookupMechanism(set(), 'lever') # 1x global > 1x all 4190 4 4191 >>> d.lookupMechanism({'D'}, 'lever') # local 4192 3 4193 >>> d.lookupMechanism({'A'}, 'lever') # found at D via Zup 4194 3 4195 >>> d.lookupMechanism({'A', 'D'}, 'lever') # local again 4196 3 4197 >>> d.lookupMechanism({'A'}, 'switch') # local 4198 0 4199 >>> d.lookupMechanism({'B'}, 'switch') # local 4200 1 4201 >>> d.lookupMechanism({'C'}, 'switch') # local 4202 2 4203 >>> d.lookupMechanism({'A', 'B'}, 'switch') # ambiguous 4204 Traceback (most recent call last): 4205 ... 4206 exploration.core.MechanismCollisionError... 4207 >>> d.lookupMechanism({'A', 'B', 'C'}, 'switch') # ambiguous 4208 Traceback (most recent call last): 4209 ... 4210 exploration.core.MechanismCollisionError... 4211 >>> d.lookupMechanism({'B', 'D'}, 'switch') # not ambiguous 4212 1 4213 >>> d.lookupMechanism({'E', 'D'}, 'switch') # ambiguous at L0 zone 4214 Traceback (most recent call last): 4215 ... 4216 exploration.core.MechanismCollisionError... 4217 >>> d.lookupMechanism({'E'}, 'switch') # ambiguous at L0 zone 4218 Traceback (most recent call last): 4219 ... 4220 exploration.core.MechanismCollisionError... 4221 >>> d.lookupMechanism({'D'}, 'switch') # found at L0 zone 4222 2 4223 """ 4224 result = self.localLookup( 4225 startFrom, 4226 DecisionGraph.uniqueMechanismFinder(name) 4227 ) 4228 if result is None: 4229 raise MissingMechanismError( 4230 f"No mechanism named {name!r}" 4231 ) 4232 else: 4233 return result 4234 4235 def resolveMechanism( 4236 self, 4237 specifier: base.AnyMechanismSpecifier, 4238 startFrom: Union[ 4239 None, 4240 base.AnyDecisionSpecifier, 4241 Collection[base.AnyDecisionSpecifier] 4242 ] = None 4243 ) -> base.MechanismID: 4244 """ 4245 Works like `lookupMechanism`, except it accepts a 4246 `base.AnyMechanismSpecifier` which may have position information 4247 baked in, and so the `startFrom` information is optional. If 4248 position information isn't specified in the mechanism specifier 4249 and startFrom is not provided, the mechanism is searched for at 4250 the global scope and then in the entire graph. On the other 4251 hand, if the specifier includes any position information, the 4252 startFrom value provided here will be ignored. 4253 """ 4254 if isinstance(specifier, base.MechanismID): 4255 return specifier 4256 4257 elif isinstance(specifier, base.MechanismName): 4258 if startFrom is None: 4259 startFrom = set() 4260 return self.lookupMechanism(startFrom, specifier) 4261 4262 elif isinstance(specifier, tuple) and len(specifier) == 4: 4263 domain, zone, decision, mechanism = specifier 4264 if domain is None and zone is None and decision is None: 4265 if startFrom is None: 4266 startFrom = set() 4267 return self.lookupMechanism(startFrom, mechanism) 4268 4269 elif decision is not None: 4270 startFrom = { 4271 self.resolveDecision( 4272 base.DecisionSpecifier(domain, zone, decision) 4273 ) 4274 } 4275 return self.lookupMechanism(startFrom, mechanism) 4276 4277 else: # decision is None but domain and/or zone aren't 4278 startFrom = set() 4279 if zone is not None: 4280 baseStart = self.allDecisionsInZone(zone) 4281 else: 4282 baseStart = set(self) 4283 4284 if domain is None: 4285 startFrom = baseStart 4286 else: 4287 for dID in baseStart: 4288 if self.domainFor(dID) == domain: 4289 startFrom.add(dID) 4290 return self.lookupMechanism(startFrom, mechanism) 4291 4292 else: 4293 raise TypeError( 4294 f"Invalid mechanism specifier: {repr(specifier)}" 4295 f"\n(Must be a mechanism ID, mechanism name, or" 4296 f" mechanism specifier tuple)" 4297 ) 4298 4299 def walkConsequenceMechanisms( 4300 self, 4301 consequence: base.Consequence, 4302 searchFrom: Set[base.DecisionID] 4303 ) -> Generator[base.MechanismID, None, None]: 4304 """ 4305 Yields each requirement in the given `base.Consequence`, 4306 including those in `base.Condition`s, `base.ConditionalSkill`s 4307 within `base.Challenge`s, and those set or toggled by 4308 `base.Effect`s. The `searchFrom` argument specifies where to 4309 start searching for mechanisms, since requirements include them 4310 by name, not by ID. 4311 """ 4312 for part in base.walkParts(consequence): 4313 if isinstance(part, dict): 4314 if 'skills' in part: # a Challenge 4315 for cSkill in part['skills'].walk(): 4316 if isinstance(cSkill, base.ConditionalSkill): 4317 yield from self.walkRequirementMechanisms( 4318 cSkill.requirement, 4319 searchFrom 4320 ) 4321 elif 'condition' in part: # a Condition 4322 yield from self.walkRequirementMechanisms( 4323 part['condition'], 4324 searchFrom 4325 ) 4326 elif 'value' in part: # an Effect 4327 val = part['value'] 4328 if part['type'] == 'set': 4329 if ( 4330 isinstance(val, tuple) 4331 and len(val) == 2 4332 and isinstance(val[1], base.State) 4333 ): 4334 yield from self.walkRequirementMechanisms( 4335 base.ReqMechanism(val[0], val[1]), 4336 searchFrom 4337 ) 4338 elif part['type'] == 'toggle': 4339 if isinstance(val, tuple): 4340 assert len(val) == 2 4341 yield from self.walkRequirementMechanisms( 4342 base.ReqMechanism(val[0], '_'), 4343 # state part is ignored here 4344 searchFrom 4345 ) 4346 4347 def walkRequirementMechanisms( 4348 self, 4349 req: base.Requirement, 4350 searchFrom: Set[base.DecisionID] 4351 ) -> Generator[base.MechanismID, None, None]: 4352 """ 4353 Given a requirement, yields any mechanisms mentioned in that 4354 requirement, in depth-first traversal order. 4355 """ 4356 for part in req.walk(): 4357 if isinstance(part, base.ReqMechanism): 4358 mech = part.mechanism 4359 yield self.resolveMechanism( 4360 mech, 4361 startFrom=searchFrom 4362 ) 4363 4364 def addUnexploredEdge( 4365 self, 4366 fromDecision: base.AnyDecisionSpecifier, 4367 name: base.Transition, 4368 destinationName: Optional[base.DecisionName] = None, 4369 reciprocal: Optional[base.Transition] = 'return', 4370 toDomain: Optional[base.Domain] = None, 4371 placeInZone: Optional[base.Zone] = None, 4372 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 4373 annotations: Optional[List[base.Annotation]] = None, 4374 revTags: Optional[Dict[base.Tag, base.TagValue]] = None, 4375 revAnnotations: Optional[List[base.Annotation]] = None, 4376 requires: Optional[base.Requirement] = None, 4377 consequence: Optional[base.Consequence] = None, 4378 revRequires: Optional[base.Requirement] = None, 4379 revConsequece: Optional[base.Consequence] = None 4380 ) -> base.DecisionID: 4381 """ 4382 Adds a transition connecting to a new decision named `'_u.-n-'` 4383 where '-n-' is the number of unknown decisions (named or not) 4384 that have ever been created in this graph (or using the 4385 specified destination name if one is provided). This represents 4386 a transition to an unknown destination. The destination node 4387 gets tagged 'unconfirmed'. 4388 4389 This also adds a reciprocal transition in the reverse direction, 4390 unless `reciprocal` is set to `None`. The reciprocal will use 4391 the provided name (default is 'return'). The new decision will 4392 be in the same domain as the decision it's connected to, unless 4393 `toDecision` is specified, in which case it will be in that 4394 domain. 4395 4396 The new decision will not be placed into any zones, unless 4397 `placeInZone` is specified, in which case it will be placed into 4398 that zone. If that zone needs to be created, it will be created 4399 at level 0; in that case that zone will be added to any 4400 grandparent zones of the decision we're branching off of. If 4401 `placeInZone` is set to `base.DefaultZone`, then the new 4402 decision will be placed into each parent zone of the decision 4403 we're branching off of, as long as the new decision is in the 4404 same domain as the decision we're branching from (otherwise only 4405 an explicit `placeInZone` would apply). 4406 4407 The ID of the decision that was created is returned. 4408 4409 A `MissingDecisionError` will be raised if the starting decision 4410 does not exist, a `TransitionCollisionError` will be raised if 4411 it exists but already has a transition with the given name, and a 4412 `DecisionCollisionWarning` will be issued if a decision with the 4413 specified destination name already exists (won't happen when 4414 using an automatic name). 4415 4416 Lists of tags and/or annotations (strings in both cases) may be 4417 provided. These may also be provided for the reciprocal edge. 4418 4419 Similarly, requirements and/or consequences for either edge may 4420 be provided. 4421 4422 ## Example 4423 4424 >>> g = DecisionGraph() 4425 >>> g.addDecision('A') 4426 0 4427 >>> g.addUnexploredEdge('A', 'up') 4428 1 4429 >>> g.nameFor(1) 4430 '_u.0' 4431 >>> g.decisionTags(1) 4432 {'unconfirmed': 1} 4433 >>> g.addUnexploredEdge('A', 'right', 'B') 4434 2 4435 >>> g.nameFor(2) 4436 'B' 4437 >>> g.decisionTags(2) 4438 {'unconfirmed': 1} 4439 >>> g.addUnexploredEdge('A', 'down', None, 'up') 4440 3 4441 >>> g.nameFor(3) 4442 '_u.2' 4443 >>> g.addUnexploredEdge( 4444 ... '_u.0', 4445 ... 'beyond', 4446 ... toDomain='otherDomain', 4447 ... tags={'fast':1}, 4448 ... revTags={'slow':1}, 4449 ... annotations=['comment'], 4450 ... revAnnotations=['one', 'two'], 4451 ... requires=base.ReqCapability('dash'), 4452 ... revRequires=base.ReqCapability('super dash'), 4453 ... consequence=[base.effect(gain='super dash')], 4454 ... revConsequece=[base.effect(lose='super dash')] 4455 ... ) 4456 4 4457 >>> g.nameFor(4) 4458 '_u.3' 4459 >>> g.domainFor(4) 4460 'otherDomain' 4461 >>> g.transitionTags('_u.0', 'beyond') 4462 {'fast': 1} 4463 >>> g.transitionAnnotations('_u.0', 'beyond') 4464 ['comment'] 4465 >>> g.getTransitionRequirement('_u.0', 'beyond') 4466 ReqCapability('dash') 4467 >>> e = g.getConsequence('_u.0', 'beyond') 4468 >>> e == [base.effect(gain='super dash')] 4469 True 4470 >>> g.transitionTags('_u.3', 'return') 4471 {'slow': 1} 4472 >>> g.transitionAnnotations('_u.3', 'return') 4473 ['one', 'two'] 4474 >>> g.getTransitionRequirement('_u.3', 'return') 4475 ReqCapability('super dash') 4476 >>> e = g.getConsequence('_u.3', 'return') 4477 >>> e == [base.effect(lose='super dash')] 4478 True 4479 """ 4480 # Defaults 4481 if tags is None: 4482 tags = {} 4483 if annotations is None: 4484 annotations = [] 4485 if revTags is None: 4486 revTags = {} 4487 if revAnnotations is None: 4488 revAnnotations = [] 4489 4490 # Resolve ID 4491 fromID = self.resolveDecision(fromDecision) 4492 if toDomain is None: 4493 toDomain = self.domainFor(fromID) 4494 4495 if name in self.destinationsFrom(fromID): 4496 raise TransitionCollisionError( 4497 f"Cannot add a new edge {name!r}:" 4498 f" {self.identityOf(fromDecision)} already has an" 4499 f" outgoing edge with that name." 4500 ) 4501 4502 if destinationName in self.nameLookup and WARN_OF_NAME_COLLISIONS: 4503 warnings.warn( 4504 ( 4505 f"Cannot add a new unexplored node" 4506 f" {destinationName!r}: A decision with that name" 4507 f" already exists.\n(Leave destinationName as None" 4508 f" to use an automatic name.)" 4509 ), 4510 DecisionCollisionWarning 4511 ) 4512 4513 # Create the new unexplored decision and add the edge 4514 if destinationName is None: 4515 toName = '_u.' + str(self.unknownCount) 4516 else: 4517 toName = destinationName 4518 self.unknownCount += 1 4519 newID = self.addDecision(toName, domain=toDomain) 4520 self.addTransition( 4521 fromID, 4522 name, 4523 newID, 4524 tags=tags, 4525 annotations=annotations 4526 ) 4527 self.setTransitionRequirement(fromID, name, requires) 4528 if consequence is not None: 4529 self.setConsequence(fromID, name, consequence) 4530 4531 # Add it to a zone if requested 4532 if ( 4533 placeInZone == base.DefaultZone 4534 and toDomain == self.domainFor(fromID) 4535 ): 4536 # Add to each parent of the from decision 4537 for parent in self.zoneParents(fromID): 4538 self.addDecisionToZone(newID, parent) 4539 elif placeInZone is not None: 4540 # Otherwise add it to one specific zone, creating that zone 4541 # at level 0 if necessary 4542 assert isinstance(placeInZone, base.Zone) 4543 if self.getZoneInfo(placeInZone) is None: 4544 self.createZone(placeInZone, 0) 4545 # Add new zone to each grandparent of the from decision 4546 for parent in self.zoneParents(fromID): 4547 for grandparent in self.zoneParents(parent): 4548 self.addZoneToZone(placeInZone, grandparent) 4549 self.addDecisionToZone(newID, placeInZone) 4550 4551 # Create the reciprocal edge 4552 if reciprocal is not None: 4553 self.addTransition( 4554 newID, 4555 reciprocal, 4556 fromID, 4557 tags=revTags, 4558 annotations=revAnnotations 4559 ) 4560 self.setTransitionRequirement(newID, reciprocal, revRequires) 4561 if revConsequece is not None: 4562 self.setConsequence(newID, reciprocal, revConsequece) 4563 # Set as a reciprocal 4564 self.setReciprocal(fromID, name, reciprocal) 4565 4566 # Tag the destination as 'unconfirmed' 4567 self.tagDecision(newID, 'unconfirmed') 4568 4569 # Return ID of new destination 4570 return newID 4571 4572 def retargetTransition( 4573 self, 4574 fromDecision: base.AnyDecisionSpecifier, 4575 transition: base.Transition, 4576 newDestination: base.AnyDecisionSpecifier, 4577 swapReciprocal=True, 4578 errorOnNameColision=True 4579 ) -> Optional[base.Transition]: 4580 """ 4581 Given a particular decision and a transition at that decision, 4582 changes that transition so that it goes to the specified new 4583 destination instead of wherever it was connected to before. If 4584 the new destination is the same as the old one, no changes are 4585 made. 4586 4587 If `swapReciprocal` is set to True (the default) then any 4588 reciprocal edge at the old destination will be deleted, and a 4589 new reciprocal edge from the new destination with equivalent 4590 properties to the original reciprocal will be created, pointing 4591 to the origin of the specified transition. If `swapReciprocal` 4592 is set to False, then the reciprocal relationship with any old 4593 reciprocal edge will be removed, but the old reciprocal edge 4594 will not be changed. 4595 4596 Note that if `errorOnNameColision` is True (the default), then 4597 if the reciprocal transition has the same name as a transition 4598 which already exists at the new destination node, a 4599 `TransitionCollisionError` will be thrown. However, if it is set 4600 to False, the reciprocal transition will be renamed with a suffix 4601 to avoid any possible name collisions. Either way, the name of 4602 the reciprocal transition (possibly just changed) will be 4603 returned, or None if there was no reciprocal transition. 4604 4605 ## Example 4606 4607 >>> g = DecisionGraph() 4608 >>> for fr, to, nm in [ 4609 ... ('A', 'B', 'up'), 4610 ... ('A', 'B', 'up2'), 4611 ... ('B', 'A', 'down'), 4612 ... ('B', 'B', 'self'), 4613 ... ('B', 'C', 'next'), 4614 ... ('C', 'B', 'prev') 4615 ... ]: 4616 ... if g.getDecision(fr) is None: 4617 ... g.addDecision(fr) 4618 ... if g.getDecision(to) is None: 4619 ... g.addDecision(to) 4620 ... g.addTransition(fr, nm, to) 4621 0 4622 1 4623 2 4624 >>> g.setReciprocal('A', 'up', 'down') 4625 >>> g.setReciprocal('B', 'next', 'prev') 4626 >>> g.destination('A', 'up') 4627 1 4628 >>> g.destination('B', 'down') 4629 0 4630 >>> g.retargetTransition('A', 'up', 'C') 4631 'down' 4632 >>> g.destination('A', 'up') 4633 2 4634 >>> g.getDestination('B', 'down') is None 4635 True 4636 >>> g.destination('C', 'down') 4637 0 4638 >>> g.addTransition('A', 'next', 'B') 4639 >>> g.addTransition('B', 'prev', 'A') 4640 >>> g.setReciprocal('A', 'next', 'prev') 4641 >>> # Can't swap a reciprocal in a way that would collide names 4642 >>> g.getReciprocal('C', 'prev') 4643 'next' 4644 >>> g.retargetTransition('C', 'prev', 'A') 4645 Traceback (most recent call last): 4646 ... 4647 exploration.core.TransitionCollisionError... 4648 >>> g.retargetTransition('C', 'prev', 'A', swapReciprocal=False) 4649 'next' 4650 >>> g.destination('C', 'prev') 4651 0 4652 >>> g.destination('A', 'next') # not changed 4653 1 4654 >>> # Reciprocal relationship is severed: 4655 >>> g.getReciprocal('C', 'prev') is None 4656 True 4657 >>> g.getReciprocal('B', 'next') is None 4658 True 4659 >>> # Swap back so we can do another demo 4660 >>> g.retargetTransition('C', 'prev', 'B', swapReciprocal=False) 4661 >>> # Note return value was None here because there was no reciprocal 4662 >>> g.setReciprocal('C', 'prev', 'next') 4663 >>> # Swap reciprocal by renaming it 4664 >>> g.retargetTransition('C', 'prev', 'A', errorOnNameColision=False) 4665 'next.1' 4666 >>> g.getReciprocal('C', 'prev') 4667 'next.1' 4668 >>> g.destination('C', 'prev') 4669 0 4670 >>> g.destination('A', 'next.1') 4671 2 4672 >>> g.destination('A', 'next') 4673 1 4674 >>> # Note names are the same but these are from different nodes 4675 >>> g.getReciprocal('A', 'next') 4676 'prev' 4677 >>> g.getReciprocal('A', 'next.1') 4678 'prev' 4679 """ 4680 fromID = self.resolveDecision(fromDecision) 4681 newDestID = self.resolveDecision(newDestination) 4682 4683 # Figure out the old destination of the transition we're swapping 4684 oldDestID = self.destination(fromID, transition) 4685 reciprocal = self.getReciprocal(fromID, transition) 4686 4687 # If thew new destination is the same, we don't do anything! 4688 if oldDestID == newDestID: 4689 return reciprocal 4690 4691 # First figure out reciprocal business so we can error out 4692 # without making changes if we need to 4693 if swapReciprocal and reciprocal is not None: 4694 reciprocal = self.rebaseTransition( 4695 oldDestID, 4696 reciprocal, 4697 newDestID, 4698 swapReciprocal=False, 4699 errorOnNameColision=errorOnNameColision 4700 ) 4701 4702 # Handle the forward transition... 4703 # Find the transition properties 4704 tProps = self.getTransitionProperties(fromID, transition) 4705 4706 # Delete the edge 4707 self.removeEdgeByKey(fromID, transition) 4708 4709 # Add the new edge 4710 self.addTransition(fromID, transition, newDestID) 4711 4712 # Reapply the transition properties 4713 self.setTransitionProperties(fromID, transition, **tProps) 4714 4715 # Handle the reciprocal transition if there is one... 4716 if reciprocal is not None: 4717 if not swapReciprocal: 4718 # Then sever the relationship, but only if that edge 4719 # still exists (we might be in the middle of a rebase) 4720 check = self.getDestination(oldDestID, reciprocal) 4721 if check is not None: 4722 self.setReciprocal( 4723 oldDestID, 4724 reciprocal, 4725 None, 4726 setBoth=False # Other transition was deleted already 4727 ) 4728 else: 4729 # Establish new reciprocal relationship 4730 self.setReciprocal( 4731 fromID, 4732 transition, 4733 reciprocal 4734 ) 4735 4736 return reciprocal 4737 4738 def rebaseTransition( 4739 self, 4740 fromDecision: base.AnyDecisionSpecifier, 4741 transition: base.Transition, 4742 newBase: base.AnyDecisionSpecifier, 4743 swapReciprocal=True, 4744 errorOnNameColision=True 4745 ) -> base.Transition: 4746 """ 4747 Given a particular destination and a transition at that 4748 destination, changes that transition's origin to a new base 4749 decision. If the new source is the same as the old one, no 4750 changes are made. 4751 4752 If `swapReciprocal` is set to True (the default) then any 4753 reciprocal edge at the destination will be retargeted to point 4754 to the new source so that it can remain a reciprocal. If 4755 `swapReciprocal` is set to False, then the reciprocal 4756 relationship with any old reciprocal edge will be removed, but 4757 the old reciprocal edge will not be otherwise changed. 4758 4759 Note that if `errorOnNameColision` is True (the default), then 4760 if the transition has the same name as a transition which 4761 already exists at the new source node, a 4762 `TransitionCollisionError` will be raised. However, if it is set 4763 to False, the transition will be renamed with a suffix to avoid 4764 any possible name collisions. Either way, the (possibly new) name 4765 of the transition that was rebased will be returned. 4766 4767 ## Example 4768 4769 >>> g = DecisionGraph() 4770 >>> for fr, to, nm in [ 4771 ... ('A', 'B', 'up'), 4772 ... ('A', 'B', 'up2'), 4773 ... ('B', 'A', 'down'), 4774 ... ('B', 'B', 'self'), 4775 ... ('B', 'C', 'next'), 4776 ... ('C', 'B', 'prev') 4777 ... ]: 4778 ... if g.getDecision(fr) is None: 4779 ... g.addDecision(fr) 4780 ... if g.getDecision(to) is None: 4781 ... g.addDecision(to) 4782 ... g.addTransition(fr, nm, to) 4783 0 4784 1 4785 2 4786 >>> g.setReciprocal('A', 'up', 'down') 4787 >>> g.setReciprocal('B', 'next', 'prev') 4788 >>> g.destination('A', 'up') 4789 1 4790 >>> g.destination('B', 'down') 4791 0 4792 >>> g.rebaseTransition('B', 'down', 'C') 4793 'down' 4794 >>> g.destination('A', 'up') 4795 2 4796 >>> g.getDestination('B', 'down') is None 4797 True 4798 >>> g.destination('C', 'down') 4799 0 4800 >>> g.addTransition('A', 'next', 'B') 4801 >>> g.addTransition('B', 'prev', 'A') 4802 >>> g.setReciprocal('A', 'next', 'prev') 4803 >>> # Can't rebase in a way that would collide names 4804 >>> g.rebaseTransition('B', 'next', 'A') 4805 Traceback (most recent call last): 4806 ... 4807 exploration.core.TransitionCollisionError... 4808 >>> g.rebaseTransition('B', 'next', 'A', errorOnNameColision=False) 4809 'next.1' 4810 >>> g.destination('C', 'prev') 4811 0 4812 >>> g.destination('A', 'next') # not changed 4813 1 4814 >>> # Collision is avoided by renaming 4815 >>> g.destination('A', 'next.1') 4816 2 4817 >>> # Swap without reciprocal 4818 >>> g.getReciprocal('A', 'next.1') 4819 'prev' 4820 >>> g.getReciprocal('C', 'prev') 4821 'next.1' 4822 >>> g.rebaseTransition('A', 'next.1', 'B', swapReciprocal=False) 4823 'next.1' 4824 >>> g.getReciprocal('C', 'prev') is None 4825 True 4826 >>> g.destination('C', 'prev') 4827 0 4828 >>> g.getDestination('A', 'next.1') is None 4829 True 4830 >>> g.destination('A', 'next') 4831 1 4832 >>> g.destination('B', 'next.1') 4833 2 4834 >>> g.getReciprocal('B', 'next.1') is None 4835 True 4836 >>> # Rebase in a way that creates a self-edge 4837 >>> g.rebaseTransition('A', 'next', 'B') 4838 'next' 4839 >>> g.getDestination('A', 'next') is None 4840 True 4841 >>> g.destination('B', 'next') 4842 1 4843 >>> g.destination('B', 'prev') # swapped as a reciprocal 4844 1 4845 >>> g.getReciprocal('B', 'next') # still reciprocals 4846 'prev' 4847 >>> g.getReciprocal('B', 'prev') 4848 'next' 4849 >>> # And rebasing of a self-edge also works 4850 >>> g.rebaseTransition('B', 'prev', 'A') 4851 'prev' 4852 >>> g.destination('A', 'prev') 4853 1 4854 >>> g.destination('B', 'next') 4855 0 4856 >>> g.getReciprocal('B', 'next') # still reciprocals 4857 'prev' 4858 >>> g.getReciprocal('A', 'prev') 4859 'next' 4860 >>> # We've effectively reversed this edge/reciprocal pair 4861 >>> # by rebasing twice 4862 """ 4863 fromID = self.resolveDecision(fromDecision) 4864 newBaseID = self.resolveDecision(newBase) 4865 4866 # If thew new base is the same, we don't do anything! 4867 if newBaseID == fromID: 4868 return transition 4869 4870 # First figure out reciprocal business so we can swap it later 4871 # without making changes if we need to 4872 destination = self.destination(fromID, transition) 4873 reciprocal = self.getReciprocal(fromID, transition) 4874 # Check for an already-deleted reciprocal 4875 if ( 4876 reciprocal is not None 4877 and self.getDestination(destination, reciprocal) is None 4878 ): 4879 reciprocal = None 4880 4881 # Handle the base swap... 4882 # Find the transition properties 4883 tProps = self.getTransitionProperties(fromID, transition) 4884 4885 # Check for a collision 4886 targetDestinations = self.destinationsFrom(newBaseID) 4887 if transition in targetDestinations: 4888 if errorOnNameColision: 4889 raise TransitionCollisionError( 4890 f"Cannot rebase transition {transition!r} from" 4891 f" {self.identityOf(fromDecision)}: it would be a" 4892 f" duplicate transition name at the new base" 4893 f" decision {self.identityOf(newBase)}." 4894 ) 4895 else: 4896 # Figure out a good fresh name 4897 newName = utils.uniqueName( 4898 transition, 4899 targetDestinations 4900 ) 4901 else: 4902 newName = transition 4903 4904 # Delete the edge 4905 self.removeEdgeByKey(fromID, transition) 4906 4907 # Add the new edge 4908 self.addTransition(newBaseID, newName, destination) 4909 4910 # Reapply the transition properties 4911 self.setTransitionProperties(newBaseID, newName, **tProps) 4912 4913 # Handle the reciprocal transition if there is one... 4914 if reciprocal is not None: 4915 if not swapReciprocal: 4916 # Then sever the relationship 4917 self.setReciprocal( 4918 destination, 4919 reciprocal, 4920 None, 4921 setBoth=False # Other transition was deleted already 4922 ) 4923 else: 4924 # Otherwise swap the reciprocal edge 4925 self.retargetTransition( 4926 destination, 4927 reciprocal, 4928 newBaseID, 4929 swapReciprocal=False 4930 ) 4931 4932 # And establish a new reciprocal relationship 4933 self.setReciprocal( 4934 newBaseID, 4935 newName, 4936 reciprocal 4937 ) 4938 4939 # Return the new name in case it was changed 4940 return newName 4941 4942 # TODO: zone merging! 4943 4944 # TODO: Double-check that exploration vars get updated when this is 4945 # called! 4946 def mergeDecisions( 4947 self, 4948 merge: base.AnyDecisionSpecifier, 4949 mergeInto: base.AnyDecisionSpecifier, 4950 errorOnNameColision=True 4951 ) -> Dict[base.Transition, base.Transition]: 4952 """ 4953 Merges two decisions, deleting the first after transferring all 4954 of its incoming and outgoing edges to target the second one, 4955 whose name is retained. The second decision will be added to any 4956 zones that the first decision was a member of. If either decision 4957 does not exist, a `MissingDecisionError` will be raised. If 4958 `merge` and `mergeInto` are the same, then nothing will be 4959 changed. 4960 4961 Unless `errorOnNameColision` is set to False, a 4962 `TransitionCollisionError` will be raised if the two decisions 4963 have outgoing transitions with the same name. If 4964 `errorOnNameColision` is set to False, then such edges will be 4965 renamed using a suffix to avoid name collisions, with edges 4966 connected to the second decision retaining their original names 4967 and edges that were connected to the first decision getting 4968 renamed. 4969 4970 Any mechanisms located at the first decision will be moved to the 4971 merged decision. 4972 4973 The tags and annotations of the merged decision are added to the 4974 tags and annotations of the merge target. If there are shared 4975 tags, the values from the merge target will override those of 4976 the merged decision. If this is undesired behavior, clear/edit 4977 the tags/annotations of the merged decision before the merge. 4978 4979 The 'unconfirmed' tag is treated specially: if both decisions have 4980 it it will be retained, but otherwise it will be dropped even if 4981 one of the situations had it before. 4982 4983 The domain of the second decision is retained. 4984 4985 Returns a dictionary mapping each original transition name to 4986 its new name in cases where transitions get renamed; this will 4987 be empty when no re-naming occurs, including when 4988 `errorOnNameColision` is True. If there were any transitions 4989 connecting the nodes that were merged, these become self-edges 4990 of the merged node (and may be renamed if necessary). 4991 Note that all renamed transitions were originally based on the 4992 first (merged) node, since transitions of the second (merge 4993 target) node are not renamed. 4994 4995 ## Example 4996 4997 >>> g = DecisionGraph() 4998 >>> for fr, to, nm in [ 4999 ... ('A', 'B', 'up'), 5000 ... ('A', 'B', 'up2'), 5001 ... ('B', 'A', 'down'), 5002 ... ('B', 'B', 'self'), 5003 ... ('B', 'C', 'next'), 5004 ... ('C', 'B', 'prev'), 5005 ... ('A', 'C', 'right') 5006 ... ]: 5007 ... if g.getDecision(fr) is None: 5008 ... g.addDecision(fr) 5009 ... if g.getDecision(to) is None: 5010 ... g.addDecision(to) 5011 ... g.addTransition(fr, nm, to) 5012 0 5013 1 5014 2 5015 >>> g.getDestination('A', 'up') 5016 1 5017 >>> g.getDestination('B', 'down') 5018 0 5019 >>> sorted(g) 5020 [0, 1, 2] 5021 >>> g.setReciprocal('A', 'up', 'down') 5022 >>> g.setReciprocal('B', 'next', 'prev') 5023 >>> g.mergeDecisions('C', 'B') 5024 {} 5025 >>> g.destinationsFrom('A') 5026 {'up': 1, 'up2': 1, 'right': 1} 5027 >>> g.destinationsFrom('B') 5028 {'down': 0, 'self': 1, 'prev': 1, 'next': 1} 5029 >>> 'C' in g 5030 False 5031 >>> g.mergeDecisions('A', 'A') # does nothing 5032 {} 5033 >>> # Can't merge non-existent decision 5034 >>> g.mergeDecisions('A', 'Z') 5035 Traceback (most recent call last): 5036 ... 5037 exploration.core.MissingDecisionError... 5038 >>> g.mergeDecisions('Z', 'A') 5039 Traceback (most recent call last): 5040 ... 5041 exploration.core.MissingDecisionError... 5042 >>> # Can't merge decisions w/ shared edge names 5043 >>> g.addDecision('D') 5044 3 5045 >>> g.addTransition('D', 'next', 'A') 5046 >>> g.addTransition('A', 'prev', 'D') 5047 >>> g.setReciprocal('D', 'next', 'prev') 5048 >>> g.mergeDecisions('D', 'B') # both have a 'next' transition 5049 Traceback (most recent call last): 5050 ... 5051 exploration.core.TransitionCollisionError... 5052 >>> # Auto-rename colliding edges 5053 >>> g.mergeDecisions('D', 'B', errorOnNameColision=False) 5054 {'next': 'next.1'} 5055 >>> g.destination('B', 'next') # merge target unchanged 5056 1 5057 >>> g.destination('B', 'next.1') # merged decision name changed 5058 0 5059 >>> g.destination('B', 'prev') # name unchanged (no collision) 5060 1 5061 >>> g.getReciprocal('B', 'next') # unchanged (from B) 5062 'prev' 5063 >>> g.getReciprocal('B', 'next.1') # from A 5064 'prev' 5065 >>> g.getReciprocal('A', 'prev') # from B 5066 'next.1' 5067 5068 ## Folding four nodes into a 2-node loop 5069 5070 >>> g = DecisionGraph() 5071 >>> g.addDecision('X') 5072 0 5073 >>> g.addDecision('Y') 5074 1 5075 >>> g.addTransition('X', 'next', 'Y', 'prev') 5076 >>> g.addDecision('preX') 5077 2 5078 >>> g.addDecision('postY') 5079 3 5080 >>> g.addTransition('preX', 'next', 'X', 'prev') 5081 >>> g.addTransition('Y', 'next', 'postY', 'prev') 5082 >>> g.mergeDecisions('preX', 'Y', errorOnNameColision=False) 5083 {'next': 'next.1'} 5084 >>> g.destinationsFrom('X') 5085 {'next': 1, 'prev': 1} 5086 >>> g.destinationsFrom('Y') 5087 {'prev': 0, 'next': 3, 'next.1': 0} 5088 >>> 2 in g 5089 False 5090 >>> g.destinationsFrom('postY') 5091 {'prev': 1} 5092 >>> g.mergeDecisions('postY', 'X', errorOnNameColision=False) 5093 {'prev': 'prev.1'} 5094 >>> g.destinationsFrom('X') 5095 {'next': 1, 'prev': 1, 'prev.1': 1} 5096 >>> g.destinationsFrom('Y') # order 'cause of 'next' re-target 5097 {'prev': 0, 'next.1': 0, 'next': 0} 5098 >>> 2 in g 5099 False 5100 >>> 3 in g 5101 False 5102 >>> # Reciprocals are tangled... 5103 >>> g.getReciprocal(0, 'prev') 5104 'next.1' 5105 >>> g.getReciprocal(0, 'prev.1') 5106 'next' 5107 >>> g.getReciprocal(1, 'next') 5108 'prev.1' 5109 >>> g.getReciprocal(1, 'next.1') 5110 'prev' 5111 >>> # Note: one merge cannot handle both extra transitions 5112 >>> # because their reciprocals are crossed (e.g., prev.1 <-> next) 5113 >>> # (It would merge both edges but the result would retain 5114 >>> # 'next.1' instead of retaining 'next'.) 5115 >>> g.mergeTransitions('X', 'prev.1', 'prev', mergeReciprocal=False) 5116 >>> g.mergeTransitions('Y', 'next.1', 'next', mergeReciprocal=True) 5117 >>> g.destinationsFrom('X') 5118 {'next': 1, 'prev': 1} 5119 >>> g.destinationsFrom('Y') 5120 {'prev': 0, 'next': 0} 5121 >>> # Reciprocals were salvaged in second merger 5122 >>> g.getReciprocal('X', 'prev') 5123 'next' 5124 >>> g.getReciprocal('Y', 'next') 5125 'prev' 5126 5127 ## Merging with tags/requirements/annotations/consequences 5128 5129 >>> g = DecisionGraph() 5130 >>> g.addDecision('X') 5131 0 5132 >>> g.addDecision('Y') 5133 1 5134 >>> g.addDecision('Z') 5135 2 5136 >>> g.addTransition('X', 'next', 'Y', 'prev') 5137 >>> g.addTransition('X', 'down', 'Z', 'up') 5138 >>> g.tagDecision('X', 'tag0', 1) 5139 >>> g.tagDecision('Y', 'tag1', 10) 5140 >>> g.tagDecision('Y', 'unconfirmed') 5141 >>> g.tagDecision('Z', 'tag1', 20) 5142 >>> g.tagDecision('Z', 'tag2', 30) 5143 >>> g.tagTransition('X', 'next', 'ttag1', 11) 5144 >>> g.tagTransition('Y', 'prev', 'ttag2', 22) 5145 >>> g.tagTransition('X', 'down', 'ttag3', 33) 5146 >>> g.tagTransition('Z', 'up', 'ttag4', 44) 5147 >>> g.annotateDecision('Y', 'annotation 1') 5148 >>> g.annotateDecision('Z', 'annotation 2') 5149 >>> g.annotateDecision('Z', 'annotation 3') 5150 >>> g.annotateTransition('Y', 'prev', 'trans annotation 1') 5151 >>> g.annotateTransition('Y', 'prev', 'trans annotation 2') 5152 >>> g.annotateTransition('Z', 'up', 'trans annotation 3') 5153 >>> g.setTransitionRequirement( 5154 ... 'X', 5155 ... 'next', 5156 ... base.ReqCapability('power') 5157 ... ) 5158 >>> g.setTransitionRequirement( 5159 ... 'Y', 5160 ... 'prev', 5161 ... base.ReqTokens('token', 1) 5162 ... ) 5163 >>> g.setTransitionRequirement( 5164 ... 'X', 5165 ... 'down', 5166 ... base.ReqCapability('power2') 5167 ... ) 5168 >>> g.setTransitionRequirement( 5169 ... 'Z', 5170 ... 'up', 5171 ... base.ReqTokens('token2', 2) 5172 ... ) 5173 >>> g.setConsequence( 5174 ... 'Y', 5175 ... 'prev', 5176 ... [base.effect(gain="power2")] 5177 ... ) 5178 >>> g.mergeDecisions('Y', 'Z') 5179 {} 5180 >>> g.destination('X', 'next') 5181 2 5182 >>> g.destination('X', 'down') 5183 2 5184 >>> g.destination('Z', 'prev') 5185 0 5186 >>> g.destination('Z', 'up') 5187 0 5188 >>> g.decisionTags('X') 5189 {'tag0': 1} 5190 >>> g.decisionTags('Z') # note that 'unconfirmed' is removed 5191 {'tag1': 20, 'tag2': 30} 5192 >>> g.transitionTags('X', 'next') 5193 {'ttag1': 11} 5194 >>> g.transitionTags('X', 'down') 5195 {'ttag3': 33} 5196 >>> g.transitionTags('Z', 'prev') 5197 {'ttag2': 22} 5198 >>> g.transitionTags('Z', 'up') 5199 {'ttag4': 44} 5200 >>> g.decisionAnnotations('Z') 5201 ['annotation 2', 'annotation 3', 'annotation 1'] 5202 >>> g.transitionAnnotations('Z', 'prev') 5203 ['trans annotation 1', 'trans annotation 2'] 5204 >>> g.transitionAnnotations('Z', 'up') 5205 ['trans annotation 3'] 5206 >>> g.getTransitionRequirement('X', 'next') 5207 ReqCapability('power') 5208 >>> g.getTransitionRequirement('Z', 'prev') 5209 ReqTokens('token', 1) 5210 >>> g.getTransitionRequirement('X', 'down') 5211 ReqCapability('power2') 5212 >>> g.getTransitionRequirement('Z', 'up') 5213 ReqTokens('token2', 2) 5214 >>> g.getConsequence('Z', 'prev') == [ 5215 ... { 5216 ... 'type': 'gain', 5217 ... 'applyTo': 'active', 5218 ... 'value': 'power2', 5219 ... 'charges': None, 5220 ... 'delay': None, 5221 ... 'hidden': False 5222 ... } 5223 ... ] 5224 True 5225 5226 ## Merging into node without tags 5227 5228 >>> g = DecisionGraph() 5229 >>> g.addDecision('X') 5230 0 5231 >>> g.addDecision('Y') 5232 1 5233 >>> g.tagDecision('Y', 'unconfirmed') # special handling 5234 >>> g.tagDecision('Y', 'tag', 'value') 5235 >>> g.mergeDecisions('Y', 'X') 5236 {} 5237 >>> g.decisionTags('X') 5238 {'tag': 'value'} 5239 >>> 0 in g # Second argument remains 5240 True 5241 >>> 1 in g # First argument is deleted 5242 False 5243 """ 5244 # Resolve IDs 5245 mergeID = self.resolveDecision(merge) 5246 mergeIntoID = self.resolveDecision(mergeInto) 5247 5248 # Create our result as an empty dictionary 5249 result: Dict[base.Transition, base.Transition] = {} 5250 5251 # Short-circuit if the two decisions are the same 5252 if mergeID == mergeIntoID: 5253 return result 5254 5255 # MissingDecisionErrors from here if either doesn't exist 5256 allNewOutgoing = set(self.destinationsFrom(mergeID)) 5257 allOldOutgoing = set(self.destinationsFrom(mergeIntoID)) 5258 # Find colliding transition names 5259 collisions = allNewOutgoing & allOldOutgoing 5260 if len(collisions) > 0 and errorOnNameColision: 5261 raise TransitionCollisionError( 5262 f"Cannot merge decision {self.identityOf(merge)} into" 5263 f" decision {self.identityOf(mergeInto)}: the decisions" 5264 f" share {len(collisions)} transition names:" 5265 f" {collisions}\n(Note that errorOnNameColision was set" 5266 f" to True, set it to False to allow the operation by" 5267 f" renaming half of those transitions.)" 5268 ) 5269 5270 # Record zones that will have to change after the merge 5271 zoneParents = self.zoneParents(mergeID) 5272 5273 # First, swap all incoming edges, along with their reciprocals 5274 # This will include self-edges, which will be retargeted and 5275 # whose reciprocals will be rebased in the process, leading to 5276 # the possibility of a missing edge during the loop 5277 for source, incoming in self.allEdgesTo(mergeID): 5278 # Skip this edge if it was already swapped away because it's 5279 # a self-loop with a reciprocal whose reciprocal was 5280 # processed earlier in the loop 5281 if incoming not in self.destinationsFrom(source): 5282 continue 5283 5284 # Find corresponding outgoing edge 5285 outgoing = self.getReciprocal(source, incoming) 5286 5287 # Swap both edges to new destination 5288 newOutgoing = self.retargetTransition( 5289 source, 5290 incoming, 5291 mergeIntoID, 5292 swapReciprocal=True, 5293 errorOnNameColision=False # collisions were detected above 5294 ) 5295 # Add to our result if the name of the reciprocal was 5296 # changed 5297 if ( 5298 outgoing is not None 5299 and newOutgoing is not None 5300 and outgoing != newOutgoing 5301 ): 5302 result[outgoing] = newOutgoing 5303 5304 # Next, swap any remaining outgoing edges (which didn't have 5305 # reciprocals, or they'd already be swapped, unless they were 5306 # self-edges previously). Note that in this loop, there can't be 5307 # any self-edges remaining, although there might be connections 5308 # between the merging nodes that need to become self-edges 5309 # because they used to be a self-edge that was half-retargeted 5310 # by the previous loop. 5311 # Note: a copy is used here to avoid iterating over a changing 5312 # dictionary 5313 for stillOutgoing in copy.copy(self.destinationsFrom(mergeID)): 5314 newOutgoing = self.rebaseTransition( 5315 mergeID, 5316 stillOutgoing, 5317 mergeIntoID, 5318 swapReciprocal=True, 5319 errorOnNameColision=False # collisions were detected above 5320 ) 5321 if stillOutgoing != newOutgoing: 5322 result[stillOutgoing] = newOutgoing 5323 5324 # At this point, there shouldn't be any remaining incoming or 5325 # outgoing edges! 5326 assert self.degree(mergeID) == 0 5327 5328 # Merge tags & annotations 5329 # Note that these operations affect the underlying graph 5330 destTags = self.decisionTags(mergeIntoID) 5331 destUnvisited = 'unconfirmed' in destTags 5332 sourceTags = self.decisionTags(mergeID) 5333 sourceUnvisited = 'unconfirmed' in sourceTags 5334 # Copy over only new tags, leaving existing tags alone 5335 for key in sourceTags: 5336 if key not in destTags: 5337 destTags[key] = sourceTags[key] 5338 5339 if int(destUnvisited) + int(sourceUnvisited) == 1: 5340 del destTags['unconfirmed'] 5341 5342 self.decisionAnnotations(mergeIntoID).extend( 5343 self.decisionAnnotations(mergeID) 5344 ) 5345 5346 # Transfer zones 5347 for zone in zoneParents: 5348 self.addDecisionToZone(mergeIntoID, zone) 5349 5350 # Delete the old node 5351 self.removeDecision(mergeID) 5352 5353 return result 5354 5355 def removeDecision(self, decision: base.AnyDecisionSpecifier) -> None: 5356 """ 5357 Deletes the specified decision from the graph, updating 5358 attendant structures like zones. Note that the ID of the deleted 5359 node will NOT be reused, unless it's specifically provided to 5360 `addIdentifiedDecision`. 5361 5362 For example: 5363 5364 >>> dg = DecisionGraph() 5365 >>> dg.addDecision('A') 5366 0 5367 >>> dg.addDecision('B') 5368 1 5369 >>> list(dg) 5370 [0, 1] 5371 >>> 1 in dg 5372 True 5373 >>> 'B' in dg.nameLookup 5374 True 5375 >>> dg.removeDecision('B') 5376 >>> 1 in dg 5377 False 5378 >>> list(dg) 5379 [0] 5380 >>> 'B' in dg.nameLookup 5381 False 5382 >>> dg.addDecision('C') # doesn't re-use ID 5383 2 5384 """ 5385 dID = self.resolveDecision(decision) 5386 5387 # Remove the target from all zones: 5388 for zone in self.zones: 5389 self.removeDecisionFromZone(dID, zone) 5390 5391 # Remove the node but record the current name 5392 name = self.nodes[dID]['name'] 5393 self.remove_node(dID) 5394 5395 # Clean up the nameLookup entry 5396 luInfo = self.nameLookup[name] 5397 luInfo.remove(dID) 5398 if len(luInfo) == 0: 5399 self.nameLookup.pop(name) 5400 5401 # TODO: Clean up edges? 5402 5403 def renameDecision( 5404 self, 5405 decision: base.AnyDecisionSpecifier, 5406 newName: base.DecisionName 5407 ): 5408 """ 5409 Renames a decision. The decision retains its old ID. 5410 5411 Generates a `DecisionCollisionWarning` if a decision using the new 5412 name already exists and `WARN_OF_NAME_COLLISIONS` is enabled. 5413 5414 Example: 5415 5416 >>> g = DecisionGraph() 5417 >>> g.addDecision('one') 5418 0 5419 >>> g.addDecision('three') 5420 1 5421 >>> g.addTransition('one', '>', 'three') 5422 >>> g.addTransition('three', '<', 'one') 5423 >>> g.tagDecision('three', 'hi') 5424 >>> g.annotateDecision('three', 'note') 5425 >>> g.destination('one', '>') 5426 1 5427 >>> g.destination('three', '<') 5428 0 5429 >>> g.renameDecision('three', 'two') 5430 >>> g.resolveDecision('one') 5431 0 5432 >>> g.resolveDecision('two') 5433 1 5434 >>> g.resolveDecision('three') 5435 Traceback (most recent call last): 5436 ... 5437 exploration.core.MissingDecisionError... 5438 >>> g.destination('one', '>') 5439 1 5440 >>> g.nameFor(1) 5441 'two' 5442 >>> g.getDecision('three') is None 5443 True 5444 >>> g.destination('two', '<') 5445 0 5446 >>> g.decisionTags('two') 5447 {'hi': 1} 5448 >>> g.decisionAnnotations('two') 5449 ['note'] 5450 """ 5451 dID = self.resolveDecision(decision) 5452 5453 if newName in self.nameLookup and WARN_OF_NAME_COLLISIONS: 5454 warnings.warn( 5455 ( 5456 f"Can't rename {self.identityOf(decision)} as" 5457 f" {newName!r} because a decision with that name" 5458 f" already exists." 5459 ), 5460 DecisionCollisionWarning 5461 ) 5462 5463 # Update name in node 5464 oldName = self.nodes[dID]['name'] 5465 self.nodes[dID]['name'] = newName 5466 5467 # Update nameLookup entries 5468 oldNL = self.nameLookup[oldName] 5469 oldNL.remove(dID) 5470 if len(oldNL) == 0: 5471 self.nameLookup.pop(oldName) 5472 self.nameLookup.setdefault(newName, []).append(dID) 5473 5474 def mergeTransitions( 5475 self, 5476 fromDecision: base.AnyDecisionSpecifier, 5477 merge: base.Transition, 5478 mergeInto: base.Transition, 5479 mergeReciprocal=True 5480 ) -> None: 5481 """ 5482 Given a decision and two transitions that start at that decision, 5483 merges the first transition into the second transition, combining 5484 their transition properties (using `mergeProperties`) and 5485 deleting the first transition. By default any reciprocal of the 5486 first transition is also merged into the reciprocal of the 5487 second, although you can set `mergeReciprocal` to `False` to 5488 disable this in which case the old reciprocal will lose its 5489 reciprocal relationship, even if the transition that was merged 5490 into does not have a reciprocal. 5491 5492 If the two names provided are the same, nothing will happen. 5493 5494 If the two transitions do not share the same destination, they 5495 cannot be merged, and an `InvalidDestinationError` will result. 5496 Use `retargetTransition` beforehand to ensure that they do if you 5497 want to merge transitions with different destinations. 5498 5499 A `MissingDecisionError` or `MissingTransitionError` will result 5500 if the decision or either transition does not exist. 5501 5502 If merging reciprocal properties was requested and the first 5503 transition does not have a reciprocal, then no reciprocal 5504 properties change. However, if the second transition does not 5505 have a reciprocal and the first does, the first transition's 5506 reciprocal will be set to the reciprocal of the second 5507 transition, and that transition will not be deleted as usual. 5508 5509 ## Example 5510 5511 >>> g = DecisionGraph() 5512 >>> g.addDecision('A') 5513 0 5514 >>> g.addDecision('B') 5515 1 5516 >>> g.addTransition('A', 'up', 'B') 5517 >>> g.addTransition('B', 'down', 'A') 5518 >>> g.setReciprocal('A', 'up', 'down') 5519 >>> # Merging a transition with no reciprocal 5520 >>> g.addTransition('A', 'up2', 'B') 5521 >>> g.mergeTransitions('A', 'up2', 'up') 5522 >>> g.getDestination('A', 'up2') is None 5523 True 5524 >>> g.getDestination('A', 'up') 5525 1 5526 >>> # Merging a transition with a reciprocal & tags 5527 >>> g.addTransition('A', 'up2', 'B') 5528 >>> g.addTransition('B', 'down2', 'A') 5529 >>> g.setReciprocal('A', 'up2', 'down2') 5530 >>> g.tagTransition('A', 'up2', 'one') 5531 >>> g.tagTransition('B', 'down2', 'two') 5532 >>> g.mergeTransitions('B', 'down2', 'down') 5533 >>> g.getDestination('A', 'up2') is None 5534 True 5535 >>> g.getDestination('A', 'up') 5536 1 5537 >>> g.getDestination('B', 'down2') is None 5538 True 5539 >>> g.getDestination('B', 'down') 5540 0 5541 >>> # Merging requirements uses ReqAll (i.e., 'and' logic) 5542 >>> g.addTransition('A', 'up2', 'B') 5543 >>> g.setTransitionProperties( 5544 ... 'A', 5545 ... 'up2', 5546 ... requirement=base.ReqCapability('dash') 5547 ... ) 5548 >>> g.setTransitionProperties('A', 'up', 5549 ... requirement=base.ReqCapability('slide')) 5550 >>> g.mergeTransitions('A', 'up2', 'up') 5551 >>> g.getDestination('A', 'up2') is None 5552 True 5553 >>> repr(g.getTransitionRequirement('A', 'up')) 5554 "ReqAll([ReqCapability('dash'), ReqCapability('slide')])" 5555 >>> # Errors if destinations differ, or if something is missing 5556 >>> g.mergeTransitions('A', 'down', 'up') 5557 Traceback (most recent call last): 5558 ... 5559 exploration.core.MissingTransitionError... 5560 >>> g.mergeTransitions('Z', 'one', 'two') 5561 Traceback (most recent call last): 5562 ... 5563 exploration.core.MissingDecisionError... 5564 >>> g.addDecision('C') 5565 2 5566 >>> g.addTransition('A', 'down', 'C') 5567 >>> g.mergeTransitions('A', 'down', 'up') 5568 Traceback (most recent call last): 5569 ... 5570 exploration.core.InvalidDestinationError... 5571 >>> # Merging a reciprocal onto an edge that doesn't have one 5572 >>> g.addTransition('A', 'down2', 'C') 5573 >>> g.addTransition('C', 'up2', 'A') 5574 >>> g.setReciprocal('A', 'down2', 'up2') 5575 >>> g.tagTransition('C', 'up2', 'narrow') 5576 >>> g.getReciprocal('A', 'down') is None 5577 True 5578 >>> g.mergeTransitions('A', 'down2', 'down') 5579 >>> g.getDestination('A', 'down2') is None 5580 True 5581 >>> g.getDestination('A', 'down') 5582 2 5583 >>> g.getDestination('C', 'up2') 5584 0 5585 >>> g.getReciprocal('A', 'down') 5586 'up2' 5587 >>> g.getReciprocal('C', 'up2') 5588 'down' 5589 >>> g.transitionTags('C', 'up2') 5590 {'narrow': 1} 5591 >>> # Merging without a reciprocal 5592 >>> g.addTransition('C', 'up', 'A') 5593 >>> g.mergeTransitions('C', 'up2', 'up', mergeReciprocal=False) 5594 >>> g.getDestination('C', 'up2') is None 5595 True 5596 >>> g.getDestination('C', 'up') 5597 0 5598 >>> g.transitionTags('C', 'up') # tag gets merged 5599 {'narrow': 1} 5600 >>> g.getDestination('A', 'down') 5601 2 5602 >>> g.getReciprocal('A', 'down') is None 5603 True 5604 >>> g.getReciprocal('C', 'up') is None 5605 True 5606 >>> # Merging w/ normal reciprocals 5607 >>> g.addDecision('D') 5608 3 5609 >>> g.addDecision('E') 5610 4 5611 >>> g.addTransition('D', 'up', 'E', 'return') 5612 >>> g.addTransition('E', 'down', 'D') 5613 >>> g.mergeTransitions('E', 'return', 'down') 5614 >>> g.getDestination('D', 'up') 5615 4 5616 >>> g.getDestination('E', 'down') 5617 3 5618 >>> g.getDestination('E', 'return') is None 5619 True 5620 >>> g.getReciprocal('D', 'up') 5621 'down' 5622 >>> g.getReciprocal('E', 'down') 5623 'up' 5624 >>> # Merging w/ weird reciprocals 5625 >>> g.addTransition('E', 'return', 'D') 5626 >>> g.setReciprocal('E', 'return', 'up', setBoth=False) 5627 >>> g.getReciprocal('D', 'up') 5628 'down' 5629 >>> g.getReciprocal('E', 'down') 5630 'up' 5631 >>> g.getReciprocal('E', 'return') # shared 5632 'up' 5633 >>> g.mergeTransitions('E', 'return', 'down') 5634 >>> g.getDestination('D', 'up') 5635 4 5636 >>> g.getDestination('E', 'down') 5637 3 5638 >>> g.getDestination('E', 'return') is None 5639 True 5640 >>> g.getReciprocal('D', 'up') 5641 'down' 5642 >>> g.getReciprocal('E', 'down') 5643 'up' 5644 """ 5645 fromID = self.resolveDecision(fromDecision) 5646 5647 # Short-circuit in the no-op case 5648 if merge == mergeInto: 5649 return 5650 5651 # These lines will raise a MissingDecisionError or 5652 # MissingTransitionError if needed 5653 dest1 = self.destination(fromID, merge) 5654 dest2 = self.destination(fromID, mergeInto) 5655 5656 if dest1 != dest2: 5657 raise InvalidDestinationError( 5658 f"Cannot merge transition {merge!r} into transition" 5659 f" {mergeInto!r} from decision" 5660 f" {self.identityOf(fromDecision)} because their" 5661 f" destinations are different ({self.identityOf(dest1)}" 5662 f" and {self.identityOf(dest2)}).\nNote: you can use" 5663 f" `retargetTransition` to change the destination of a" 5664 f" transition." 5665 ) 5666 5667 # Find and the transition properties 5668 props1 = self.getTransitionProperties(fromID, merge) 5669 props2 = self.getTransitionProperties(fromID, mergeInto) 5670 merged = mergeProperties(props1, props2) 5671 # Note that this doesn't change the reciprocal: 5672 self.setTransitionProperties(fromID, mergeInto, **merged) 5673 5674 # Merge the reciprocal properties if requested 5675 # Get reciprocal to merge into 5676 reciprocal = self.getReciprocal(fromID, mergeInto) 5677 # Get reciprocal that needs cleaning up 5678 altReciprocal = self.getReciprocal(fromID, merge) 5679 # If the reciprocal to be merged actually already was the 5680 # reciprocal to merge into, there's nothing to do here 5681 if altReciprocal != reciprocal: 5682 if not mergeReciprocal: 5683 # In this case, we sever the reciprocal relationship if 5684 # there is a reciprocal 5685 if altReciprocal is not None: 5686 self.setReciprocal(dest1, altReciprocal, None) 5687 # By default setBoth takes care of the other half 5688 else: 5689 # In this case, we try to merge reciprocals 5690 # If altReciprocal is None, we don't need to do anything 5691 if altReciprocal is not None: 5692 # Was there already a reciprocal or not? 5693 if reciprocal is None: 5694 # altReciprocal becomes the new reciprocal and is 5695 # not deleted 5696 self.setReciprocal( 5697 fromID, 5698 mergeInto, 5699 altReciprocal 5700 ) 5701 else: 5702 # merge reciprocal properties 5703 props1 = self.getTransitionProperties( 5704 dest1, 5705 altReciprocal 5706 ) 5707 props2 = self.getTransitionProperties( 5708 dest2, 5709 reciprocal 5710 ) 5711 merged = mergeProperties(props1, props2) 5712 self.setTransitionProperties( 5713 dest1, 5714 reciprocal, 5715 **merged 5716 ) 5717 5718 # delete the old reciprocal transition 5719 self.remove_edge(dest1, fromID, altReciprocal) 5720 5721 # Delete the old transition (reciprocal deletion/severance is 5722 # handled above if necessary) 5723 self.remove_edge(fromID, dest1, merge) 5724 5725 def isConfirmed(self, decision: base.AnyDecisionSpecifier) -> bool: 5726 """ 5727 Returns `True` or `False` depending on whether or not the 5728 specified decision has been confirmed. Uses the presence or 5729 absence of the 'unconfirmed' tag to determine this. 5730 5731 Note: 'unconfirmed' is used instead of 'confirmed' so that large 5732 graphs with many confirmed nodes will be smaller when saved. 5733 """ 5734 dID = self.resolveDecision(decision) 5735 5736 return 'unconfirmed' not in self.nodes[dID]['tags'] 5737 5738 def replaceUnconfirmed( 5739 self, 5740 fromDecision: base.AnyDecisionSpecifier, 5741 transition: base.Transition, 5742 connectTo: Optional[base.AnyDecisionSpecifier] = None, 5743 reciprocal: Optional[base.Transition] = None, 5744 requirement: Optional[base.Requirement] = None, 5745 applyConsequence: Optional[base.Consequence] = None, 5746 placeInZone: Optional[base.Zone] = None, 5747 forceNew: bool = False, 5748 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 5749 annotations: Optional[List[base.Annotation]] = None, 5750 revRequires: Optional[base.Requirement] = None, 5751 revConsequence: Optional[base.Consequence] = None, 5752 revTags: Optional[Dict[base.Tag, base.TagValue]] = None, 5753 revAnnotations: Optional[List[base.Annotation]] = None, 5754 decisionTags: Optional[Dict[base.Tag, base.TagValue]] = None, 5755 decisionAnnotations: Optional[List[base.Annotation]] = None 5756 ) -> Tuple[ 5757 Dict[base.Transition, base.Transition], 5758 Dict[base.Transition, base.Transition] 5759 ]: 5760 """ 5761 Given a decision and an edge name in that decision, where the 5762 named edge leads to a decision with an unconfirmed exploration 5763 state (see `isConfirmed`), renames the unexplored decision on 5764 the other end of that edge using the given `connectTo` name, or 5765 if a decision using that name already exists, merges the 5766 unexplored decision into that decision. If `connectTo` is a 5767 `DecisionSpecifier` whose target doesn't exist, it will be 5768 treated as just a name, but if it's an ID and it doesn't exist, 5769 you'll get a `MissingDecisionError`. If a `reciprocal` is provided, 5770 a reciprocal edge will be added using that name connecting the 5771 `connectTo` decision back to the original decision. If this 5772 transition already exists, it must also point to a node which is 5773 also unexplored, and which will also be merged into the 5774 `fromDecision` node. 5775 5776 If `connectTo` is not given (or is set to `None` explicitly) 5777 then the name of the unexplored decision will not be changed, 5778 unless that name has the form `'_u.-n-'` where `-n-` is a positive 5779 integer (i.e., the form given to automatically-named unknown 5780 nodes). In that case, the name will be changed to `'_x.-n-'` using 5781 the same number, or a higher number if that name is already taken. 5782 5783 If the destination is being renamed or if the destination's 5784 exploration state counts as unexplored, the exploration state of 5785 the destination will be set to 'exploring'. 5786 5787 If a `placeInZone` is specified, the destination will be placed 5788 directly into that zone (even if it already existed and has zone 5789 information), and it will be removed from any other zones it had 5790 been a direct member of. If `placeInZone` is set to 5791 `base.DefaultZone`, then the destination will be placed into 5792 each zone which is a direct parent of the origin, but only if 5793 the destination is not an already-explored existing decision AND 5794 it is not already in any zones (in those cases no zone changes 5795 are made). This will also remove it from any previous zones it 5796 had been a part of. If `placeInZone` is left as `None` (the 5797 default) no zone changes are made. 5798 5799 If `placeInZone` is specified and that zone didn't already exist, 5800 it will be created as a new level-0 zone and will be added as a 5801 sub-zone of each zone that's a direct parent of any level-0 zone 5802 that the origin is a member of. 5803 5804 If `forceNew` is specified, then the destination will just be 5805 renamed, even if another decision with the same name already 5806 exists. It's an error to use `forceNew` with a decision ID as 5807 the destination. 5808 5809 Any additional edges pointing to or from the unknown node(s) 5810 being replaced will also be re-targeted at the now-discovered 5811 known destination(s) if necessary. These edges will retain their 5812 reciprocal names, or if this would cause a name clash, they will 5813 be renamed with a suffix (see `retargetTransition`). 5814 5815 The return value is a pair of dictionaries mapping old names to 5816 new ones that just includes the names which were changed. The 5817 first dictionary contains renamed transitions that are outgoing 5818 from the new destination node (which used to be outgoing from 5819 the unexplored node). The second dictionary contains renamed 5820 transitions that are outgoing from the source node (which used 5821 to be outgoing from the unexplored node attached to the 5822 reciprocal transition; if there was no reciprocal transition 5823 specified then this will always be an empty dictionary). 5824 5825 An `ExplorationStatusError` will be raised if the destination 5826 of the specified transition counts as visited (see 5827 `hasBeenVisited`). An `ExplorationStatusError` will also be 5828 raised if the `connectTo`'s `reciprocal` transition does not lead 5829 to an unconfirmed decision (it's okay if this second transition 5830 doesn't exist). A `TransitionCollisionError` will be raised if 5831 the unconfirmed destination decision already has an outgoing 5832 transition with the specified `reciprocal` which does not lead 5833 back to the `fromDecision`. 5834 5835 The transition properties (requirement, consequences, tags, 5836 and/or annotations) of the replaced transition will be copied 5837 over to the new transition. Transition properties from the 5838 reciprocal transition will also be copied for the newly created 5839 reciprocal edge. Properties for any additional edges to/from the 5840 unknown node will also be copied. 5841 5842 Also, any transition properties on existing forward or reciprocal 5843 edges from the destination node with the indicated reverse name 5844 will be merged with those from the target transition. Note that 5845 this merging process may introduce corruption of complex 5846 transition consequences. TODO: Fix that! 5847 5848 Any tags and annotations are added to copied tags/annotations, 5849 but specified requirements, and/or consequences will replace 5850 previous requirements/consequences, rather than being added to 5851 them. 5852 5853 ## Example 5854 5855 >>> g = DecisionGraph() 5856 >>> g.addDecision('A') 5857 0 5858 >>> g.addUnexploredEdge('A', 'up') 5859 1 5860 >>> g.destination('A', 'up') 5861 1 5862 >>> g.destination('_u.0', 'return') 5863 0 5864 >>> g.replaceUnconfirmed('A', 'up', 'B', 'down') 5865 ({}, {}) 5866 >>> g.destination('A', 'up') 5867 1 5868 >>> g.nameFor(1) 5869 'B' 5870 >>> g.destination('B', 'down') 5871 0 5872 >>> g.getDestination('B', 'return') is None 5873 True 5874 >>> '_u.0' in g.nameLookup 5875 False 5876 >>> g.getReciprocal('A', 'up') 5877 'down' 5878 >>> g.getReciprocal('B', 'down') 5879 'up' 5880 >>> # Two unexplored edges to the same node: 5881 >>> g.addDecision('C') 5882 2 5883 >>> g.addTransition('B', 'next', 'C') 5884 >>> g.addTransition('C', 'prev', 'B') 5885 >>> g.setReciprocal('B', 'next', 'prev') 5886 >>> g.addUnexploredEdge('A', 'next', 'D', 'prev') 5887 3 5888 >>> g.addTransition('C', 'down', 'D') 5889 >>> g.addTransition('D', 'up', 'C') 5890 >>> g.setReciprocal('C', 'down', 'up') 5891 >>> g.replaceUnconfirmed('C', 'down') 5892 ({}, {}) 5893 >>> g.destination('C', 'down') 5894 3 5895 >>> g.destination('A', 'next') 5896 3 5897 >>> g.destinationsFrom('D') 5898 {'prev': 0, 'up': 2} 5899 >>> g.decisionTags('D') 5900 {} 5901 >>> # An unexplored transition which turns out to connect to a 5902 >>> # known decision, with name collisions 5903 >>> g.addUnexploredEdge('D', 'next', reciprocal='prev') 5904 4 5905 >>> g.tagDecision('_u.2', 'wet') 5906 >>> g.addUnexploredEdge('B', 'next', reciprocal='prev') # edge taken 5907 Traceback (most recent call last): 5908 ... 5909 exploration.core.TransitionCollisionError... 5910 >>> g.addUnexploredEdge('A', 'prev', reciprocal='next') 5911 5 5912 >>> g.tagDecision('_u.3', 'dry') 5913 >>> # Add transitions that will collide when merged 5914 >>> g.addUnexploredEdge('_u.2', 'up') # collides with A/up 5915 6 5916 >>> g.addUnexploredEdge('_u.3', 'prev') # collides with D/prev 5917 7 5918 >>> g.getReciprocal('A', 'prev') 5919 'next' 5920 >>> g.replaceUnconfirmed('A', 'prev', 'D', 'next') # two gone 5921 ({'prev': 'prev.1'}, {'up': 'up.1'}) 5922 >>> g.destination('A', 'prev') 5923 3 5924 >>> g.destination('D', 'next') 5925 0 5926 >>> g.getReciprocal('A', 'prev') 5927 'next' 5928 >>> g.getReciprocal('D', 'next') 5929 'prev' 5930 >>> # Note that further unexplored structures are NOT merged 5931 >>> # even if they match against existing structures... 5932 >>> g.destination('A', 'up.1') 5933 6 5934 >>> g.destination('D', 'prev.1') 5935 7 5936 >>> '_u.2' in g.nameLookup 5937 False 5938 >>> '_u.3' in g.nameLookup 5939 False 5940 >>> g.decisionTags('D') # tags are merged 5941 {'dry': 1} 5942 >>> g.decisionTags('A') 5943 {'wet': 1} 5944 >>> # Auto-renaming an anonymous unexplored node 5945 >>> g.addUnexploredEdge('B', 'out') 5946 8 5947 >>> g.replaceUnconfirmed('B', 'out') 5948 ({}, {}) 5949 >>> '_u.6' in g 5950 False 5951 >>> g.destination('B', 'out') 5952 8 5953 >>> g.nameFor(8) 5954 '_x.6' 5955 >>> g.destination('_x.6', 'return') 5956 1 5957 >>> # Placing a node into a zone 5958 >>> g.addUnexploredEdge('B', 'through') 5959 9 5960 >>> g.getDecision('E') is None 5961 True 5962 >>> g.replaceUnconfirmed( 5963 ... 'B', 5964 ... 'through', 5965 ... 'E', 5966 ... 'back', 5967 ... placeInZone='Zone' 5968 ... ) 5969 ({}, {}) 5970 >>> g.getDecision('E') 5971 9 5972 >>> g.destination('B', 'through') 5973 9 5974 >>> g.destination('E', 'back') 5975 1 5976 >>> g.zoneParents(9) 5977 {'Zone'} 5978 >>> g.addUnexploredEdge('E', 'farther') 5979 10 5980 >>> g.replaceUnconfirmed( 5981 ... 'E', 5982 ... 'farther', 5983 ... 'F', 5984 ... 'closer', 5985 ... placeInZone=base.DefaultZone 5986 ... ) 5987 ({}, {}) 5988 >>> g.destination('E', 'farther') 5989 10 5990 >>> g.destination('F', 'closer') 5991 9 5992 >>> g.zoneParents(10) 5993 {'Zone'} 5994 >>> g.addUnexploredEdge('F', 'backwards', placeInZone='Enoz') 5995 11 5996 >>> g.replaceUnconfirmed( 5997 ... 'F', 5998 ... 'backwards', 5999 ... 'G', 6000 ... 'forwards', 6001 ... placeInZone=base.DefaultZone 6002 ... ) 6003 ({}, {}) 6004 >>> g.destination('F', 'backwards') 6005 11 6006 >>> g.destination('G', 'forwards') 6007 10 6008 >>> g.zoneParents(11) # not changed since it already had a zone 6009 {'Enoz'} 6010 >>> # TODO: forceNew example 6011 """ 6012 6013 # Defaults 6014 if tags is None: 6015 tags = {} 6016 if annotations is None: 6017 annotations = [] 6018 if revTags is None: 6019 revTags = {} 6020 if revAnnotations is None: 6021 revAnnotations = [] 6022 if decisionTags is None: 6023 decisionTags = {} 6024 if decisionAnnotations is None: 6025 decisionAnnotations = [] 6026 6027 # Resolve source 6028 fromID = self.resolveDecision(fromDecision) 6029 6030 # Figure out destination decision 6031 oldUnexplored = self.destination(fromID, transition) 6032 if self.isConfirmed(oldUnexplored): 6033 raise ExplorationStatusError( 6034 f"Transition {transition!r} from" 6035 f" {self.identityOf(fromDecision)} does not lead to an" 6036 f" unconfirmed decision (it leads to" 6037 f" {self.identityOf(oldUnexplored)} which is not tagged" 6038 f" 'unconfirmed')." 6039 ) 6040 6041 # Resolve destination 6042 newName: Optional[base.DecisionName] = None 6043 connectID: Optional[base.DecisionID] = None 6044 if forceNew: 6045 if isinstance(connectTo, base.DecisionID): 6046 raise TypeError( 6047 f"connectTo cannot be a decision ID when forceNew" 6048 f" is True. Got: {self.identityOf(connectTo)}" 6049 ) 6050 elif isinstance(connectTo, base.DecisionSpecifier): 6051 newName = connectTo.name 6052 elif isinstance(connectTo, base.DecisionName): 6053 newName = connectTo 6054 elif connectTo is None: 6055 oldName = self.nameFor(oldUnexplored) 6056 if ( 6057 oldName.startswith('_u.') 6058 and oldName[3:].isdigit() 6059 ): 6060 newName = utils.uniqueName('_x.' + oldName[3:], self) 6061 else: 6062 newName = oldName 6063 else: 6064 raise TypeError( 6065 f"Invalid connectTo value: {connectTo!r}" 6066 ) 6067 elif connectTo is not None: 6068 try: 6069 connectID = self.resolveDecision(connectTo) 6070 # leave newName as None 6071 except MissingDecisionError: 6072 if isinstance(connectTo, int): 6073 raise 6074 elif isinstance(connectTo, base.DecisionSpecifier): 6075 newName = connectTo.name 6076 # The domain & zone are ignored here 6077 else: # Must just be a string 6078 assert isinstance(connectTo, str) 6079 newName = connectTo 6080 else: 6081 # If connectTo name wasn't specified, use current name of 6082 # unknown node unless it's a default name 6083 oldName = self.nameFor(oldUnexplored) 6084 if ( 6085 oldName.startswith('_u.') 6086 and oldName[3:].isdigit() 6087 ): 6088 newName = utils.uniqueName('_x.' + oldName[3:], self) 6089 else: 6090 newName = oldName 6091 6092 # One or the other should be valid at this point 6093 assert connectID is not None or newName is not None 6094 6095 # Check that the old unknown doesn't have a reciprocal edge that 6096 # would collide with the specified return edge 6097 if reciprocal is not None: 6098 revFromUnknown = self.getDestination(oldUnexplored, reciprocal) 6099 if revFromUnknown not in (None, fromID): 6100 raise TransitionCollisionError( 6101 f"Transition {reciprocal!r} from" 6102 f" {self.identityOf(oldUnexplored)} exists and does" 6103 f" not lead back to {self.identityOf(fromDecision)}" 6104 f" (it leads to {self.identityOf(revFromUnknown)})." 6105 ) 6106 6107 # Remember old reciprocal edge for future merging in case 6108 # it's not reciprocal 6109 oldReciprocal = self.getReciprocal(fromID, transition) 6110 6111 # Apply any new tags or annotations, or create a new node 6112 needsZoneInfo = False 6113 if connectID is not None: 6114 # Before applying tags, check if we need to error out 6115 # because of a reciprocal edge that points to a known 6116 # destination: 6117 if reciprocal is not None: 6118 otherOldUnknown: Optional[ 6119 base.DecisionID 6120 ] = self.getDestination( 6121 connectID, 6122 reciprocal 6123 ) 6124 if ( 6125 otherOldUnknown is not None 6126 and self.isConfirmed(otherOldUnknown) 6127 ): 6128 raise ExplorationStatusError( 6129 f"Reciprocal transition {reciprocal!r} from" 6130 f" {self.identityOf(connectTo)} does not lead" 6131 f" to an unconfirmed decision (it leads to" 6132 f" {self.identityOf(otherOldUnknown)})." 6133 ) 6134 self.tagDecision(connectID, decisionTags) 6135 self.annotateDecision(connectID, decisionAnnotations) 6136 # Still needs zone info if the place we're connecting to was 6137 # unconfirmed up until now, since unconfirmed nodes don't 6138 # normally get zone info when they're created. 6139 if not self.isConfirmed(connectID): 6140 needsZoneInfo = True 6141 6142 # First, merge the old unknown with the connectTo node... 6143 destRenames = self.mergeDecisions( 6144 oldUnexplored, 6145 connectID, 6146 errorOnNameColision=False 6147 ) 6148 else: 6149 needsZoneInfo = True 6150 if len(self.zoneParents(oldUnexplored)) > 0: 6151 needsZoneInfo = False 6152 assert newName is not None 6153 self.renameDecision(oldUnexplored, newName) 6154 connectID = oldUnexplored 6155 # In this case there can't be an other old unknown 6156 otherOldUnknown = None 6157 destRenames = {} # empty 6158 6159 # Check for domain mismatch to stifle zone updates: 6160 fromDomain = self.domainFor(fromID) 6161 if connectID is None: 6162 destDomain = self.domainFor(oldUnexplored) 6163 else: 6164 destDomain = self.domainFor(connectID) 6165 6166 # Stifle zone updates if there's a mismatch 6167 if fromDomain != destDomain: 6168 needsZoneInfo = False 6169 6170 # Records renames that happen at the source (from node) 6171 sourceRenames = {} # empty for now 6172 6173 assert connectID is not None 6174 6175 # Apply the new zone if there is one 6176 if placeInZone is not None: 6177 if placeInZone == base.DefaultZone: 6178 # When using DefaultZone, changes are only made for new 6179 # destinations which don't already have any zones and 6180 # which are in the same domain as the departing node: 6181 # they get placed into each zone parent of the source 6182 # decision. 6183 if needsZoneInfo: 6184 # Remove destination from all current parents 6185 removeFrom = set(self.zoneParents(connectID)) # copy 6186 for parent in removeFrom: 6187 self.removeDecisionFromZone(connectID, parent) 6188 # Add it to parents of origin 6189 for parent in self.zoneParents(fromID): 6190 self.addDecisionToZone(connectID, parent) 6191 else: 6192 placeInZone = cast(base.Zone, placeInZone) 6193 # Create the zone if it doesn't already exist 6194 if self.getZoneInfo(placeInZone) is None: 6195 self.createZone(placeInZone, 0) 6196 # Add it to each grandparent of the from decision 6197 for parent in self.zoneParents(fromID): 6198 for grandparent in self.zoneParents(parent): 6199 self.addZoneToZone(placeInZone, grandparent) 6200 # Remove destination from all current parents 6201 for parent in set(self.zoneParents(connectID)): 6202 self.removeDecisionFromZone(connectID, parent) 6203 # Add it to the specified zone 6204 self.addDecisionToZone(connectID, placeInZone) 6205 6206 # Next, if there is a reciprocal name specified, we do more... 6207 if reciprocal is not None: 6208 # Figure out what kind of merging needs to happen 6209 if otherOldUnknown is None: 6210 if revFromUnknown is None: 6211 # Just create the desired reciprocal transition, which 6212 # we know does not already exist 6213 self.addTransition(connectID, reciprocal, fromID) 6214 otherOldReciprocal = None 6215 else: 6216 # Reciprocal exists, as revFromUnknown 6217 otherOldReciprocal = None 6218 else: 6219 otherOldReciprocal = self.getReciprocal( 6220 connectID, 6221 reciprocal 6222 ) 6223 # we need to merge otherOldUnknown into our fromDecision 6224 sourceRenames = self.mergeDecisions( 6225 otherOldUnknown, 6226 fromID, 6227 errorOnNameColision=False 6228 ) 6229 # Unvisited tag after merge only if both were 6230 6231 # No matter what happened we ensure the reciprocal 6232 # relationship is set up: 6233 self.setReciprocal(fromID, transition, reciprocal) 6234 6235 # Now we might need to merge some transitions: 6236 # - Any reciprocal of the target transition should be merged 6237 # with reciprocal (if it was already reciprocal, that's a 6238 # no-op). 6239 # - Any reciprocal of the reciprocal transition from the target 6240 # node (leading to otherOldUnknown) should be merged with 6241 # the target transition, even if it shared a name and was 6242 # renamed as a result. 6243 # - If reciprocal was renamed during the initial merge, those 6244 # transitions should be merged. 6245 6246 # Merge old reciprocal into reciprocal 6247 if oldReciprocal is not None: 6248 oldRev = destRenames.get(oldReciprocal, oldReciprocal) 6249 if self.getDestination(connectID, oldRev) is not None: 6250 # Note that we don't want to auto-merge the reciprocal, 6251 # which is the target transition 6252 self.mergeTransitions( 6253 connectID, 6254 oldRev, 6255 reciprocal, 6256 mergeReciprocal=False 6257 ) 6258 # Remove it from the renames map 6259 if oldReciprocal in destRenames: 6260 del destRenames[oldReciprocal] 6261 6262 # Merge reciprocal reciprocal from otherOldUnknown 6263 if otherOldReciprocal is not None: 6264 otherOldRev = sourceRenames.get( 6265 otherOldReciprocal, 6266 otherOldReciprocal 6267 ) 6268 # Note that the reciprocal is reciprocal, which we don't 6269 # need to merge 6270 self.mergeTransitions( 6271 fromID, 6272 otherOldRev, 6273 transition, 6274 mergeReciprocal=False 6275 ) 6276 # Remove it from the renames map 6277 if otherOldReciprocal in sourceRenames: 6278 del sourceRenames[otherOldReciprocal] 6279 6280 # Merge any renamed reciprocal onto reciprocal 6281 if reciprocal in destRenames: 6282 extraRev = destRenames[reciprocal] 6283 self.mergeTransitions( 6284 connectID, 6285 extraRev, 6286 reciprocal, 6287 mergeReciprocal=False 6288 ) 6289 # Remove it from the renames map 6290 del destRenames[reciprocal] 6291 6292 # Accumulate new tags & annotations for the transitions 6293 self.tagTransition(fromID, transition, tags) 6294 self.annotateTransition(fromID, transition, annotations) 6295 6296 if reciprocal is not None: 6297 self.tagTransition(connectID, reciprocal, revTags) 6298 self.annotateTransition(connectID, reciprocal, revAnnotations) 6299 6300 # Override copied requirement/consequences for the transitions 6301 if requirement is not None: 6302 self.setTransitionRequirement( 6303 fromID, 6304 transition, 6305 requirement 6306 ) 6307 if applyConsequence is not None: 6308 self.setConsequence( 6309 fromID, 6310 transition, 6311 applyConsequence 6312 ) 6313 6314 if reciprocal is not None: 6315 if revRequires is not None: 6316 self.setTransitionRequirement( 6317 connectID, 6318 reciprocal, 6319 revRequires 6320 ) 6321 if revConsequence is not None: 6322 self.setConsequence( 6323 connectID, 6324 reciprocal, 6325 revConsequence 6326 ) 6327 6328 # Remove 'unconfirmed' tag if it was present 6329 self.untagDecision(connectID, 'unconfirmed') 6330 6331 # Final checks 6332 assert self.getDestination(fromDecision, transition) == connectID 6333 useConnect: base.AnyDecisionSpecifier 6334 useRev: Optional[str] 6335 if connectTo is None: 6336 useConnect = connectID 6337 else: 6338 useConnect = connectTo 6339 if reciprocal is None: 6340 useRev = self.getReciprocal(fromDecision, transition) 6341 else: 6342 useRev = reciprocal 6343 if useRev is not None: 6344 try: 6345 assert self.getDestination(useConnect, useRev) == fromID 6346 except AmbiguousDecisionSpecifierError: 6347 assert self.getDestination(connectID, useRev) == fromID 6348 6349 # Return our final rename dictionaries 6350 return (destRenames, sourceRenames) 6351 6352 def endingID(self, name: base.DecisionName) -> base.DecisionID: 6353 """ 6354 Returns the decision ID for the ending with the specified name. 6355 Endings are disconnected decisions in the `ENDINGS_DOMAIN`; they 6356 don't normally include any zone information. If no ending with 6357 the specified name already existed, then a new ending with that 6358 name will be created and its Decision ID will be returned. 6359 6360 If a new decision is created, it will be tagged as unconfirmed. 6361 6362 Note that endings mostly aren't special: they're normal 6363 decisions in a separate singular-focalized domain. However, some 6364 parts of the exploration and journal machinery treat them 6365 differently (in particular, taking certain actions via 6366 `advanceSituation` while any decision in the `ENDINGS_DOMAIN` is 6367 active is an error. 6368 """ 6369 # Create our new ending decision if we need to 6370 try: 6371 endID = self.resolveDecision( 6372 base.DecisionSpecifier(ENDINGS_DOMAIN, None, name) 6373 ) 6374 except MissingDecisionError: 6375 # Create a new decision for the ending 6376 endID = self.addDecision(name, domain=ENDINGS_DOMAIN) 6377 # Tag it as unconfirmed 6378 self.tagDecision(endID, 'unconfirmed') 6379 6380 return endID 6381 6382 def triggerGroupID(self, name: base.DecisionName) -> base.DecisionID: 6383 """ 6384 Given the name of a trigger group, returns the ID of the special 6385 node representing that trigger group in the `TRIGGERS_DOMAIN`. 6386 If the specified group didn't already exist, it will be created. 6387 6388 Trigger group decisions are not special: they just exist in a 6389 separate spreading-focalized domain and have a few API methods to 6390 access them, but all the normal decision-related API methods 6391 still work. Their intended use is for sets of global triggers, 6392 by attaching actions with the 'trigger' tag to them and then 6393 activating or deactivating them as needed. 6394 """ 6395 result = self.getDecision( 6396 base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name) 6397 ) 6398 if result is None: 6399 return self.addDecision(name, domain=TRIGGERS_DOMAIN) 6400 else: 6401 return result 6402 6403 @staticmethod 6404 def example(which: Literal['simple', 'abc']) -> 'DecisionGraph': 6405 """ 6406 Returns one of a number of example decision graphs, depending on 6407 the string given. It returns a fresh copy each time. The graphs 6408 are: 6409 6410 - 'simple': Three nodes named 'A', 'B', and 'C' with IDs 0, 1, 6411 and 2, each connected to the next in the sequence by a 6412 'next' transition with reciprocal 'prev'. In other words, a 6413 simple little triangle. There are no tags, annotations, 6414 requirements, consequences, mechanisms, or equivalences. 6415 - 'abc': A more complicated 3-node setup that introduces a 6416 little bit of everything. In this graph, we have the same 6417 three nodes, but different transitions: 6418 6419 * From A you can go 'left' to B with reciprocal 'right'. 6420 * From A you can also go 'up_left' to B with reciprocal 6421 'up_right'. These transitions both require the 6422 'grate' mechanism (which is at decision A) to be in 6423 state 'open'. 6424 * From A you can go 'down' to C with reciprocal 'up'. 6425 6426 (In this graph, B and C are not directly connected to each 6427 other.) 6428 6429 The graph has two level-0 zones 'zoneA' and 'zoneB', along 6430 with a level-1 zone 'upZone'. Decisions A and C are in 6431 zoneA while B is in zoneB; zoneA is in upZone, but zoneB is 6432 not. 6433 6434 The decision A has annotation: 6435 6436 'This is a multi-word "annotation."' 6437 6438 The transition 'down' from A has annotation: 6439 6440 "Transition 'annotation.'" 6441 6442 Decision B has tags 'b' with value 1 and 'tag2' with value 6443 '"value"'. 6444 6445 Decision C has tag 'aw"ful' with value "ha'ha'". 6446 6447 Transition 'up' from C has tag 'fast' with value 1. 6448 6449 At decision C there are actions 'grab_helmet' and 6450 'pull_lever'. 6451 6452 The 'grab_helmet' transition requires that you don't have 6453 the 'helmet' capability, and gives you that capability, 6454 deactivating with delay 3. 6455 6456 The 'pull_lever' transition requires that you do have the 6457 'helmet' capability, and takes away that capability, but it 6458 also gives you 1 token, and if you have 2 tokens (before 6459 getting the one extra), it sets the 'grate' mechanism (which 6460 is a decision A) to state 'open' and deactivates. 6461 6462 The graph has an equivalence: having the 'helmet' capability 6463 satisfies requirements for the 'grate' mechanism to be in the 6464 'open' state. 6465 6466 """ 6467 result = DecisionGraph() 6468 if which == 'simple': 6469 result.addDecision('A') # id 0 6470 result.addDecision('B') # id 1 6471 result.addDecision('C') # id 2 6472 result.addTransition('A', 'next', 'B', 'prev') 6473 result.addTransition('B', 'next', 'C', 'prev') 6474 result.addTransition('C', 'next', 'A', 'prev') 6475 elif which == 'abc': 6476 result.addDecision('A') # id 0 6477 result.addDecision('B') # id 1 6478 result.addDecision('C') # id 2 6479 result.createZone('zoneA', 0) 6480 result.createZone('zoneB', 0) 6481 result.createZone('upZone', 1) 6482 result.addZoneToZone('zoneA', 'upZone') 6483 result.addDecisionToZone('A', 'zoneA') 6484 result.addDecisionToZone('B', 'zoneB') 6485 result.addDecisionToZone('C', 'zoneA') 6486 result.addTransition('A', 'left', 'B', 'right') 6487 result.addTransition('A', 'up_left', 'B', 'up_right') 6488 result.addTransition('A', 'down', 'C', 'up') 6489 result.setTransitionRequirement( 6490 'A', 6491 'up_left', 6492 base.ReqMechanism('grate', 'open') 6493 ) 6494 result.setTransitionRequirement( 6495 'B', 6496 'up_right', 6497 base.ReqMechanism('grate', 'open') 6498 ) 6499 result.annotateDecision('A', 'This is a multi-word "annotation."') 6500 result.annotateTransition('A', 'down', "Transition 'annotation.'") 6501 result.tagDecision('B', 'b') 6502 result.tagDecision('B', 'tag2', '"value"') 6503 result.tagDecision('C', 'aw"ful', "ha'ha") 6504 result.tagTransition('C', 'up', 'fast') 6505 result.addMechanism('grate', 'A') 6506 result.addAction( 6507 'C', 6508 'grab_helmet', 6509 base.ReqNot(base.ReqCapability('helmet')), 6510 [ 6511 base.effect(gain='helmet'), 6512 base.effect(deactivate=True, delay=3) 6513 ] 6514 ) 6515 result.addAction( 6516 'C', 6517 'pull_lever', 6518 base.ReqCapability('helmet'), 6519 [ 6520 base.effect(lose='helmet'), 6521 base.effect(gain=('token', 1)), 6522 base.condition( 6523 base.ReqTokens('token', 2), 6524 [ 6525 base.effect(set=('grate', 'open')), 6526 base.effect(deactivate=True) 6527 ] 6528 ) 6529 ] 6530 ) 6531 result.addEquivalence( 6532 base.ReqCapability('helmet'), 6533 (0, 'open') 6534 ) 6535 else: 6536 raise ValueError(f"Invalid example name: {which!r}") 6537 6538 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
(Transition
s) 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 Annotation
s associated with them that
include extra details about the explorer's perception of the
situation. They can also have Tag
s, 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.
420 def __init__(self) -> None: 421 super().__init__() 422 423 self.zones: Dict[base.Zone, base.ZoneInfo] = {} 424 """ 425 Mapping from zone names to zone info 426 """ 427 428 self.unknownCount: int = 0 429 """ 430 Number of unknown decisions that have been created (not number 431 of current unknown decisions, which is likely lower) 432 """ 433 434 self.equivalences: base.Equivalences = {} 435 """ 436 See `base.Equivalences`. Determines what capabilities and/or 437 mechanism states can count as active based on alternate 438 requirements. 439 """ 440 441 self.reversionTypes: Dict[str, Set[str]] = {} 442 """ 443 This tracks shorthand reversion types. See `base.revertedState` 444 for how these are applied. Keys are custom names and values are 445 reversion type strings that `base.revertedState` could access. 446 """ 447 448 self.nextID: base.DecisionID = 0 449 """ 450 The ID to use for the next new decision we create. 451 """ 452 453 self.nextMechanismID: base.MechanismID = 0 454 """ 455 ID for the next mechanism. 456 """ 457 458 self.mechanisms: Dict[ 459 base.MechanismID, 460 Tuple[Optional[base.DecisionID], base.MechanismName] 461 ] = {} 462 """ 463 Mapping from `MechanismID`s to (`DecisionID`, `MechanismName`) 464 pairs. For global mechanisms, the `DecisionID` is None. 465 """ 466 467 self.globalMechanisms: Dict[ 468 base.MechanismName, 469 base.MechanismID 470 ] = {} 471 """ 472 Global mechanisms 473 """ 474 475 self.nameLookup: Dict[base.DecisionName, List[base.DecisionID]] = {} 476 """ 477 A cache for name -> ID lookups 478 """
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 MechanismID
s to (DecisionID
, MechanismName
)
pairs. For global mechanisms, the DecisionID
is None.
526 def listDifferences( 527 self, 528 other: 'DecisionGraph' 529 ) -> Generator[str, None, None]: 530 """ 531 Generates strings describing differences between this graph and 532 another graph. This does NOT perform graph matching, so it will 533 consider graphs different even if they have identical structures 534 but use different IDs for the nodes in those structures. 535 """ 536 if not isinstance(other, DecisionGraph): 537 yield "other is not a graph" 538 else: 539 suppress = False 540 myNodes = set(self.nodes) 541 theirNodes = set(other.nodes) 542 for n in myNodes: 543 if n not in theirNodes: 544 suppress = True 545 yield ( 546 f"other graph missing node {n}" 547 ) 548 else: 549 if self.nodes[n] != other.nodes[n]: 550 suppress = True 551 yield ( 552 f"other graph has differences at node {n}:" 553 f"\n Ours: {self.nodes[n]}" 554 f"\nTheirs: {other.nodes[n]}" 555 ) 556 myDests = self.destinationsFrom(n) 557 theirDests = other.destinationsFrom(n) 558 for tr in myDests: 559 myTo = myDests[tr] 560 if tr not in theirDests: 561 suppress = True 562 yield ( 563 f"at {self.identityOf(n)}: other graph" 564 f" missing transition {tr!r}" 565 ) 566 else: 567 theirTo = theirDests[tr] 568 if myTo != theirTo: 569 suppress = True 570 yield ( 571 f"at {self.identityOf(n)}: other" 572 f" graph transition {tr!r} leads to" 573 f" {theirTo} instead of {myTo}" 574 ) 575 else: 576 myProps = self.edges[n, myTo, tr] # type:ignore [index] # noqa 577 theirProps = other.edges[n, myTo, tr] # type:ignore [index] # noqa 578 if myProps != theirProps: 579 suppress = True 580 yield ( 581 f"at {self.identityOf(n)}: other" 582 f" graph transition {tr!r} has" 583 f" different properties:" 584 f"\n Ours: {myProps}" 585 f"\nTheirs: {theirProps}" 586 ) 587 for extra in theirNodes - myNodes: 588 suppress = True 589 yield ( 590 f"other graph has extra node {extra}" 591 ) 592 593 # TODO: Fix networkx stubs! 594 if self.graph != other.graph: # type:ignore [attr-defined] 595 suppress = True 596 yield ( 597 " different graph attributes:" # type:ignore [attr-defined] # noqa 598 f"\n Ours: {self.graph}" 599 f"\nTheirs: {other.graph}" 600 ) 601 602 # Checks any other graph data we might have missed 603 if not super().__eq__(other) and not suppress: 604 for attr in dir(self): 605 if attr.startswith('__') and attr.endswith('__'): 606 continue 607 if not hasattr(other, attr): 608 yield f"other graph missing attribute: {attr!r}" 609 else: 610 myVal = getattr(self, attr) 611 theirVal = getattr(other, attr) 612 if ( 613 myVal != theirVal 614 and not ((callable(myVal) and callable(theirVal))) 615 ): 616 yield ( 617 f"other has different val for {attr!r}:" 618 f"\n Ours: {myVal}" 619 f"\nTheirs: {theirVal}" 620 ) 621 for attr in sorted(set(dir(other)) - set(dir(self))): 622 yield f"other has extra attribute: {attr!r}" 623 yield "graph data is different" 624 # TODO: More detail here! 625 626 # Check unknown count 627 if self.unknownCount != other.unknownCount: 628 yield "unknown count is different" 629 630 # Check zones 631 if self.zones != other.zones: 632 yield "zones are different" 633 634 # Check equivalences 635 if self.equivalences != other.equivalences: 636 yield "equivalences are different" 637 638 # Check reversion types 639 if self.reversionTypes != other.reversionTypes: 640 yield "reversionTypes are different" 641 642 # Check mechanisms 643 if self.nextMechanismID != other.nextMechanismID: 644 yield "nextMechanismID is different" 645 646 if self.mechanisms != other.mechanisms: 647 yield "mechanisms are different" 648 649 if self.globalMechanisms != other.globalMechanisms: 650 yield "global mechanisms are different" 651 652 # Check names: 653 if self.nameLookup != other.nameLookup: 654 for name in self.nameLookup: 655 if name not in other.nameLookup: 656 yield ( 657 f"other graph is missing name lookup entry" 658 f" for {name!r}" 659 ) 660 else: 661 mine = self.nameLookup[name] 662 theirs = other.nameLookup[name] 663 if theirs != mine: 664 yield ( 665 f"name lookup for {name!r} is {theirs}" 666 f" instead of {mine}" 667 ) 668 extras = set(other.nameLookup) - set(self.nameLookup) 669 if extras: 670 yield ( 671 f"other graph has extra name lookup entries:" 672 f" {extras}" 673 )
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.
693 def decisionInfo(self, dID: base.DecisionID) -> DecisionInfo: 694 """ 695 Retrieves the decision info for the specified decision, as a 696 live editable dictionary. 697 698 For example: 699 700 >>> g = DecisionGraph() 701 >>> g.addDecision('A') 702 0 703 >>> g.annotateDecision('A', 'note') 704 >>> g.decisionInfo(0) 705 {'name': 'A', 'domain': 'main', 'tags': {}, 'annotations': ['note']} 706 """ 707 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']}
709 def resolveDecision( 710 self, 711 spec: base.AnyDecisionSpecifier, 712 zoneHint: Optional[base.Zone] = None, 713 domainHint: Optional[base.Domain] = None 714 ) -> base.DecisionID: 715 """ 716 Given a decision specifier returns the ID associated with that 717 decision, or raises an `AmbiguousDecisionSpecifierError` or a 718 `MissingDecisionError` if the specified decision is either 719 missing or ambiguous. Cannot handle strings that contain domain 720 and/or zone parts; use 721 `parsing.ParseFormat.parseDecisionSpecifier` to turn such 722 strings into `DecisionSpecifier`s if you need to first. 723 724 Examples: 725 726 >>> g = DecisionGraph() 727 >>> g.addDecision('A') 728 0 729 >>> g.addDecision('B') 730 1 731 >>> g.addDecision('C') 732 2 733 >>> g.addDecision('A') 734 3 735 >>> g.addDecision('B', 'menu') 736 4 737 >>> g.createZone('Z', 0) 738 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 739 annotations=[]) 740 >>> g.createZone('Z2', 0) 741 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 742 annotations=[]) 743 >>> g.createZone('Zup', 1) 744 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 745 annotations=[]) 746 >>> g.addDecisionToZone(0, 'Z') 747 >>> g.addDecisionToZone(1, 'Z') 748 >>> g.addDecisionToZone(2, 'Z') 749 >>> g.addDecisionToZone(3, 'Z2') 750 >>> g.addZoneToZone('Z', 'Zup') 751 >>> g.addZoneToZone('Z2', 'Zup') 752 >>> g.resolveDecision(1) 753 1 754 >>> g.resolveDecision('A') 755 Traceback (most recent call last): 756 ... 757 exploration.core.AmbiguousDecisionSpecifierError... 758 >>> g.resolveDecision('B') 759 Traceback (most recent call last): 760 ... 761 exploration.core.AmbiguousDecisionSpecifierError... 762 >>> g.resolveDecision('C') 763 2 764 >>> g.resolveDecision('A', 'Z') 765 0 766 >>> g.resolveDecision('A', zoneHint='Z2') 767 3 768 >>> g.resolveDecision('B', domainHint='main') 769 1 770 >>> g.resolveDecision('B', None, 'menu') 771 4 772 >>> g.resolveDecision('B', zoneHint='Z2') 773 Traceback (most recent call last): 774 ... 775 exploration.core.MissingDecisionError... 776 >>> g.resolveDecision('A', domainHint='menu') 777 Traceback (most recent call last): 778 ... 779 exploration.core.MissingDecisionError... 780 >>> g.resolveDecision('A', domainHint='madeup') 781 Traceback (most recent call last): 782 ... 783 exploration.core.MissingDecisionError... 784 >>> g.resolveDecision('A', zoneHint='madeup') 785 Traceback (most recent call last): 786 ... 787 exploration.core.MissingDecisionError... 788 """ 789 # Parse it to either an ID or specifier if it's a string: 790 if isinstance(spec, str): 791 try: 792 spec = int(spec) 793 except ValueError: 794 pass 795 796 # If it's an ID, check for existence: 797 if isinstance(spec, base.DecisionID): 798 if spec in self: 799 return spec 800 else: 801 raise MissingDecisionError( 802 f"There is no decision with ID {spec!r}." 803 ) 804 else: 805 if isinstance(spec, base.DecisionName): 806 spec = base.DecisionSpecifier( 807 domain=None, 808 zone=None, 809 name=spec 810 ) 811 elif not isinstance(spec, base.DecisionSpecifier): 812 raise TypeError( 813 f"Specification is not provided as a" 814 f" DecisionSpecifier or other valid type. (got type" 815 f" {type(spec)})." 816 ) 817 818 # Merge domain hints from spec/args 819 if ( 820 spec.domain is not None 821 and domainHint is not None 822 and spec.domain != domainHint 823 ): 824 raise ValueError( 825 f"Specifier {repr(spec)} includes domain hint" 826 f" {repr(spec.domain)} which is incompatible with" 827 f" explicit domain hint {repr(domainHint)}." 828 ) 829 else: 830 domainHint = spec.domain or domainHint 831 832 # Merge zone hints from spec/args 833 if ( 834 spec.zone is not None 835 and zoneHint is not None 836 and spec.zone != zoneHint 837 ): 838 raise ValueError( 839 f"Specifier {repr(spec)} includes zone hint" 840 f" {repr(spec.zone)} which is incompatible with" 841 f" explicit zone hint {repr(zoneHint)}." 842 ) 843 else: 844 zoneHint = spec.zone or zoneHint 845 846 if spec.name not in self.nameLookup: 847 raise MissingDecisionError( 848 f"No decision named {repr(spec.name)}." 849 ) 850 else: 851 options = self.nameLookup[spec.name] 852 if len(options) == 0: 853 raise MissingDecisionError( 854 f"No decision named {repr(spec.name)}." 855 ) 856 filtered = [ 857 opt 858 for opt in options 859 if ( 860 domainHint is None 861 or self.domainFor(opt) == domainHint 862 ) and ( 863 zoneHint is None 864 or zoneHint in self.zoneAncestors(opt) 865 ) 866 ] 867 if len(filtered) == 1: 868 return filtered[0] 869 else: 870 filterDesc = "" 871 if domainHint is not None: 872 filterDesc += f" in domain {repr(domainHint)}" 873 if zoneHint is not None: 874 filterDesc += f" in zone {repr(zoneHint)}" 875 if len(filtered) == 0: 876 raise MissingDecisionError( 877 f"No decisions named" 878 f" {repr(spec.name)}{filterDesc}." 879 ) 880 else: 881 raise AmbiguousDecisionSpecifierError( 882 f"There are {len(filtered)} decisions" 883 f" named {repr(spec.name)}{filterDesc}." 884 )
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 DecisionSpecifier
s 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...
886 def getDecision( 887 self, 888 decision: base.AnyDecisionSpecifier, 889 zoneHint: Optional[base.Zone] = None, 890 domainHint: Optional[base.Domain] = None 891 ) -> Optional[base.DecisionID]: 892 """ 893 Works like `resolveDecision` but returns None instead of raising 894 a `MissingDecisionError` if the specified decision isn't listed. 895 May still raise an `AmbiguousDecisionSpecifierError`. 896 """ 897 try: 898 return self.resolveDecision( 899 decision, 900 zoneHint, 901 domainHint 902 ) 903 except MissingDecisionError: 904 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
.
906 def nameFor( 907 self, 908 decision: base.AnyDecisionSpecifier 909 ) -> base.DecisionName: 910 """ 911 Returns the name of the specified decision. Note that names are 912 not necessarily unique. 913 914 Example: 915 916 >>> d = DecisionGraph() 917 >>> d.addDecision('A') 918 0 919 >>> d.addDecision('B') 920 1 921 >>> d.addDecision('B') 922 2 923 >>> d.nameFor(0) 924 'A' 925 >>> d.nameFor(1) 926 'B' 927 >>> d.nameFor(2) 928 'B' 929 >>> d.nameFor(3) 930 Traceback (most recent call last): 931 ... 932 exploration.core.MissingDecisionError... 933 """ 934 dID = self.resolveDecision(decision) 935 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...
937 def shortIdentity( 938 self, 939 decision: Optional[base.AnyDecisionSpecifier], 940 includeZones: bool = True, 941 alwaysDomain: Optional[bool] = None 942 ): 943 """ 944 Returns a string containing the name for the given decision, 945 prefixed by its level-0 zone(s) and domain. If the value provided 946 is `None`, it returns the string "(nowhere)". 947 948 If `includeZones` is true (the default) then zone information 949 is included before the decision name. 950 951 If `alwaysDomain` is true or false, then the domain information 952 will always (or never) be included. If it's `None` (the default) 953 then domain info will only be included for decisions which are 954 not in the default domain. 955 """ 956 if decision is None: 957 return "(nowhere)" 958 else: 959 dID = self.resolveDecision(decision) 960 thisDomain = self.domainFor(dID) 961 dSpec = '' 962 zSpec = '' 963 if ( 964 alwaysDomain is True 965 or ( 966 alwaysDomain is None 967 and thisDomain != base.DEFAULT_DOMAIN 968 ) 969 ): 970 dSpec = thisDomain + '//' # TODO: Don't hardcode this? 971 if includeZones: 972 zones = [ 973 z 974 for z in self.zoneParents(dID) 975 if self.zones[z].level == 0 976 ] 977 if len(zones) == 1: 978 zSpec = zones[0] + '::' # TODO: Don't hardcode this? 979 elif len(zones) > 1: 980 zSpec = '[' + ', '.join(sorted(zones)) + ']::' 981 # else leave zSpec empty 982 983 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)".
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.
985 def identityOf( 986 self, 987 decision: Optional[base.AnyDecisionSpecifier], 988 includeZones: bool = True, 989 alwaysDomain: Optional[bool] = None 990 ) -> str: 991 """ 992 Returns the given node's ID, plus its `shortIdentity` in 993 parentheses. Arguments are passed through to `shortIdentity`. 994 """ 995 if decision is None: 996 return "(nowhere)" 997 else: 998 dID = self.resolveDecision(decision) 999 short = self.shortIdentity(decision, includeZones, alwaysDomain) 1000 return f"{dID} ({short})"
Returns the given node's ID, plus its shortIdentity
in
parentheses. Arguments are passed through to shortIdentity
.
1002 def namesListing( 1003 self, 1004 decisions: Collection[base.DecisionID], 1005 includeZones: bool = True, 1006 indent: int = 2 1007 ) -> str: 1008 """ 1009 Returns a multi-line string containing an indented listing of 1010 the provided decision IDs with their names in parentheses after 1011 each. Useful for debugging & error messages. 1012 1013 Includes level-0 zones where applicable, with a zone separator 1014 before the decision, unless `includeZones` is set to False. Where 1015 there are multiple level-0 zones, they're listed together in 1016 brackets. 1017 1018 Uses the string '(none)' when there are no decisions are in the 1019 list. 1020 1021 Set `indent` to something other than 2 to control how much 1022 indentation is added. 1023 1024 For example: 1025 1026 >>> g = DecisionGraph() 1027 >>> g.addDecision('A') 1028 0 1029 >>> g.addDecision('B') 1030 1 1031 >>> g.addDecision('C') 1032 2 1033 >>> g.namesListing(['A', 'C', 'B']) 1034 ' 0 (A)\\n 2 (C)\\n 1 (B)\\n' 1035 >>> g.namesListing([]) 1036 ' (none)\\n' 1037 >>> g.createZone('zone', 0) 1038 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1039 annotations=[]) 1040 >>> g.createZone('zone2', 0) 1041 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1042 annotations=[]) 1043 >>> g.createZone('zoneUp', 1) 1044 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 1045 annotations=[]) 1046 >>> g.addDecisionToZone(0, 'zone') 1047 >>> g.addDecisionToZone(1, 'zone') 1048 >>> g.addDecisionToZone(1, 'zone2') 1049 >>> g.addDecisionToZone(2, 'zoneUp') # won't be listed: it's level-1 1050 >>> g.namesListing(['A', 'C', 'B']) 1051 ' 0 (zone::A)\\n 2 (C)\\n 1 ([zone, zone2]::B)\\n' 1052 """ 1053 ind = ' ' * indent 1054 if len(decisions) == 0: 1055 return ind + '(none)\n' 1056 else: 1057 result = '' 1058 for dID in decisions: 1059 result += ind + self.identityOf(dID, includeZones) + '\n' 1060 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'
1062 def destinationsListing( 1063 self, 1064 destinations: Dict[base.Transition, base.DecisionID], 1065 includeZones: bool = True, 1066 indent: int = 2 1067 ) -> str: 1068 """ 1069 Returns a multi-line string containing an indented listing of 1070 the provided transitions along with their destinations and the 1071 names of those destinations in parentheses. Useful for debugging 1072 & error messages. (Use e.g., `destinationsFrom` to get a 1073 transitions -> destinations dictionary in the required format.) 1074 1075 Uses the string '(no transitions)' when there are no transitions 1076 in the dictionary. 1077 1078 Set `indent` to something other than 2 to control how much 1079 indentation is added. 1080 1081 For example: 1082 1083 >>> g = DecisionGraph() 1084 >>> g.addDecision('A') 1085 0 1086 >>> g.addDecision('B') 1087 1 1088 >>> g.addDecision('C') 1089 2 1090 >>> g.addTransition('A', 'north', 'B', 'south') 1091 >>> g.addTransition('B', 'east', 'C', 'west') 1092 >>> g.addTransition('C', 'southwest', 'A', 'northeast') 1093 >>> g.destinationsListing(g.destinationsFrom('A')) 1094 ' north to 1 (B)\\n northeast to 2 (C)\\n' 1095 >>> g.destinationsListing(g.destinationsFrom('B')) 1096 ' south to 0 (A)\\n east to 2 (C)\\n' 1097 >>> g.destinationsListing({}) 1098 ' (none)\\n' 1099 >>> g.createZone('zone', 0) 1100 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1101 annotations=[]) 1102 >>> g.addDecisionToZone(0, 'zone') 1103 >>> g.destinationsListing(g.destinationsFrom('B')) 1104 ' south to 0 (zone::A)\\n east to 2 (C)\\n' 1105 """ 1106 ind = ' ' * indent 1107 if len(destinations) == 0: 1108 return ind + '(none)\n' 1109 else: 1110 result = '' 1111 for transition, dID in destinations.items(): 1112 line = f"{transition} to {self.identityOf(dID, includeZones)}" 1113 result += ind + line + '\n' 1114 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'
1116 def domainFor(self, decision: base.AnyDecisionSpecifier) -> base.Domain: 1117 """ 1118 Returns the domain that a decision belongs to. 1119 """ 1120 dID = self.resolveDecision(decision) 1121 return self.nodes[dID]['domain']
Returns the domain that a decision belongs to.
1123 def allDecisionsInDomain( 1124 self, 1125 domain: base.Domain 1126 ) -> Set[base.DecisionID]: 1127 """ 1128 Returns the set of all `DecisionID`s for decisions in the 1129 specified domain. 1130 """ 1131 return set(dID for dID in self if self.nodes[dID]['domain'] == domain)
Returns the set of all DecisionID
s for decisions in the
specified domain.
1133 def destination( 1134 self, 1135 decision: base.AnyDecisionSpecifier, 1136 transition: base.Transition 1137 ) -> base.DecisionID: 1138 """ 1139 Overrides base `UniqueExitsGraph.destination` to raise 1140 `MissingDecisionError` or `MissingTransitionError` as 1141 appropriate, and to work with an `AnyDecisionSpecifier`. 1142 """ 1143 dID = self.resolveDecision(decision) 1144 try: 1145 return super().destination(dID, transition) 1146 except KeyError: 1147 raise MissingTransitionError( 1148 f"Transition {transition!r} does not exist at decision" 1149 f" {self.identityOf(dID)}." 1150 )
Overrides base UniqueExitsGraph.destination
to raise
MissingDecisionError
or MissingTransitionError
as
appropriate, and to work with an AnyDecisionSpecifier
.
1152 def getDestination( 1153 self, 1154 decision: base.AnyDecisionSpecifier, 1155 transition: base.Transition, 1156 default: Any = None 1157 ) -> Optional[base.DecisionID]: 1158 """ 1159 Overrides base `UniqueExitsGraph.getDestination` with different 1160 argument names, since those matter for the edit DSL. 1161 """ 1162 dID = self.resolveDecision(decision) 1163 return super().getDestination(dID, transition)
Overrides base UniqueExitsGraph.getDestination
with different
argument names, since those matter for the edit DSL.
1165 def destinationsFrom( 1166 self, 1167 decision: base.AnyDecisionSpecifier 1168 ) -> Dict[base.Transition, base.DecisionID]: 1169 """ 1170 Override that just changes the type of the exception from a 1171 `KeyError` to a `MissingDecisionError` when the source does not 1172 exist. 1173 """ 1174 dID = self.resolveDecision(decision) 1175 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.
1177 def bothEnds( 1178 self, 1179 decision: base.AnyDecisionSpecifier, 1180 transition: base.Transition 1181 ) -> Set[base.DecisionID]: 1182 """ 1183 Returns a set containing the `DecisionID`(s) for both the start 1184 and end of the specified transition. Raises a 1185 `MissingDecisionError` or `MissingTransitionError`if the 1186 specified decision and/or transition do not exist. 1187 1188 Note that for actions since the source and destination are the 1189 same, the set will have only one element. 1190 """ 1191 dID = self.resolveDecision(decision) 1192 result = {dID} 1193 dest = self.destination(dID, transition) 1194 if dest is not None: 1195 result.add(dest) 1196 return result
Returns a set containing the DecisionID
(s) for both the start
and end of the specified transition. Raises a
MissingDecisionError
or MissingTransitionError
if 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.
1198 def decisionActions( 1199 self, 1200 decision: base.AnyDecisionSpecifier 1201 ) -> Set[base.Transition]: 1202 """ 1203 Retrieves the set of self-edges at a decision. Editing the set 1204 will not affect the graph. 1205 1206 Example: 1207 1208 >>> g = DecisionGraph() 1209 >>> g.addDecision('A') 1210 0 1211 >>> g.addDecision('B') 1212 1 1213 >>> g.addDecision('C') 1214 2 1215 >>> g.addAction('A', 'action1') 1216 >>> g.addAction('A', 'action2') 1217 >>> g.addAction('B', 'action3') 1218 >>> sorted(g.decisionActions('A')) 1219 ['action1', 'action2'] 1220 >>> g.decisionActions('B') 1221 {'action3'} 1222 >>> g.decisionActions('C') 1223 set() 1224 """ 1225 result = set() 1226 dID = self.resolveDecision(decision) 1227 for transition, dest in self.destinationsFrom(dID).items(): 1228 if dest == dID: 1229 result.add(transition) 1230 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()
1232 def getTransitionProperties( 1233 self, 1234 decision: base.AnyDecisionSpecifier, 1235 transition: base.Transition 1236 ) -> TransitionProperties: 1237 """ 1238 Returns a dictionary containing transition properties for the 1239 specified transition from the specified decision. The properties 1240 included are: 1241 1242 - 'requirement': The requirement for the transition. 1243 - 'consequence': Any consequence of the transition. 1244 - 'tags': Any tags applied to the transition. 1245 - 'annotations': Any annotations on the transition. 1246 1247 The reciprocal of the transition is not included. 1248 1249 The result is a clone of the stored properties; edits to the 1250 dictionary will NOT modify the graph. 1251 """ 1252 dID = self.resolveDecision(decision) 1253 dest = self.destination(dID, transition) 1254 1255 info: TransitionProperties = copy.deepcopy( 1256 self.edges[dID, dest, transition] # type:ignore 1257 ) 1258 return { 1259 'requirement': info.get('requirement', base.ReqNothing()), 1260 'consequence': info.get('consequence', []), 1261 'tags': info.get('tags', {}), 1262 'annotations': info.get('annotations', []) 1263 }
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.
1265 def setTransitionProperties( 1266 self, 1267 decision: base.AnyDecisionSpecifier, 1268 transition: base.Transition, 1269 requirement: Optional[base.Requirement] = None, 1270 consequence: Optional[base.Consequence] = None, 1271 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 1272 annotations: Optional[List[base.Annotation]] = None 1273 ) -> None: 1274 """ 1275 Sets one or more transition properties all at once. Can be used 1276 to set the requirement, consequence, tags, and/or annotations. 1277 Old values are overwritten, although if `None`s are provided (or 1278 arguments are omitted), corresponding properties are not 1279 updated. 1280 1281 To add tags or annotations to existing tags/annotations instead 1282 of replacing them, use `tagTransition` or `annotateTransition` 1283 instead. 1284 """ 1285 dID = self.resolveDecision(decision) 1286 if requirement is not None: 1287 self.setTransitionRequirement(dID, transition, requirement) 1288 if consequence is not None: 1289 self.setConsequence(dID, transition, consequence) 1290 if tags is not None: 1291 dest = self.destination(dID, transition) 1292 # TODO: Submit pull request to update MultiDiGraph stubs in 1293 # types-networkx to include OutMultiEdgeView that accepts 1294 # from/to/key tuples as indices. 1295 info = cast( 1296 TransitionProperties, 1297 self.edges[dID, dest, transition] # type:ignore 1298 ) 1299 info['tags'] = tags 1300 if annotations is not None: 1301 dest = self.destination(dID, transition) 1302 info = cast( 1303 TransitionProperties, 1304 self.edges[dID, dest, transition] # type:ignore 1305 ) 1306 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 None
s 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.
1308 def getTransitionRequirement( 1309 self, 1310 decision: base.AnyDecisionSpecifier, 1311 transition: base.Transition 1312 ) -> base.Requirement: 1313 """ 1314 Returns the `Requirement` for accessing a specific transition at 1315 a specific decision. For transitions which don't have 1316 requirements, returns a `ReqNothing` instance. 1317 """ 1318 dID = self.resolveDecision(decision) 1319 dest = self.destination(dID, transition) 1320 1321 info = cast( 1322 TransitionProperties, 1323 self.edges[dID, dest, transition] # type:ignore 1324 ) 1325 1326 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.
1328 def setTransitionRequirement( 1329 self, 1330 decision: base.AnyDecisionSpecifier, 1331 transition: base.Transition, 1332 requirement: Optional[base.Requirement] 1333 ) -> None: 1334 """ 1335 Sets the `Requirement` for accessing a specific transition at 1336 a specific decision. Raises a `KeyError` if the decision or 1337 transition does not exist. 1338 1339 Deletes the requirement if `None` is given as the requirement. 1340 1341 Use `parsing.ParseFormat.parseRequirement` first if you have a 1342 requirement in string format. 1343 1344 Does not raise an error if deletion is requested for a 1345 non-existent requirement, and silently overwrites any previous 1346 requirement. 1347 """ 1348 dID = self.resolveDecision(decision) 1349 1350 dest = self.destination(dID, transition) 1351 1352 info = cast( 1353 TransitionProperties, 1354 self.edges[dID, dest, transition] # type:ignore 1355 ) 1356 1357 if requirement is None: 1358 try: 1359 del info['requirement'] 1360 except KeyError: 1361 pass 1362 else: 1363 if not isinstance(requirement, base.Requirement): 1364 raise TypeError( 1365 f"Invalid requirement type: {type(requirement)}" 1366 ) 1367 1368 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.
1370 def getConsequence( 1371 self, 1372 decision: base.AnyDecisionSpecifier, 1373 transition: base.Transition 1374 ) -> base.Consequence: 1375 """ 1376 Retrieves the consequence of a transition. 1377 1378 A `KeyError` is raised if the specified decision/transition 1379 combination doesn't exist. 1380 """ 1381 dID = self.resolveDecision(decision) 1382 1383 dest = self.destination(dID, transition) 1384 1385 info = cast( 1386 TransitionProperties, 1387 self.edges[dID, dest, transition] # type:ignore 1388 ) 1389 1390 return info.get('consequence', [])
Retrieves the consequence of a transition.
A KeyError
is raised if the specified decision/transition
combination doesn't exist.
1392 def addConsequence( 1393 self, 1394 decision: base.AnyDecisionSpecifier, 1395 transition: base.Transition, 1396 consequence: base.Consequence 1397 ) -> Tuple[int, int]: 1398 """ 1399 Adds the given `Consequence` to the consequence list for the 1400 specified transition, extending that list at the end. Note that 1401 this does NOT make a copy of the consequence, so it should not 1402 be used to copy consequences from one transition to another 1403 without making a deep copy first. 1404 1405 A `MissingDecisionError` or a `MissingTransitionError` is raised 1406 if the specified decision/transition combination doesn't exist. 1407 1408 Returns a pair of integers indicating the minimum and maximum 1409 depth-first-traversal-indices of the added consequence part(s). 1410 The outer consequence list itself (index 0) is not counted. 1411 1412 >>> d = DecisionGraph() 1413 >>> d.addDecision('A') 1414 0 1415 >>> d.addDecision('B') 1416 1 1417 >>> d.addTransition('A', 'fwd', 'B', 'rev') 1418 >>> d.addConsequence('A', 'fwd', [base.effect(gain='sword')]) 1419 (1, 1) 1420 >>> d.addConsequence('B', 'rev', [base.effect(lose='sword')]) 1421 (1, 1) 1422 >>> ef = d.getConsequence('A', 'fwd') 1423 >>> er = d.getConsequence('B', 'rev') 1424 >>> ef == [base.effect(gain='sword')] 1425 True 1426 >>> er == [base.effect(lose='sword')] 1427 True 1428 >>> d.addConsequence('A', 'fwd', [base.effect(deactivate=True)]) 1429 (2, 2) 1430 >>> ef = d.getConsequence('A', 'fwd') 1431 >>> ef == [base.effect(gain='sword'), base.effect(deactivate=True)] 1432 True 1433 >>> d.addConsequence( 1434 ... 'A', 1435 ... 'fwd', # adding to consequence with 3 parts already 1436 ... [ # outer list not counted because it merges 1437 ... base.challenge( # 1 part 1438 ... None, 1439 ... 0, 1440 ... [base.effect(gain=('flowers', 3))], # 2 parts 1441 ... [base.effect(gain=('flowers', 1))] # 2 parts 1442 ... ) 1443 ... ] 1444 ... ) # note indices below are inclusive; indices are 3, 4, 5, 6, 7 1445 (3, 7) 1446 """ 1447 dID = self.resolveDecision(decision) 1448 1449 dest = self.destination(dID, transition) 1450 1451 info = cast( 1452 TransitionProperties, 1453 self.edges[dID, dest, transition] # type:ignore 1454 ) 1455 1456 existing = info.setdefault('consequence', []) 1457 startIndex = base.countParts(existing) 1458 existing.extend(consequence) 1459 endIndex = base.countParts(existing) - 1 1460 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). 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)
1462 def setConsequence( 1463 self, 1464 decision: base.AnyDecisionSpecifier, 1465 transition: base.Transition, 1466 consequence: base.Consequence 1467 ) -> None: 1468 """ 1469 Replaces the transition consequence for the given transition at 1470 the given decision. Any previous consequence is discarded. See 1471 `Consequence` for the structure of these. Note that this does 1472 NOT make a copy of the consequence, do that first to avoid 1473 effect-entanglement if you're copying a consequence. 1474 1475 A `MissingDecisionError` or a `MissingTransitionError` is raised 1476 if the specified decision/transition combination doesn't exist. 1477 """ 1478 dID = self.resolveDecision(decision) 1479 1480 dest = self.destination(dID, transition) 1481 1482 info = cast( 1483 TransitionProperties, 1484 self.edges[dID, dest, transition] # type:ignore 1485 ) 1486 1487 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.
1489 def addEquivalence( 1490 self, 1491 requirement: base.Requirement, 1492 capabilityOrMechanismState: Union[ 1493 base.Capability, 1494 Tuple[base.MechanismID, base.MechanismState] 1495 ] 1496 ) -> None: 1497 """ 1498 Adds the given requirement as an equivalence for the given 1499 capability or the given mechanism state. Note that having a 1500 capability via an equivalence does not count as actually having 1501 that capability; it only counts for the purpose of satisfying 1502 `Requirement`s. 1503 1504 Note also that because a mechanism-based requirement looks up 1505 the specific mechanism locally based on a name, an equivalence 1506 defined in one location may affect mechanism requirements in 1507 other locations unless the mechanism name in the requirement is 1508 zone-qualified to be specific. But in such situations the base 1509 mechanism would have caused issues in any case. 1510 """ 1511 self.equivalences.setdefault( 1512 capabilityOrMechanismState, 1513 set() 1514 ).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
Requirement
s.
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.
1516 def removeEquivalence( 1517 self, 1518 requirement: base.Requirement, 1519 capabilityOrMechanismState: Union[ 1520 base.Capability, 1521 Tuple[base.MechanismID, base.MechanismState] 1522 ] 1523 ) -> None: 1524 """ 1525 Removes an equivalence. Raises a `KeyError` if no such 1526 equivalence existed. 1527 """ 1528 self.equivalences[capabilityOrMechanismState].remove(requirement)
Removes an equivalence. Raises a KeyError
if no such
equivalence existed.
1530 def hasAnyEquivalents( 1531 self, 1532 capabilityOrMechanismState: Union[ 1533 base.Capability, 1534 Tuple[base.MechanismID, base.MechanismState] 1535 ] 1536 ) -> bool: 1537 """ 1538 Returns `True` if the given capability or mechanism state has at 1539 least one equivalence. 1540 """ 1541 return capabilityOrMechanismState in self.equivalences
Returns True
if the given capability or mechanism state has at
least one equivalence.
1543 def allEquivalents( 1544 self, 1545 capabilityOrMechanismState: Union[ 1546 base.Capability, 1547 Tuple[base.MechanismID, base.MechanismState] 1548 ] 1549 ) -> Set[base.Requirement]: 1550 """ 1551 Returns the set of equivalences for the given capability. This is 1552 a live set which may be modified (it's probably better to use 1553 `addEquivalence` and `removeEquivalence` instead...). 1554 """ 1555 return self.equivalences.setdefault( 1556 capabilityOrMechanismState, 1557 set() 1558 )
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...).
1560 def reversionType(self, name: str, equivalentTo: Set[str]) -> None: 1561 """ 1562 Specifies a new reversion type, so that when used in a reversion 1563 aspects set with a colon before the name, all items in the 1564 `equivalentTo` value will be added to that set. These may 1565 include other custom reversion type names (with the colon) but 1566 take care not to create an equivalence loop which would result 1567 in a crash. 1568 1569 If you re-use the same name, it will override the old equivalence 1570 for that name. 1571 """ 1572 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.
1574 def addAction( 1575 self, 1576 decision: base.AnyDecisionSpecifier, 1577 action: base.Transition, 1578 requires: Optional[base.Requirement] = None, 1579 consequence: Optional[base.Consequence] = None, 1580 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 1581 annotations: Optional[List[base.Annotation]] = None, 1582 ) -> None: 1583 """ 1584 Adds the given action as a possibility at the given decision. An 1585 action is just a self-edge, which can have requirements like any 1586 edge, and which can have consequences like any edge. 1587 The optional arguments are given to `setTransitionRequirement` 1588 and `setConsequence`; see those functions for descriptions 1589 of what they mean. 1590 1591 Raises a `KeyError` if a transition with the given name already 1592 exists at the given decision. 1593 """ 1594 if tags is None: 1595 tags = {} 1596 if annotations is None: 1597 annotations = [] 1598 1599 dID = self.resolveDecision(decision) 1600 1601 self.add_edge( 1602 dID, 1603 dID, 1604 key=action, 1605 tags=tags, 1606 annotations=annotations 1607 ) 1608 self.setTransitionRequirement(dID, action, requires) 1609 if consequence is not None: 1610 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.
1612 def tagDecision( 1613 self, 1614 decision: base.AnyDecisionSpecifier, 1615 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 1616 tagValue: Union[ 1617 base.TagValue, 1618 type[base.NoTagValue] 1619 ] = base.NoTagValue 1620 ) -> None: 1621 """ 1622 Adds a tag (or many tags from a dictionary of tags) to a 1623 decision, using `1` as the value if no value is provided. It's 1624 a `ValueError` to provide a value when a dictionary of tags is 1625 provided to set multiple tags at once. 1626 1627 Note that certain tags have special meanings: 1628 1629 - 'unconfirmed' is used for decisions that represent unconfirmed 1630 parts of the graph (this is separate from the 'unknown' 1631 and/or 'hypothesized' exploration statuses, which are only 1632 tracked in a `DiscreteExploration`, not in a `DecisionGraph`). 1633 Various methods require this tag and many also add or remove 1634 it. 1635 """ 1636 if isinstance(tagOrTags, base.Tag): 1637 if tagValue is base.NoTagValue: 1638 tagValue = 1 1639 1640 # Not sure why this cast is necessary given the `if` above... 1641 tagValue = cast(base.TagValue, tagValue) 1642 1643 tagOrTags = {tagOrTags: tagValue} 1644 1645 elif tagValue is not base.NoTagValue: 1646 raise ValueError( 1647 "Provided a dictionary to update multiple tags, but" 1648 " also a tag value." 1649 ) 1650 1651 dID = self.resolveDecision(decision) 1652 1653 tagsAlready = self.nodes[dID].setdefault('tags', {}) 1654 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.
1656 def untagDecision( 1657 self, 1658 decision: base.AnyDecisionSpecifier, 1659 tag: base.Tag 1660 ) -> Union[base.TagValue, type[base.NoTagValue]]: 1661 """ 1662 Removes a tag from a decision. Returns the tag's old value if 1663 the tag was present and got removed, or `NoTagValue` if the tag 1664 wasn't present. 1665 """ 1666 dID = self.resolveDecision(decision) 1667 1668 target = self.nodes[dID]['tags'] 1669 try: 1670 return target.pop(tag) 1671 except KeyError: 1672 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.
1674 def decisionTags( 1675 self, 1676 decision: base.AnyDecisionSpecifier 1677 ) -> Dict[base.Tag, base.TagValue]: 1678 """ 1679 Returns the dictionary of tags for a decision. Edits to the 1680 returned value will be applied to the graph. 1681 """ 1682 dID = self.resolveDecision(decision) 1683 1684 return self.nodes[dID]['tags']
Returns the dictionary of tags for a decision. Edits to the returned value will be applied to the graph.
1686 def annotateDecision( 1687 self, 1688 decision: base.AnyDecisionSpecifier, 1689 annotationOrAnnotations: Union[ 1690 base.Annotation, 1691 Sequence[base.Annotation] 1692 ] 1693 ) -> None: 1694 """ 1695 Adds an annotation to a decision's annotations list. 1696 """ 1697 dID = self.resolveDecision(decision) 1698 1699 if isinstance(annotationOrAnnotations, base.Annotation): 1700 annotationOrAnnotations = [annotationOrAnnotations] 1701 self.nodes[dID]['annotations'].extend(annotationOrAnnotations)
Adds an annotation to a decision's annotations list.
1703 def decisionAnnotations( 1704 self, 1705 decision: base.AnyDecisionSpecifier 1706 ) -> List[base.Annotation]: 1707 """ 1708 Returns the list of annotations for the specified decision. 1709 Modifying the list affects the graph. 1710 """ 1711 dID = self.resolveDecision(decision) 1712 1713 return self.nodes[dID]['annotations']
Returns the list of annotations for the specified decision. Modifying the list affects the graph.
1715 def tagTransition( 1716 self, 1717 decision: base.AnyDecisionSpecifier, 1718 transition: base.Transition, 1719 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 1720 tagValue: Union[ 1721 base.TagValue, 1722 type[base.NoTagValue] 1723 ] = base.NoTagValue 1724 ) -> None: 1725 """ 1726 Adds a tag (or each tag from a dictionary) to a transition 1727 coming out of a specific decision. `1` will be used as the 1728 default value if a single tag is supplied; supplying a tag value 1729 when providing a dictionary of multiple tags to update is a 1730 `ValueError`. 1731 1732 Note that certain transition tags have special meanings: 1733 - 'trigger' causes any actions (but not normal transitions) that 1734 it applies to to be automatically triggered when 1735 `advanceSituation` is called and the decision they're 1736 attached to is active in the new situation (as long as the 1737 action's requirements are met). This happens once per 1738 situation; use 'wait' steps to re-apply triggers. 1739 """ 1740 dID = self.resolveDecision(decision) 1741 1742 dest = self.destination(dID, transition) 1743 if isinstance(tagOrTags, base.Tag): 1744 if tagValue is base.NoTagValue: 1745 tagValue = 1 1746 1747 # Not sure why this is necessary given the `if` above... 1748 tagValue = cast(base.TagValue, tagValue) 1749 1750 tagOrTags = {tagOrTags: tagValue} 1751 elif tagValue is not base.NoTagValue: 1752 raise ValueError( 1753 "Provided a dictionary to update multiple tags, but" 1754 " also a tag value." 1755 ) 1756 1757 info = cast( 1758 TransitionProperties, 1759 self.edges[dID, dest, transition] # type:ignore 1760 ) 1761 1762 info.setdefault('tags', {}).update(tagOrTags)
Adds a tag (or each tag from a dictionary) to a transition
coming out of a specific decision. 1
will be used as the
default value if a single tag is supplied; supplying a tag value
when providing a dictionary of multiple tags to update is a
ValueError
.
Note that certain transition tags have special meanings:
- 'trigger' causes any actions (but not normal transitions) that
it applies to to be automatically triggered when
advanceSituation
is called and the decision they're attached to is active in the new situation (as long as the action's requirements are met). This happens once per situation; use 'wait' steps to re-apply triggers.
1764 def untagTransition( 1765 self, 1766 decision: base.AnyDecisionSpecifier, 1767 transition: base.Transition, 1768 tagOrTags: Union[base.Tag, Set[base.Tag]] 1769 ) -> None: 1770 """ 1771 Removes a tag (or each tag in a set) from a transition coming out 1772 of a specific decision. Raises a `KeyError` if (one of) the 1773 specified tag(s) is not currently applied to the specified 1774 transition. 1775 """ 1776 dID = self.resolveDecision(decision) 1777 1778 dest = self.destination(dID, transition) 1779 if isinstance(tagOrTags, base.Tag): 1780 tagOrTags = {tagOrTags} 1781 1782 info = cast( 1783 TransitionProperties, 1784 self.edges[dID, dest, transition] # type:ignore 1785 ) 1786 tagsAlready = info.setdefault('tags', {}) 1787 1788 for tag in tagOrTags: 1789 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.
1791 def transitionTags( 1792 self, 1793 decision: base.AnyDecisionSpecifier, 1794 transition: base.Transition 1795 ) -> Dict[base.Tag, base.TagValue]: 1796 """ 1797 Returns the dictionary of tags for a transition. Edits to the 1798 returned dictionary will be applied to the graph. 1799 """ 1800 dID = self.resolveDecision(decision) 1801 1802 dest = self.destination(dID, transition) 1803 info = cast( 1804 TransitionProperties, 1805 self.edges[dID, dest, transition] # type:ignore 1806 ) 1807 return info.setdefault('tags', {})
Returns the dictionary of tags for a transition. Edits to the returned dictionary will be applied to the graph.
1809 def annotateTransition( 1810 self, 1811 decision: base.AnyDecisionSpecifier, 1812 transition: base.Transition, 1813 annotations: Union[base.Annotation, Sequence[base.Annotation]] 1814 ) -> None: 1815 """ 1816 Adds an annotation (or a sequence of annotations) to a 1817 transition's annotations list. 1818 """ 1819 dID = self.resolveDecision(decision) 1820 1821 dest = self.destination(dID, transition) 1822 if isinstance(annotations, base.Annotation): 1823 annotations = [annotations] 1824 info = cast( 1825 TransitionProperties, 1826 self.edges[dID, dest, transition] # type:ignore 1827 ) 1828 info['annotations'].extend(annotations)
Adds an annotation (or a sequence of annotations) to a transition's annotations list.
1830 def transitionAnnotations( 1831 self, 1832 decision: base.AnyDecisionSpecifier, 1833 transition: base.Transition 1834 ) -> List[base.Annotation]: 1835 """ 1836 Returns the annotation list for a specific transition at a 1837 specific decision. Editing the list affects the graph. 1838 """ 1839 dID = self.resolveDecision(decision) 1840 1841 dest = self.destination(dID, transition) 1842 info = cast( 1843 TransitionProperties, 1844 self.edges[dID, dest, transition] # type:ignore 1845 ) 1846 return info['annotations']
Returns the annotation list for a specific transition at a specific decision. Editing the list affects the graph.
1848 def annotateZone( 1849 self, 1850 zone: base.Zone, 1851 annotations: Union[base.Annotation, Sequence[base.Annotation]] 1852 ) -> None: 1853 """ 1854 Adds an annotation (or many annotations from a sequence) to a 1855 zone. 1856 1857 Raises a `MissingZoneError` if the specified zone does not exist. 1858 """ 1859 if zone not in self.zones: 1860 raise MissingZoneError( 1861 f"Can't add annotation(s) to zone {zone!r} because that" 1862 f" zone doesn't exist yet." 1863 ) 1864 1865 if isinstance(annotations, base.Annotation): 1866 annotations = [ annotations ] 1867 1868 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.
1870 def zoneAnnotations(self, zone: base.Zone) -> List[base.Annotation]: 1871 """ 1872 Returns the list of annotations for the specified zone (empty if 1873 none have been added yet). 1874 """ 1875 return self.zones[zone].annotations
Returns the list of annotations for the specified zone (empty if none have been added yet).
1877 def tagZone( 1878 self, 1879 zone: base.Zone, 1880 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 1881 tagValue: Union[ 1882 base.TagValue, 1883 type[base.NoTagValue] 1884 ] = base.NoTagValue 1885 ) -> None: 1886 """ 1887 Adds a tag (or many tags from a dictionary of tags) to a 1888 zone, using `1` as the value if no value is provided. It's 1889 a `ValueError` to provide a value when a dictionary of tags is 1890 provided to set multiple tags at once. 1891 1892 Raises a `MissingZoneError` if the specified zone does not exist. 1893 """ 1894 if zone not in self.zones: 1895 raise MissingZoneError( 1896 f"Can't add tag(s) to zone {zone!r} because that zone" 1897 f" doesn't exist yet." 1898 ) 1899 1900 if isinstance(tagOrTags, base.Tag): 1901 if tagValue is base.NoTagValue: 1902 tagValue = 1 1903 1904 # Not sure why this cast is necessary given the `if` above... 1905 tagValue = cast(base.TagValue, tagValue) 1906 1907 tagOrTags = {tagOrTags: tagValue} 1908 1909 elif tagValue is not base.NoTagValue: 1910 raise ValueError( 1911 "Provided a dictionary to update multiple tags, but" 1912 " also a tag value." 1913 ) 1914 1915 tagsAlready = self.zones[zone].tags 1916 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.
1918 def untagZone( 1919 self, 1920 zone: base.Zone, 1921 tag: base.Tag 1922 ) -> Union[base.TagValue, type[base.NoTagValue]]: 1923 """ 1924 Removes a tag from a zone. Returns the tag's old value if the 1925 tag was present and got removed, or `NoTagValue` if the tag 1926 wasn't present. 1927 1928 Raises a `MissingZoneError` if the specified zone does not exist. 1929 """ 1930 if zone not in self.zones: 1931 raise MissingZoneError( 1932 f"Can't remove tag {tag!r} from zone {zone!r} because" 1933 f" that zone doesn't exist yet." 1934 ) 1935 target = self.zones[zone].tags 1936 try: 1937 return target.pop(tag) 1938 except KeyError: 1939 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.
1941 def zoneTags( 1942 self, 1943 zone: base.Zone 1944 ) -> Dict[base.Tag, base.TagValue]: 1945 """ 1946 Returns the dictionary of tags for a zone. Edits to the returned 1947 value will be applied to the graph. Returns an empty tags 1948 dictionary if called on a zone that didn't have any tags 1949 previously, but raises a `MissingZoneError` if attempting to get 1950 tags for a zone which does not exist. 1951 1952 For example: 1953 1954 >>> g = DecisionGraph() 1955 >>> g.addDecision('A') 1956 0 1957 >>> g.addDecision('B') 1958 1 1959 >>> g.createZone('Zone') 1960 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1961 annotations=[]) 1962 >>> g.tagZone('Zone', 'color', 'blue') 1963 >>> g.tagZone( 1964 ... 'Zone', 1965 ... {'shape': 'square', 'color': 'red', 'sound': 'loud'} 1966 ... ) 1967 >>> g.untagZone('Zone', 'sound') 1968 'loud' 1969 >>> g.zoneTags('Zone') 1970 {'color': 'red', 'shape': 'square'} 1971 """ 1972 if zone in self.zones: 1973 return self.zones[zone].tags 1974 else: 1975 raise MissingZoneError( 1976 f"Tags for zone {zone!r} don't exist because that" 1977 f" zone has not been created yet." 1978 )
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'}
1980 def createZone(self, zone: base.Zone, level: int = 0) -> base.ZoneInfo: 1981 """ 1982 Creates an empty zone with the given name at the given level 1983 (default 0). Raises a `ZoneCollisionError` if that zone name is 1984 already in use (at any level), including if it's in use by a 1985 decision. 1986 1987 Raises an `InvalidLevelError` if the level value is less than 0. 1988 1989 Returns the `ZoneInfo` for the new blank zone. 1990 1991 For example: 1992 1993 >>> d = DecisionGraph() 1994 >>> d.createZone('Z', 0) 1995 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1996 annotations=[]) 1997 >>> d.getZoneInfo('Z') 1998 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 1999 annotations=[]) 2000 >>> d.createZone('Z2', 0) 2001 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2002 annotations=[]) 2003 >>> d.createZone('Z3', -1) # level -1 is not valid (must be >= 0) 2004 Traceback (most recent call last): 2005 ... 2006 exploration.core.InvalidLevelError... 2007 >>> d.createZone('Z2') # Name Z2 is already in use 2008 Traceback (most recent call last): 2009 ... 2010 exploration.core.ZoneCollisionError... 2011 """ 2012 if level < 0: 2013 raise InvalidLevelError( 2014 "Cannot create a zone with a negative level." 2015 ) 2016 if zone in self.zones: 2017 raise ZoneCollisionError(f"Zone {zone!r} already exists.") 2018 if zone in self: 2019 raise ZoneCollisionError( 2020 f"A decision named {zone!r} already exists, so a zone" 2021 f" with that name cannot be created." 2022 ) 2023 info: base.ZoneInfo = base.ZoneInfo( 2024 level=level, 2025 parents=set(), 2026 contents=set(), 2027 tags={}, 2028 annotations=[] 2029 ) 2030 self.zones[zone] = info 2031 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...
2033 def getZoneInfo(self, zone: base.Zone) -> Optional[base.ZoneInfo]: 2034 """ 2035 Returns the `ZoneInfo` (level, parents, and contents) for the 2036 specified zone, or `None` if that zone does not exist. 2037 2038 For example: 2039 2040 >>> d = DecisionGraph() 2041 >>> d.createZone('Z', 0) 2042 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2043 annotations=[]) 2044 >>> d.getZoneInfo('Z') 2045 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2046 annotations=[]) 2047 >>> d.createZone('Z2', 0) 2048 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2049 annotations=[]) 2050 >>> d.getZoneInfo('Z2') 2051 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2052 annotations=[]) 2053 """ 2054 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=[])
2056 def deleteZone(self, zone: base.Zone) -> base.ZoneInfo: 2057 """ 2058 Deletes the specified zone, returning a `ZoneInfo` object with 2059 the information on the level, parents, and contents of that zone. 2060 2061 Raises a `MissingZoneError` if the zone in question does not 2062 exist. 2063 2064 For example: 2065 2066 >>> d = DecisionGraph() 2067 >>> d.createZone('Z', 0) 2068 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2069 annotations=[]) 2070 >>> d.getZoneInfo('Z') 2071 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2072 annotations=[]) 2073 >>> d.deleteZone('Z') 2074 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2075 annotations=[]) 2076 >>> d.getZoneInfo('Z') is None # no info any more 2077 True 2078 >>> d.deleteZone('Z') # can't re-delete 2079 Traceback (most recent call last): 2080 ... 2081 exploration.core.MissingZoneError... 2082 """ 2083 info = self.getZoneInfo(zone) 2084 if info is None: 2085 raise MissingZoneError( 2086 f"Cannot delete zone {zone!r}: it does not exist." 2087 ) 2088 for sub in info.contents: 2089 if 'zones' in self.nodes[sub]: 2090 try: 2091 self.nodes[sub]['zones'].remove(zone) 2092 except KeyError: 2093 pass 2094 del self.zones[zone] 2095 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.
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...
2097 def addDecisionToZone( 2098 self, 2099 decision: base.AnyDecisionSpecifier, 2100 zone: base.Zone 2101 ) -> None: 2102 """ 2103 Adds a decision directly to a zone. Should normally only be used 2104 with level-0 zones. Raises a `MissingZoneError` if the specified 2105 zone did not already exist. 2106 2107 For example: 2108 2109 >>> d = DecisionGraph() 2110 >>> d.addDecision('A') 2111 0 2112 >>> d.addDecision('B') 2113 1 2114 >>> d.addDecision('C') 2115 2 2116 >>> d.createZone('Z', 0) 2117 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2118 annotations=[]) 2119 >>> d.addDecisionToZone('A', 'Z') 2120 >>> d.getZoneInfo('Z') 2121 ZoneInfo(level=0, parents=set(), contents={0}, tags={},\ 2122 annotations=[]) 2123 >>> d.addDecisionToZone('B', 'Z') 2124 >>> d.getZoneInfo('Z') 2125 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2126 annotations=[]) 2127 """ 2128 dID = self.resolveDecision(decision) 2129 2130 if zone not in self.zones: 2131 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2132 2133 self.zones[zone].contents.add(dID) 2134 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=[])
2136 def removeDecisionFromZone( 2137 self, 2138 decision: base.AnyDecisionSpecifier, 2139 zone: base.Zone 2140 ) -> bool: 2141 """ 2142 Removes a decision from a zone if it had been in it, returning 2143 True if that decision had been in that zone, and False if it was 2144 not in that zone, including if that zone didn't exist. 2145 2146 Note that this only removes a decision from direct zone 2147 membership. If the decision is a member of one or more zones 2148 which are (directly or indirectly) sub-zones of the target zone, 2149 the decision will remain in those zones, and will still be 2150 indirectly part of the target zone afterwards. 2151 2152 Examples: 2153 2154 >>> g = DecisionGraph() 2155 >>> g.addDecision('A') 2156 0 2157 >>> g.addDecision('B') 2158 1 2159 >>> g.createZone('level0', 0) 2160 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2161 annotations=[]) 2162 >>> g.createZone('level1', 1) 2163 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2164 annotations=[]) 2165 >>> g.createZone('level2', 2) 2166 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2167 annotations=[]) 2168 >>> g.createZone('level3', 3) 2169 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2170 annotations=[]) 2171 >>> g.addDecisionToZone('A', 'level0') 2172 >>> g.addDecisionToZone('B', 'level0') 2173 >>> g.addZoneToZone('level0', 'level1') 2174 >>> g.addZoneToZone('level1', 'level2') 2175 >>> g.addZoneToZone('level2', 'level3') 2176 >>> g.addDecisionToZone('B', 'level2') # Direct w/ skips 2177 >>> g.removeDecisionFromZone('A', 'level1') 2178 False 2179 >>> g.zoneParents(0) 2180 {'level0'} 2181 >>> g.removeDecisionFromZone('A', 'level0') 2182 True 2183 >>> g.zoneParents(0) 2184 set() 2185 >>> g.removeDecisionFromZone('A', 'level0') 2186 False 2187 >>> g.removeDecisionFromZone('B', 'level0') 2188 True 2189 >>> g.zoneParents(1) 2190 {'level2'} 2191 >>> g.removeDecisionFromZone('B', 'level0') 2192 False 2193 >>> g.removeDecisionFromZone('B', 'level2') 2194 True 2195 >>> g.zoneParents(1) 2196 set() 2197 """ 2198 dID = self.resolveDecision(decision) 2199 2200 if zone not in self.zones: 2201 return False 2202 2203 info = self.zones[zone] 2204 if dID not in info.contents: 2205 return False 2206 else: 2207 info.contents.remove(dID) 2208 try: 2209 self.nodes[dID]['zones'].remove(zone) 2210 except KeyError: 2211 pass 2212 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.
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()
2214 def addZoneToZone( 2215 self, 2216 addIt: base.Zone, 2217 addTo: base.Zone 2218 ) -> None: 2219 """ 2220 Adds a zone to another zone. The `addIt` one must be at a 2221 strictly lower level than the `addTo` zone, or an 2222 `InvalidLevelError` will be raised. 2223 2224 If the zone to be added didn't already exist, it is created at 2225 one level below the target zone. Similarly, if the zone being 2226 added to didn't already exist, it is created at one level above 2227 the target zone. If neither existed, a `MissingZoneError` will 2228 be raised. 2229 2230 For example: 2231 2232 >>> d = DecisionGraph() 2233 >>> d.addDecision('A') 2234 0 2235 >>> d.addDecision('B') 2236 1 2237 >>> d.addDecision('C') 2238 2 2239 >>> d.createZone('Z', 0) 2240 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2241 annotations=[]) 2242 >>> d.addDecisionToZone('A', 'Z') 2243 >>> d.addDecisionToZone('B', 'Z') 2244 >>> d.getZoneInfo('Z') 2245 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2246 annotations=[]) 2247 >>> d.createZone('Z2', 0) 2248 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2249 annotations=[]) 2250 >>> d.addDecisionToZone('B', 'Z2') 2251 >>> d.addDecisionToZone('C', 'Z2') 2252 >>> d.getZoneInfo('Z2') 2253 ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\ 2254 annotations=[]) 2255 >>> d.createZone('l1Z', 1) 2256 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2257 annotations=[]) 2258 >>> d.createZone('l2Z', 2) 2259 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2260 annotations=[]) 2261 >>> d.addZoneToZone('Z', 'l1Z') 2262 >>> d.getZoneInfo('Z') 2263 ZoneInfo(level=0, parents={'l1Z'}, contents={0, 1}, tags={},\ 2264 annotations=[]) 2265 >>> d.getZoneInfo('l1Z') 2266 ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\ 2267 annotations=[]) 2268 >>> d.addZoneToZone('l1Z', 'l2Z') 2269 >>> d.getZoneInfo('l1Z') 2270 ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\ 2271 annotations=[]) 2272 >>> d.getZoneInfo('l2Z') 2273 ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\ 2274 annotations=[]) 2275 >>> d.addZoneToZone('Z2', 'l2Z') 2276 >>> d.getZoneInfo('Z2') 2277 ZoneInfo(level=0, parents={'l2Z'}, contents={1, 2}, tags={},\ 2278 annotations=[]) 2279 >>> l2i = d.getZoneInfo('l2Z') 2280 >>> l2i.level 2281 2 2282 >>> l2i.parents 2283 set() 2284 >>> sorted(l2i.contents) 2285 ['Z2', 'l1Z'] 2286 >>> d.addZoneToZone('NZ', 'NZ2') 2287 Traceback (most recent call last): 2288 ... 2289 exploration.core.MissingZoneError... 2290 >>> d.addZoneToZone('Z', 'l1Z2') 2291 >>> zi = d.getZoneInfo('Z') 2292 >>> zi.level 2293 0 2294 >>> sorted(zi.parents) 2295 ['l1Z', 'l1Z2'] 2296 >>> sorted(zi.contents) 2297 [0, 1] 2298 >>> d.getZoneInfo('l1Z2') 2299 ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\ 2300 annotations=[]) 2301 >>> d.addZoneToZone('NZ', 'l1Z') 2302 >>> d.getZoneInfo('NZ') 2303 ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\ 2304 annotations=[]) 2305 >>> zi = d.getZoneInfo('l1Z') 2306 >>> zi.level 2307 1 2308 >>> zi.parents 2309 {'l2Z'} 2310 >>> sorted(zi.contents) 2311 ['NZ', 'Z'] 2312 """ 2313 # Create one or the other (but not both) if they're missing 2314 addInfo = self.getZoneInfo(addIt) 2315 toInfo = self.getZoneInfo(addTo) 2316 if addInfo is None and toInfo is None: 2317 raise MissingZoneError( 2318 f"Cannot add zone {addIt!r} to zone {addTo!r}: neither" 2319 f" exists already." 2320 ) 2321 2322 # Create missing addIt 2323 elif addInfo is None: 2324 toInfo = cast(base.ZoneInfo, toInfo) 2325 newLevel = toInfo.level - 1 2326 if newLevel < 0: 2327 raise InvalidLevelError( 2328 f"Zone {addTo!r} is at level {toInfo.level} and so" 2329 f" a new zone cannot be added underneath it." 2330 ) 2331 addInfo = self.createZone(addIt, newLevel) 2332 2333 # Create missing addTo 2334 elif toInfo is None: 2335 addInfo = cast(base.ZoneInfo, addInfo) 2336 newLevel = addInfo.level + 1 2337 if newLevel < 0: 2338 raise InvalidLevelError( 2339 f"Zone {addIt!r} is at level {addInfo.level} (!!!)" 2340 f" and so a new zone cannot be added above it." 2341 ) 2342 toInfo = self.createZone(addTo, newLevel) 2343 2344 # Now both addInfo and toInfo are defined 2345 if addInfo.level >= toInfo.level: 2346 raise InvalidLevelError( 2347 f"Cannot add zone {addIt!r} at level {addInfo.level}" 2348 f" to zone {addTo!r} at level {toInfo.level}: zones can" 2349 f" only contain zones of lower levels." 2350 ) 2351 2352 # Now both addInfo and toInfo are defined 2353 toInfo.contents.add(addIt) 2354 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']
2356 def removeZoneFromZone( 2357 self, 2358 removeIt: base.Zone, 2359 removeFrom: base.Zone 2360 ) -> bool: 2361 """ 2362 Removes a zone from a zone if it had been in it, returning True 2363 if that zone had been in that zone, and False if it was not in 2364 that zone, including if either zone did not exist. 2365 2366 For example: 2367 2368 >>> d = DecisionGraph() 2369 >>> d.createZone('Z', 0) 2370 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2371 annotations=[]) 2372 >>> d.createZone('Z2', 0) 2373 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2374 annotations=[]) 2375 >>> d.createZone('l1Z', 1) 2376 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2377 annotations=[]) 2378 >>> d.createZone('l2Z', 2) 2379 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2380 annotations=[]) 2381 >>> d.addZoneToZone('Z', 'l1Z') 2382 >>> d.addZoneToZone('l1Z', 'l2Z') 2383 >>> d.getZoneInfo('Z') 2384 ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\ 2385 annotations=[]) 2386 >>> d.getZoneInfo('l1Z') 2387 ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\ 2388 annotations=[]) 2389 >>> d.getZoneInfo('l2Z') 2390 ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\ 2391 annotations=[]) 2392 >>> d.removeZoneFromZone('l1Z', 'l2Z') 2393 True 2394 >>> d.getZoneInfo('l1Z') 2395 ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\ 2396 annotations=[]) 2397 >>> d.getZoneInfo('l2Z') 2398 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2399 annotations=[]) 2400 >>> d.removeZoneFromZone('Z', 'l1Z') 2401 True 2402 >>> d.getZoneInfo('Z') 2403 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2404 annotations=[]) 2405 >>> d.getZoneInfo('l1Z') 2406 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2407 annotations=[]) 2408 >>> d.removeZoneFromZone('Z', 'l1Z') 2409 False 2410 >>> d.removeZoneFromZone('Z', 'madeup') 2411 False 2412 >>> d.removeZoneFromZone('nope', 'madeup') 2413 False 2414 >>> d.removeZoneFromZone('nope', 'l1Z') 2415 False 2416 """ 2417 remInfo = self.getZoneInfo(removeIt) 2418 fromInfo = self.getZoneInfo(removeFrom) 2419 2420 if remInfo is None or fromInfo is None: 2421 return False 2422 2423 if removeIt not in fromInfo.contents: 2424 return False 2425 2426 remInfo.parents.remove(removeFrom) 2427 fromInfo.contents.remove(removeIt) 2428 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
2430 def decisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]: 2431 """ 2432 Returns a set of all decisions included directly in the given 2433 zone, not counting decisions included via intermediate 2434 sub-zones (see `allDecisionsInZone` to include those). 2435 2436 Raises a `MissingZoneError` if the specified zone does not 2437 exist. 2438 2439 The returned set is a copy, not a live editable set. 2440 2441 For example: 2442 2443 >>> d = DecisionGraph() 2444 >>> d.addDecision('A') 2445 0 2446 >>> d.addDecision('B') 2447 1 2448 >>> d.addDecision('C') 2449 2 2450 >>> d.createZone('Z', 0) 2451 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2452 annotations=[]) 2453 >>> d.addDecisionToZone('A', 'Z') 2454 >>> d.addDecisionToZone('B', 'Z') 2455 >>> d.getZoneInfo('Z') 2456 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2457 annotations=[]) 2458 >>> d.decisionsInZone('Z') 2459 {0, 1} 2460 >>> d.createZone('Z2', 0) 2461 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2462 annotations=[]) 2463 >>> d.addDecisionToZone('B', 'Z2') 2464 >>> d.addDecisionToZone('C', 'Z2') 2465 >>> d.getZoneInfo('Z2') 2466 ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\ 2467 annotations=[]) 2468 >>> d.decisionsInZone('Z') 2469 {0, 1} 2470 >>> d.decisionsInZone('Z2') 2471 {1, 2} 2472 >>> d.createZone('l1Z', 1) 2473 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2474 annotations=[]) 2475 >>> d.addZoneToZone('Z', 'l1Z') 2476 >>> d.decisionsInZone('Z') 2477 {0, 1} 2478 >>> d.decisionsInZone('l1Z') 2479 set() 2480 >>> d.decisionsInZone('madeup') 2481 Traceback (most recent call last): 2482 ... 2483 exploration.core.MissingZoneError... 2484 >>> zDec = d.decisionsInZone('Z') 2485 >>> zDec.add(2) # won't affect the zone 2486 >>> zDec 2487 {0, 1, 2} 2488 >>> d.decisionsInZone('Z') 2489 {0, 1} 2490 """ 2491 info = self.getZoneInfo(zone) 2492 if info is None: 2493 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2494 2495 # Everything that's not a zone must be a decision 2496 return { 2497 item 2498 for item in info.contents 2499 if isinstance(item, base.DecisionID) 2500 }
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}
2502 def subZones(self, zone: base.Zone) -> Set[base.Zone]: 2503 """ 2504 Returns the set of all immediate sub-zones of the given zone. 2505 Will be an empty set if there are no sub-zones; raises a 2506 `MissingZoneError` if the specified zone does not exit. 2507 2508 The returned set is a copy, not a live editable set. 2509 2510 For example: 2511 2512 >>> d = DecisionGraph() 2513 >>> d.createZone('Z', 0) 2514 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2515 annotations=[]) 2516 >>> d.subZones('Z') 2517 set() 2518 >>> d.createZone('l1Z', 1) 2519 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2520 annotations=[]) 2521 >>> d.addZoneToZone('Z', 'l1Z') 2522 >>> d.subZones('Z') 2523 set() 2524 >>> d.subZones('l1Z') 2525 {'Z'} 2526 >>> s = d.subZones('l1Z') 2527 >>> s.add('Q') # doesn't affect the zone 2528 >>> sorted(s) 2529 ['Q', 'Z'] 2530 >>> d.subZones('l1Z') 2531 {'Z'} 2532 >>> d.subZones('madeup') 2533 Traceback (most recent call last): 2534 ... 2535 exploration.core.MissingZoneError... 2536 """ 2537 info = self.getZoneInfo(zone) 2538 if info is None: 2539 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2540 2541 # Sub-zones will appear in self.zones 2542 return { 2543 item 2544 for item in info.contents 2545 if isinstance(item, base.Zone) 2546 }
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...
2548 def allDecisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]: 2549 """ 2550 Returns a set containing all decisions in the given zone, 2551 including those included via sub-zones. 2552 2553 Raises a `MissingZoneError` if the specified zone does not 2554 exist.` 2555 2556 For example: 2557 2558 >>> d = DecisionGraph() 2559 >>> d.addDecision('A') 2560 0 2561 >>> d.addDecision('B') 2562 1 2563 >>> d.addDecision('C') 2564 2 2565 >>> d.createZone('Z', 0) 2566 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2567 annotations=[]) 2568 >>> d.addDecisionToZone('A', 'Z') 2569 >>> d.addDecisionToZone('B', 'Z') 2570 >>> d.getZoneInfo('Z') 2571 ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\ 2572 annotations=[]) 2573 >>> d.decisionsInZone('Z') 2574 {0, 1} 2575 >>> d.allDecisionsInZone('Z') 2576 {0, 1} 2577 >>> d.createZone('Z2', 0) 2578 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2579 annotations=[]) 2580 >>> d.addDecisionToZone('B', 'Z2') 2581 >>> d.addDecisionToZone('C', 'Z2') 2582 >>> d.getZoneInfo('Z2') 2583 ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\ 2584 annotations=[]) 2585 >>> d.decisionsInZone('Z') 2586 {0, 1} 2587 >>> d.decisionsInZone('Z2') 2588 {1, 2} 2589 >>> d.allDecisionsInZone('Z2') 2590 {1, 2} 2591 >>> d.createZone('l1Z', 1) 2592 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2593 annotations=[]) 2594 >>> d.createZone('l2Z', 2) 2595 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2596 annotations=[]) 2597 >>> d.addZoneToZone('Z', 'l1Z') 2598 >>> d.addZoneToZone('l1Z', 'l2Z') 2599 >>> d.addZoneToZone('Z2', 'l2Z') 2600 >>> d.decisionsInZone('Z') 2601 {0, 1} 2602 >>> d.decisionsInZone('Z2') 2603 {1, 2} 2604 >>> d.decisionsInZone('l1Z') 2605 set() 2606 >>> d.allDecisionsInZone('l1Z') 2607 {0, 1} 2608 >>> d.allDecisionsInZone('l2Z') 2609 {0, 1, 2} 2610 """ 2611 result: Set[base.DecisionID] = set() 2612 info = self.getZoneInfo(zone) 2613 if info is None: 2614 raise MissingZoneError(f"Zone {zone!r} does not exist.") 2615 2616 for item in info.contents: 2617 if isinstance(item, base.Zone): 2618 # This can't be an error because of the condition above 2619 result |= self.allDecisionsInZone(item) 2620 else: # it's a decision 2621 result.add(item) 2622 2623 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}
2625 def zoneHierarchyLevel(self, zone: base.Zone) -> int: 2626 """ 2627 Returns the hierarchy level of the given zone, as stored in its 2628 zone info. 2629 2630 By convention, level-0 zones contain decisions directly, and 2631 higher-level zones contain zones of lower levels. This 2632 convention is not enforced, and there could be exceptions to it. 2633 2634 Raises a `MissingZoneError` if the specified zone does not 2635 exist. 2636 2637 For example: 2638 2639 >>> d = DecisionGraph() 2640 >>> d.createZone('Z', 0) 2641 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2642 annotations=[]) 2643 >>> d.createZone('l1Z', 1) 2644 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2645 annotations=[]) 2646 >>> d.createZone('l5Z', 5) 2647 ZoneInfo(level=5, parents=set(), contents=set(), tags={},\ 2648 annotations=[]) 2649 >>> d.zoneHierarchyLevel('Z') 2650 0 2651 >>> d.zoneHierarchyLevel('l1Z') 2652 1 2653 >>> d.zoneHierarchyLevel('l5Z') 2654 5 2655 >>> d.zoneHierarchyLevel('madeup') 2656 Traceback (most recent call last): 2657 ... 2658 exploration.core.MissingZoneError... 2659 """ 2660 info = self.getZoneInfo(zone) 2661 if info is None: 2662 raise MissingZoneError(f"Zone {zone!r} dose not exist.") 2663 2664 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...
2666 def zoneParents( 2667 self, 2668 zoneOrDecision: Union[base.Zone, base.DecisionID] 2669 ) -> Set[base.Zone]: 2670 """ 2671 Returns the set of all zones which directly contain the target 2672 zone or decision. 2673 2674 Raises a `MissingDecisionError` if the target is neither a valid 2675 zone nor a valid decision. 2676 2677 Returns a copy, not a live editable set. 2678 2679 Example: 2680 2681 >>> g = DecisionGraph() 2682 >>> g.addDecision('A') 2683 0 2684 >>> g.addDecision('B') 2685 1 2686 >>> g.createZone('level0', 0) 2687 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2688 annotations=[]) 2689 >>> g.createZone('level1', 1) 2690 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2691 annotations=[]) 2692 >>> g.createZone('level2', 2) 2693 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2694 annotations=[]) 2695 >>> g.createZone('level3', 3) 2696 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2697 annotations=[]) 2698 >>> g.addDecisionToZone('A', 'level0') 2699 >>> g.addDecisionToZone('B', 'level0') 2700 >>> g.addZoneToZone('level0', 'level1') 2701 >>> g.addZoneToZone('level1', 'level2') 2702 >>> g.addZoneToZone('level2', 'level3') 2703 >>> g.addDecisionToZone('B', 'level2') # Direct w/ skips 2704 >>> sorted(g.zoneParents(0)) 2705 ['level0'] 2706 >>> sorted(g.zoneParents(1)) 2707 ['level0', 'level2'] 2708 """ 2709 if zoneOrDecision in self.zones: 2710 zoneOrDecision = cast(base.Zone, zoneOrDecision) 2711 info = cast(base.ZoneInfo, self.getZoneInfo(zoneOrDecision)) 2712 return copy.copy(info.parents) 2713 elif zoneOrDecision in self: 2714 return self.nodes[zoneOrDecision].get('zones', set()) 2715 else: 2716 raise MissingDecisionError( 2717 f"Name {zoneOrDecision!r} is neither a valid zone nor a" 2718 f" valid decision." 2719 )
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']
2721 def zoneAncestors( 2722 self, 2723 zoneOrDecision: Union[base.Zone, base.DecisionID], 2724 exclude: Set[base.Zone] = set(), 2725 atLevel: Optional[int] = None 2726 ) -> Set[base.Zone]: 2727 """ 2728 Returns the set of zones which contain the target zone or 2729 decision, either directly or indirectly. The target is not 2730 included in the set. 2731 2732 Any ones listed in the `exclude` set are also excluded, as are 2733 any of their ancestors which are not also ancestors of the 2734 target zone via another path of inclusion. 2735 2736 If `atLevel` is not `None`, then only zones at that hierarchy 2737 level will be included. 2738 2739 Raises a `MissingDecisionError` if the target is nether a valid 2740 zone nor a valid decision. 2741 2742 Example: 2743 2744 >>> g = DecisionGraph() 2745 >>> g.addDecision('A') 2746 0 2747 >>> g.addDecision('B') 2748 1 2749 >>> g.createZone('level0', 0) 2750 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2751 annotations=[]) 2752 >>> g.createZone('level1', 1) 2753 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2754 annotations=[]) 2755 >>> g.createZone('level2', 2) 2756 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2757 annotations=[]) 2758 >>> g.createZone('level3', 3) 2759 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 2760 annotations=[]) 2761 >>> g.addDecisionToZone('A', 'level0') 2762 >>> g.addDecisionToZone('B', 'level0') 2763 >>> g.addZoneToZone('level0', 'level1') 2764 >>> g.addZoneToZone('level1', 'level2') 2765 >>> g.addZoneToZone('level2', 'level3') 2766 >>> g.addDecisionToZone('B', 'level2') # Direct w/ skips 2767 >>> sorted(g.zoneAncestors(0)) 2768 ['level0', 'level1', 'level2', 'level3'] 2769 >>> sorted(g.zoneAncestors(1)) 2770 ['level0', 'level1', 'level2', 'level3'] 2771 >>> sorted(g.zoneParents(0)) 2772 ['level0'] 2773 >>> sorted(g.zoneParents(1)) 2774 ['level0', 'level2'] 2775 >>> sorted(g.zoneAncestors(0, atLevel=2)) 2776 ['level2'] 2777 >>> sorted(g.zoneAncestors(0, exclude={'level2'})) 2778 ['level0', 'level1'] 2779 """ 2780 # Copy is important here! 2781 result = set(self.zoneParents(zoneOrDecision)) 2782 result -= exclude 2783 for parent in copy.copy(result): 2784 # Recursively dig up ancestors, but exclude 2785 # results-so-far to avoid re-enumerating when there are 2786 # multiple braided inclusion paths. 2787 result |= self.zoneAncestors(parent, result | exclude, atLevel) 2788 2789 if atLevel is not None: 2790 return {z for z in result if self.zoneHierarchyLevel(z) == atLevel} 2791 else: 2792 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']
2794 def region( 2795 self, 2796 decision: base.DecisionID, 2797 useLevel: int=1 2798 ) -> Optional[base.Zone]: 2799 """ 2800 Returns the 'region' that this decision belongs to. 'Regions' 2801 are level-1 zones, but when a decision is in multiple level-1 2802 zones, its region counts as the smallest of those zones in terms 2803 of total decisions contained, breaking ties by the one with the 2804 alphabetically earlier name. 2805 2806 Always returns a single zone name string, unless the target 2807 decision is not in any level-1 zones, in which case it returns 2808 `None`. 2809 2810 If `useLevel` is specified, then zones of the specified level 2811 will be used instead of level-1 zones. 2812 2813 Example: 2814 2815 >>> g = DecisionGraph() 2816 >>> g.addDecision('A') 2817 0 2818 >>> g.addDecision('B') 2819 1 2820 >>> g.addDecision('C') 2821 2 2822 >>> g.addDecision('D') 2823 3 2824 >>> g.createZone('zoneX', 0) 2825 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2826 annotations=[]) 2827 >>> g.createZone('regionA', 1) 2828 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2829 annotations=[]) 2830 >>> g.createZone('zoneY', 0) 2831 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2832 annotations=[]) 2833 >>> g.createZone('regionB', 1) 2834 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2835 annotations=[]) 2836 >>> g.createZone('regionC', 1) 2837 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2838 annotations=[]) 2839 >>> g.createZone('quadrant', 2) 2840 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 2841 annotations=[]) 2842 >>> g.addDecisionToZone('A', 'zoneX') 2843 >>> g.addDecisionToZone('B', 'zoneY') 2844 >>> # C is not in any level-1 zones 2845 >>> g.addDecisionToZone('D', 'zoneX') 2846 >>> g.addDecisionToZone('D', 'zoneY') # D is in both 2847 >>> g.addZoneToZone('zoneX', 'regionA') 2848 >>> g.addZoneToZone('zoneY', 'regionB') 2849 >>> g.addZoneToZone('zoneX', 'regionC') # includes both 2850 >>> g.addZoneToZone('zoneY', 'regionC') 2851 >>> g.addZoneToZone('regionA', 'quadrant') 2852 >>> g.addZoneToZone('regionB', 'quadrant') 2853 >>> g.addDecisionToZone('C', 'regionC') # Direct in level-2 2854 >>> sorted(g.allDecisionsInZone('zoneX')) 2855 [0, 3] 2856 >>> sorted(g.allDecisionsInZone('zoneY')) 2857 [1, 3] 2858 >>> sorted(g.allDecisionsInZone('regionA')) 2859 [0, 3] 2860 >>> sorted(g.allDecisionsInZone('regionB')) 2861 [1, 3] 2862 >>> sorted(g.allDecisionsInZone('regionC')) 2863 [0, 1, 2, 3] 2864 >>> sorted(g.allDecisionsInZone('quadrant')) 2865 [0, 1, 3] 2866 >>> g.region(0) # for A; region A is smaller than region C 2867 'regionA' 2868 >>> g.region(1) # for B; region B is also smaller than C 2869 'regionB' 2870 >>> g.region(2) # for C 2871 'regionC' 2872 >>> g.region(3) # for D; tie broken alphabetically 2873 'regionA' 2874 >>> g.region(0, useLevel=0) # for A at level 0 2875 'zoneX' 2876 >>> g.region(1, useLevel=0) # for B at level 0 2877 'zoneY' 2878 >>> g.region(2, useLevel=0) is None # for C at level 0 (none) 2879 True 2880 >>> g.region(3, useLevel=0) # for D at level 0; tie 2881 'zoneX' 2882 >>> g.region(0, useLevel=2) # for A at level 2 2883 'quadrant' 2884 >>> g.region(1, useLevel=2) # for B at level 2 2885 'quadrant' 2886 >>> g.region(2, useLevel=2) is None # for C at level 2 (none) 2887 True 2888 >>> g.region(3, useLevel=2) # for D at level 2 2889 'quadrant' 2890 """ 2891 relevant = self.zoneAncestors(decision, atLevel=useLevel) 2892 if len(relevant) == 0: 2893 return None 2894 elif len(relevant) == 1: 2895 for zone in relevant: 2896 return zone 2897 return None # not really necessary but keeps mypy happy 2898 else: 2899 # more than one zone ancestor at the relevant hierarchy 2900 # level: need to measure their sizes 2901 minSize = None 2902 candidates = [] 2903 for zone in relevant: 2904 size = len(self.allDecisionsInZone(zone)) 2905 if minSize is None or size < minSize: 2906 candidates = [zone] 2907 minSize = size 2908 elif size == minSize: 2909 candidates.append(zone) 2910 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'
2912 def zoneEdges(self, zone: base.Zone) -> Optional[ 2913 Tuple[ 2914 Set[Tuple[base.DecisionID, base.Transition]], 2915 Set[Tuple[base.DecisionID, base.Transition]] 2916 ] 2917 ]: 2918 """ 2919 Given a zone to look at, finds all of the transitions which go 2920 out of and into that zone, ignoring internal transitions between 2921 decisions in the zone. This includes all decisions in sub-zones. 2922 The return value is a pair of sets for outgoing and then 2923 incoming transitions, where each transition is specified as a 2924 (sourceID, transitionName) pair. 2925 2926 Returns `None` if the target zone isn't yet fully defined. 2927 2928 Note that this takes time proportional to *all* edges plus *all* 2929 nodes in the graph no matter how large or small the zone in 2930 question is. 2931 2932 >>> g = DecisionGraph() 2933 >>> g.addDecision('A') 2934 0 2935 >>> g.addDecision('B') 2936 1 2937 >>> g.addDecision('C') 2938 2 2939 >>> g.addDecision('D') 2940 3 2941 >>> g.addTransition('A', 'up', 'B', 'down') 2942 >>> g.addTransition('B', 'right', 'C', 'left') 2943 >>> g.addTransition('C', 'down', 'D', 'up') 2944 >>> g.addTransition('D', 'left', 'A', 'right') 2945 >>> g.addTransition('A', 'tunnel', 'C', 'tunnel') 2946 >>> g.createZone('Z', 0) 2947 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 2948 annotations=[]) 2949 >>> g.createZone('ZZ', 1) 2950 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 2951 annotations=[]) 2952 >>> g.addZoneToZone('Z', 'ZZ') 2953 >>> g.addDecisionToZone('A', 'Z') 2954 >>> g.addDecisionToZone('B', 'Z') 2955 >>> g.addDecisionToZone('D', 'ZZ') 2956 >>> outgoing, incoming = g.zoneEdges('Z') # TODO: Sort for testing 2957 >>> sorted(outgoing) 2958 [(0, 'right'), (0, 'tunnel'), (1, 'right')] 2959 >>> sorted(incoming) 2960 [(2, 'left'), (2, 'tunnel'), (3, 'left')] 2961 >>> outgoing, incoming = g.zoneEdges('ZZ') 2962 >>> sorted(outgoing) 2963 [(0, 'tunnel'), (1, 'right'), (3, 'up')] 2964 >>> sorted(incoming) 2965 [(2, 'down'), (2, 'left'), (2, 'tunnel')] 2966 >>> g.zoneEdges('madeup') is None 2967 True 2968 """ 2969 # Find the interior nodes 2970 try: 2971 interior = self.allDecisionsInZone(zone) 2972 except MissingZoneError: 2973 return None 2974 2975 # Set up our result 2976 results: Tuple[ 2977 Set[Tuple[base.DecisionID, base.Transition]], 2978 Set[Tuple[base.DecisionID, base.Transition]] 2979 ] = (set(), set()) 2980 2981 # Because finding incoming edges requires searching the entire 2982 # graph anyways, it's more efficient to just consider each edge 2983 # once. 2984 for fromDecision in self: 2985 fromThere = self[fromDecision] 2986 for toDecision in fromThere: 2987 for transition in fromThere[toDecision]: 2988 sourceIn = fromDecision in interior 2989 destIn = toDecision in interior 2990 if sourceIn and not destIn: 2991 results[0].add((fromDecision, transition)) 2992 elif destIn and not sourceIn: 2993 results[1].add((fromDecision, transition)) 2994 2995 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
2997 def replaceZonesInHierarchy( 2998 self, 2999 target: base.AnyDecisionSpecifier, 3000 zone: base.Zone, 3001 level: int 3002 ) -> None: 3003 """ 3004 This method replaces one or more zones which contain the 3005 specified `target` decision with a specific zone, at a specific 3006 level in the zone hierarchy (see `zoneHierarchyLevel`). If the 3007 named zone doesn't yet exist, it will be created. 3008 3009 To do this, it looks at all zones which contain the target 3010 decision directly or indirectly (see `zoneAncestors`) and which 3011 are at the specified level. 3012 3013 - Any direct children of those zones which are ancestors of the 3014 target decision are removed from those zones and placed into 3015 the new zone instead, regardless of their levels. Indirect 3016 children are not affected (except perhaps indirectly via 3017 their parents' ancestors changing). 3018 - The new zone is placed into every direct parent of those 3019 zones, regardless of their levels (those parents are by 3020 definition all ancestors of the target decision). 3021 - If there were no zones at the target level, every zone at the 3022 next level down which is an ancestor of the target decision 3023 (or just that decision if the level is 0) is placed into the 3024 new zone as a direct child (and is removed from any previous 3025 parents it had). In this case, the new zone will also be 3026 added as a sub-zone to every ancestor of the target decision 3027 at the level above the specified level, if there are any. 3028 * In this case, if there are no zones at the level below the 3029 specified level, the highest level of zones smaller than 3030 that is treated as the level below, down to targeting 3031 the decision itself. 3032 * Similarly, if there are no zones at the level above the 3033 specified level but there are zones at a higher level, 3034 the new zone will be added to each of the zones in the 3035 lowest level above the target level that has zones in it. 3036 3037 A `MissingDecisionError` will be raised if the specified 3038 decision is not valid, or if the decision is left as default but 3039 there is no current decision in the exploration. 3040 3041 An `InvalidLevelError` will be raised if the level is less than 3042 zero. 3043 3044 Example: 3045 3046 >>> g = DecisionGraph() 3047 >>> g.addDecision('decision') 3048 0 3049 >>> g.addDecision('alternate') 3050 1 3051 >>> g.createZone('zone0', 0) 3052 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 3053 annotations=[]) 3054 >>> g.createZone('zone1', 1) 3055 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 3056 annotations=[]) 3057 >>> g.createZone('zone2.1', 2) 3058 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 3059 annotations=[]) 3060 >>> g.createZone('zone2.2', 2) 3061 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 3062 annotations=[]) 3063 >>> g.createZone('zone3', 3) 3064 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 3065 annotations=[]) 3066 >>> g.addDecisionToZone('decision', 'zone0') 3067 >>> g.addDecisionToZone('alternate', 'zone0') 3068 >>> g.addZoneToZone('zone0', 'zone1') 3069 >>> g.addZoneToZone('zone1', 'zone2.1') 3070 >>> g.addZoneToZone('zone1', 'zone2.2') 3071 >>> g.addZoneToZone('zone2.1', 'zone3') 3072 >>> g.addZoneToZone('zone2.2', 'zone3') 3073 >>> g.zoneHierarchyLevel('zone0') 3074 0 3075 >>> g.zoneHierarchyLevel('zone1') 3076 1 3077 >>> g.zoneHierarchyLevel('zone2.1') 3078 2 3079 >>> g.zoneHierarchyLevel('zone2.2') 3080 2 3081 >>> g.zoneHierarchyLevel('zone3') 3082 3 3083 >>> sorted(g.decisionsInZone('zone0')) 3084 [0, 1] 3085 >>> sorted(g.zoneAncestors('zone0')) 3086 ['zone1', 'zone2.1', 'zone2.2', 'zone3'] 3087 >>> g.subZones('zone1') 3088 {'zone0'} 3089 >>> g.zoneParents('zone0') 3090 {'zone1'} 3091 >>> g.replaceZonesInHierarchy('decision', 'new0', 0) 3092 >>> g.zoneParents('zone0') 3093 {'zone1'} 3094 >>> g.zoneParents('new0') 3095 {'zone1'} 3096 >>> sorted(g.zoneAncestors('zone0')) 3097 ['zone1', 'zone2.1', 'zone2.2', 'zone3'] 3098 >>> sorted(g.zoneAncestors('new0')) 3099 ['zone1', 'zone2.1', 'zone2.2', 'zone3'] 3100 >>> g.decisionsInZone('zone0') 3101 {1} 3102 >>> g.decisionsInZone('new0') 3103 {0} 3104 >>> sorted(g.subZones('zone1')) 3105 ['new0', 'zone0'] 3106 >>> g.zoneParents('new0') 3107 {'zone1'} 3108 >>> g.replaceZonesInHierarchy('decision', 'new1', 1) 3109 >>> sorted(g.zoneAncestors(0)) 3110 ['new0', 'new1', 'zone2.1', 'zone2.2', 'zone3'] 3111 >>> g.subZones('zone1') 3112 {'zone0'} 3113 >>> g.subZones('new1') 3114 {'new0'} 3115 >>> g.zoneParents('new0') 3116 {'new1'} 3117 >>> sorted(g.zoneParents('zone1')) 3118 ['zone2.1', 'zone2.2'] 3119 >>> sorted(g.zoneParents('new1')) 3120 ['zone2.1', 'zone2.2'] 3121 >>> g.zoneParents('zone2.1') 3122 {'zone3'} 3123 >>> g.zoneParents('zone2.2') 3124 {'zone3'} 3125 >>> sorted(g.subZones('zone2.1')) 3126 ['new1', 'zone1'] 3127 >>> sorted(g.subZones('zone2.2')) 3128 ['new1', 'zone1'] 3129 >>> sorted(g.allDecisionsInZone('zone2.1')) 3130 [0, 1] 3131 >>> sorted(g.allDecisionsInZone('zone2.2')) 3132 [0, 1] 3133 >>> g.replaceZonesInHierarchy('decision', 'new2', 2) 3134 >>> g.zoneParents('zone2.1') 3135 {'zone3'} 3136 >>> g.zoneParents('zone2.2') 3137 {'zone3'} 3138 >>> g.subZones('zone2.1') 3139 {'zone1'} 3140 >>> g.subZones('zone2.2') 3141 {'zone1'} 3142 >>> g.subZones('new2') 3143 {'new1'} 3144 >>> g.zoneParents('new2') 3145 {'zone3'} 3146 >>> g.allDecisionsInZone('zone2.1') 3147 {1} 3148 >>> g.allDecisionsInZone('zone2.2') 3149 {1} 3150 >>> g.allDecisionsInZone('new2') 3151 {0} 3152 >>> sorted(g.subZones('zone3')) 3153 ['new2', 'zone2.1', 'zone2.2'] 3154 >>> g.zoneParents('zone3') 3155 set() 3156 >>> sorted(g.allDecisionsInZone('zone3')) 3157 [0, 1] 3158 >>> g.replaceZonesInHierarchy('decision', 'new3', 3) 3159 >>> sorted(g.subZones('zone3')) 3160 ['zone2.1', 'zone2.2'] 3161 >>> g.subZones('new3') 3162 {'new2'} 3163 >>> g.zoneParents('zone3') 3164 set() 3165 >>> g.zoneParents('new3') 3166 set() 3167 >>> g.allDecisionsInZone('zone3') 3168 {1} 3169 >>> g.allDecisionsInZone('new3') 3170 {0} 3171 >>> g.replaceZonesInHierarchy('decision', 'new4', 5) 3172 >>> g.subZones('new4') 3173 {'new3'} 3174 >>> g.zoneHierarchyLevel('new4') 3175 5 3176 3177 Another example of level collapse when trying to replace a zone 3178 at a level above : 3179 3180 >>> g = DecisionGraph() 3181 >>> g.addDecision('A') 3182 0 3183 >>> g.addDecision('B') 3184 1 3185 >>> g.createZone('level0', 0) 3186 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 3187 annotations=[]) 3188 >>> g.createZone('level1', 1) 3189 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 3190 annotations=[]) 3191 >>> g.createZone('level2', 2) 3192 ZoneInfo(level=2, parents=set(), contents=set(), tags={},\ 3193 annotations=[]) 3194 >>> g.createZone('level3', 3) 3195 ZoneInfo(level=3, parents=set(), contents=set(), tags={},\ 3196 annotations=[]) 3197 >>> g.addDecisionToZone('B', 'level0') 3198 >>> g.addZoneToZone('level0', 'level1') 3199 >>> g.addZoneToZone('level1', 'level2') 3200 >>> g.addZoneToZone('level2', 'level3') 3201 >>> g.addDecisionToZone('A', 'level3') # missing some zone levels 3202 >>> g.zoneHierarchyLevel('level3') 3203 3 3204 >>> g.replaceZonesInHierarchy('A', 'newFirst', 1) 3205 >>> g.zoneHierarchyLevel('newFirst') 3206 1 3207 >>> g.decisionsInZone('newFirst') 3208 {0} 3209 >>> g.decisionsInZone('level3') 3210 set() 3211 >>> sorted(g.allDecisionsInZone('level3')) 3212 [0, 1] 3213 >>> g.subZones('newFirst') 3214 set() 3215 >>> sorted(g.subZones('level3')) 3216 ['level2', 'newFirst'] 3217 >>> g.zoneParents('newFirst') 3218 {'level3'} 3219 >>> g.replaceZonesInHierarchy('A', 'newSecond', 2) 3220 >>> g.zoneHierarchyLevel('newSecond') 3221 2 3222 >>> g.decisionsInZone('newSecond') 3223 set() 3224 >>> g.allDecisionsInZone('newSecond') 3225 {0} 3226 >>> g.subZones('newSecond') 3227 {'newFirst'} 3228 >>> g.zoneParents('newSecond') 3229 {'level3'} 3230 >>> g.zoneParents('newFirst') 3231 {'newSecond'} 3232 >>> sorted(g.subZones('level3')) 3233 ['level2', 'newSecond'] 3234 """ 3235 tID = self.resolveDecision(target) 3236 3237 if level < 0: 3238 raise InvalidLevelError( 3239 f"Target level must be positive (got {level})." 3240 ) 3241 3242 info = self.getZoneInfo(zone) 3243 if info is None: 3244 info = self.createZone(zone, level) 3245 elif level != info.level: 3246 raise InvalidLevelError( 3247 f"Target level ({level}) does not match the level of" 3248 f" the target zone ({zone!r} at level {info.level})." 3249 ) 3250 3251 # Collect both parents & ancestors 3252 parents = self.zoneParents(tID) 3253 ancestors = set(self.zoneAncestors(tID)) 3254 3255 # Map from levels to sets of zones from the ancestors pool 3256 levelMap: Dict[int, Set[base.Zone]] = {} 3257 highest = -1 3258 for ancestor in ancestors: 3259 ancestorLevel = self.zoneHierarchyLevel(ancestor) 3260 levelMap.setdefault(ancestorLevel, set()).add(ancestor) 3261 if ancestorLevel > highest: 3262 highest = ancestorLevel 3263 3264 # Figure out if we have target zones to replace or not 3265 reparentDecision = False 3266 if level in levelMap: 3267 # If there are zones at the target level, 3268 targetZones = levelMap[level] 3269 3270 above = set() 3271 below = set() 3272 3273 for replaced in targetZones: 3274 above |= self.zoneParents(replaced) 3275 below |= self.subZones(replaced) 3276 if replaced in parents: 3277 reparentDecision = True 3278 3279 # Only ancestors should be reparented 3280 below &= ancestors 3281 3282 else: 3283 # Find levels w/ zones in them above + below 3284 levelBelow = level - 1 3285 levelAbove = level + 1 3286 below = levelMap.get(levelBelow, set()) 3287 above = levelMap.get(levelAbove, set()) 3288 3289 while len(below) == 0 and levelBelow > 0: 3290 levelBelow -= 1 3291 below = levelMap.get(levelBelow, set()) 3292 3293 if len(below) == 0: 3294 reparentDecision = True 3295 3296 while len(above) == 0 and levelAbove < highest: 3297 levelAbove += 1 3298 above = levelMap.get(levelAbove, set()) 3299 3300 # Handle re-parenting zones below 3301 for under in below: 3302 for parent in self.zoneParents(under): 3303 if parent in ancestors: 3304 self.removeZoneFromZone(under, parent) 3305 self.addZoneToZone(under, zone) 3306 3307 # Add this zone to each parent 3308 for parent in above: 3309 self.addZoneToZone(zone, parent) 3310 3311 # Re-parent the decision itself if necessary 3312 if reparentDecision: 3313 # (using set() here to avoid size-change-during-iteration) 3314 for parent in set(parents): 3315 self.removeDecisionFromZone(tID, parent) 3316 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']
3318 def getReciprocal( 3319 self, 3320 decision: base.AnyDecisionSpecifier, 3321 transition: base.Transition 3322 ) -> Optional[base.Transition]: 3323 """ 3324 Returns the reciprocal edge for the specified transition from the 3325 specified decision (see `setReciprocal`). Returns 3326 `None` if no reciprocal has been established for that 3327 transition, or if that decision or transition does not exist. 3328 """ 3329 dID = self.resolveDecision(decision) 3330 3331 dest = self.getDestination(dID, transition) 3332 if dest is not None: 3333 info = cast( 3334 TransitionProperties, 3335 self.edges[dID, dest, transition] # type:ignore 3336 ) 3337 recip = info.get("reciprocal") 3338 if recip is not None and not isinstance(recip, base.Transition): 3339 raise ValueError(f"Invalid reciprocal value: {repr(recip)}") 3340 return recip 3341 else: 3342 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.
3344 def setReciprocal( 3345 self, 3346 decision: base.AnyDecisionSpecifier, 3347 transition: base.Transition, 3348 reciprocal: Optional[base.Transition], 3349 setBoth: bool = True, 3350 cleanup: bool = True 3351 ) -> None: 3352 """ 3353 Sets the 'reciprocal' transition for a particular transition from 3354 a particular decision, and removes the reciprocal property from 3355 any old reciprocal transition. 3356 3357 Raises a `MissingDecisionError` or a `MissingTransitionError` if 3358 the specified decision or transition does not exist. 3359 3360 Raises an `InvalidDestinationError` if the reciprocal transition 3361 does not exist, or if it does exist but does not lead back to 3362 the decision the transition came from. 3363 3364 If `setBoth` is True (the default) then the transition which is 3365 being identified as a reciprocal will also have its reciprocal 3366 property set, pointing back to the primary transition being 3367 modified, and any old reciprocal of that transition will have its 3368 reciprocal set to None. If you want to create a situation with 3369 non-exclusive reciprocals, use `setBoth=False`. 3370 3371 If `cleanup` is True (the default) then abandoned reciprocal 3372 transitions (for both edges if `setBoth` was true) have their 3373 reciprocal properties removed. Set `cleanup` to false if you want 3374 to retain them, although this will result in non-exclusive 3375 reciprocal relationships. 3376 3377 If the `reciprocal` value is None, this deletes the reciprocal 3378 value entirely, and if `setBoth` is true, it does this for the 3379 previous reciprocal edge as well. No error is raised in this case 3380 when there was not already a reciprocal to delete. 3381 3382 Note that one should remove a reciprocal relationship before 3383 redirecting either edge of the pair in a way that gives it a new 3384 reciprocal, since otherwise, a later attempt to remove the 3385 reciprocal with `setBoth` set to True (the default) will end up 3386 deleting the reciprocal information from the other edge that was 3387 already modified. There is no way to reliably detect and avoid 3388 this, because two different decisions could (and often do in 3389 practice) have transitions with identical names, meaning that the 3390 reciprocal value will still be the same, but it will indicate a 3391 different edge in virtue of the destination of the edge changing. 3392 3393 ## Example 3394 3395 >>> g = DecisionGraph() 3396 >>> g.addDecision('G') 3397 0 3398 >>> g.addDecision('H') 3399 1 3400 >>> g.addDecision('I') 3401 2 3402 >>> g.addTransition('G', 'up', 'H', 'down') 3403 >>> g.addTransition('G', 'next', 'H', 'prev') 3404 >>> g.addTransition('H', 'next', 'I', 'prev') 3405 >>> g.addTransition('H', 'return', 'G') 3406 >>> g.setReciprocal('G', 'up', 'next') # Error w/ destinations 3407 Traceback (most recent call last): 3408 ... 3409 exploration.core.InvalidDestinationError... 3410 >>> g.setReciprocal('G', 'up', 'none') # Doesn't exist 3411 Traceback (most recent call last): 3412 ... 3413 exploration.core.MissingTransitionError... 3414 >>> g.getReciprocal('G', 'up') 3415 'down' 3416 >>> g.getReciprocal('H', 'down') 3417 'up' 3418 >>> g.getReciprocal('H', 'return') is None 3419 True 3420 >>> g.setReciprocal('G', 'up', 'return') 3421 >>> g.getReciprocal('G', 'up') 3422 'return' 3423 >>> g.getReciprocal('H', 'down') is None 3424 True 3425 >>> g.getReciprocal('H', 'return') 3426 'up' 3427 >>> g.setReciprocal('H', 'return', None) # remove the reciprocal 3428 >>> g.getReciprocal('G', 'up') is None 3429 True 3430 >>> g.getReciprocal('H', 'down') is None 3431 True 3432 >>> g.getReciprocal('H', 'return') is None 3433 True 3434 >>> g.setReciprocal('G', 'up', 'down', setBoth=False) # one-way 3435 >>> g.getReciprocal('G', 'up') 3436 'down' 3437 >>> g.getReciprocal('H', 'down') is None 3438 True 3439 >>> g.getReciprocal('H', 'return') is None 3440 True 3441 >>> g.setReciprocal('H', 'return', 'up', setBoth=False) # non-sym 3442 >>> g.getReciprocal('G', 'up') 3443 'down' 3444 >>> g.getReciprocal('H', 'down') is None 3445 True 3446 >>> g.getReciprocal('H', 'return') 3447 'up' 3448 >>> g.setReciprocal('H', 'down', 'up') # setBoth not needed 3449 >>> g.getReciprocal('G', 'up') 3450 'down' 3451 >>> g.getReciprocal('H', 'down') 3452 'up' 3453 >>> g.getReciprocal('H', 'return') # unchanged 3454 'up' 3455 >>> g.setReciprocal('G', 'up', 'return', cleanup=False) # no cleanup 3456 >>> g.getReciprocal('G', 'up') 3457 'return' 3458 >>> g.getReciprocal('H', 'down') 3459 'up' 3460 >>> g.getReciprocal('H', 'return') # unchanged 3461 'up' 3462 >>> # Cleanup only applies to reciprocal if setBoth is true 3463 >>> g.setReciprocal('H', 'down', 'up', setBoth=False) 3464 >>> g.getReciprocal('G', 'up') 3465 'return' 3466 >>> g.getReciprocal('H', 'down') 3467 'up' 3468 >>> g.getReciprocal('H', 'return') # not cleaned up w/out setBoth 3469 'up' 3470 >>> g.setReciprocal('H', 'down', 'up') # with cleanup and setBoth 3471 >>> g.getReciprocal('G', 'up') 3472 'down' 3473 >>> g.getReciprocal('H', 'down') 3474 'up' 3475 >>> g.getReciprocal('H', 'return') is None # cleaned up 3476 True 3477 """ 3478 dID = self.resolveDecision(decision) 3479 3480 dest = self.destination(dID, transition) # possible KeyError 3481 if reciprocal is None: 3482 rDest = None 3483 else: 3484 rDest = self.getDestination(dest, reciprocal) 3485 3486 # Set or delete reciprocal property 3487 if reciprocal is None: 3488 # Delete the property 3489 info = self.edges[dID, dest, transition] # type:ignore 3490 3491 old = info.pop('reciprocal') 3492 if setBoth: 3493 rDest = self.getDestination(dest, old) 3494 if rDest != dID: 3495 raise RuntimeError( 3496 f"Invalid reciprocal {old!r} for transition" 3497 f" {transition!r} from {self.identityOf(dID)}:" 3498 f" destination is {rDest}." 3499 ) 3500 rInfo = self.edges[dest, dID, old] # type:ignore 3501 if 'reciprocal' in rInfo: 3502 del rInfo['reciprocal'] 3503 else: 3504 # Set the property, checking for errors first 3505 if rDest is None: 3506 raise MissingTransitionError( 3507 f"Reciprocal transition {reciprocal!r} for" 3508 f" transition {transition!r} from decision" 3509 f" {self.identityOf(dID)} does not exist at" 3510 f" decision {self.identityOf(dest)}" 3511 ) 3512 3513 if rDest != dID: 3514 raise InvalidDestinationError( 3515 f"Reciprocal transition {reciprocal!r} from" 3516 f" decision {self.identityOf(dest)} does not lead" 3517 f" back to decision {self.identityOf(dID)}." 3518 ) 3519 3520 eProps = self.edges[dID, dest, transition] # type:ignore [index] 3521 abandoned = eProps.get('reciprocal') 3522 eProps['reciprocal'] = reciprocal 3523 if cleanup and abandoned not in (None, reciprocal): 3524 aProps = self.edges[dest, dID, abandoned] # type:ignore 3525 if 'reciprocal' in aProps: 3526 del aProps['reciprocal'] 3527 3528 if setBoth: 3529 rProps = self.edges[dest, dID, reciprocal] # type:ignore 3530 revAbandoned = rProps.get('reciprocal') 3531 rProps['reciprocal'] = transition 3532 # Sever old reciprocal relationship 3533 if cleanup and revAbandoned not in (None, transition): 3534 raProps = self.edges[ 3535 dID, # type:ignore 3536 dest, 3537 revAbandoned 3538 ] 3539 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
3541 def getReciprocalPair( 3542 self, 3543 decision: base.AnyDecisionSpecifier, 3544 transition: base.Transition 3545 ) -> Optional[Tuple[base.DecisionID, base.Transition]]: 3546 """ 3547 Returns a tuple containing both the destination decision ID and 3548 the transition at that decision which is the reciprocal of the 3549 specified destination & transition. Returns `None` if no 3550 reciprocal has been established for that transition, or if that 3551 decision or transition does not exist. 3552 3553 >>> g = DecisionGraph() 3554 >>> g.addDecision('A') 3555 0 3556 >>> g.addDecision('B') 3557 1 3558 >>> g.addDecision('C') 3559 2 3560 >>> g.addTransition('A', 'up', 'B', 'down') 3561 >>> g.addTransition('B', 'right', 'C', 'left') 3562 >>> g.addTransition('A', 'oneway', 'C') 3563 >>> g.getReciprocalPair('A', 'up') 3564 (1, 'down') 3565 >>> g.getReciprocalPair('B', 'down') 3566 (0, 'up') 3567 >>> g.getReciprocalPair('B', 'right') 3568 (2, 'left') 3569 >>> g.getReciprocalPair('C', 'left') 3570 (1, 'right') 3571 >>> g.getReciprocalPair('C', 'up') is None 3572 True 3573 >>> g.getReciprocalPair('Q', 'up') is None 3574 True 3575 >>> g.getReciprocalPair('A', 'tunnel') is None 3576 True 3577 """ 3578 try: 3579 dID = self.resolveDecision(decision) 3580 except MissingDecisionError: 3581 return None 3582 3583 reciprocal = self.getReciprocal(dID, transition) 3584 if reciprocal is None: 3585 return None 3586 else: 3587 destination = self.getDestination(dID, transition) 3588 if destination is None: 3589 return None 3590 else: 3591 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
3593 def addDecision( 3594 self, 3595 name: base.DecisionName, 3596 domain: Optional[base.Domain] = None, 3597 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 3598 annotations: Optional[List[base.Annotation]] = None 3599 ) -> base.DecisionID: 3600 """ 3601 Adds a decision to the graph, without any transitions yet. Each 3602 decision will be assigned an ID so name collisions are allowed, 3603 but it's usually best to keep names unique at least within each 3604 zone. If no domain is provided, the `DEFAULT_DOMAIN` will be 3605 used for the decision's domain. A dictionary of tags and/or a 3606 list of annotations (strings in both cases) may be provided. 3607 3608 Returns the newly-assigned `DecisionID` for the decision it 3609 created. 3610 3611 Emits a `DecisionCollisionWarning` if a decision with the 3612 provided name already exists and the `WARN_OF_NAME_COLLISIONS` 3613 global variable is set to `True`. 3614 """ 3615 # Defaults 3616 if domain is None: 3617 domain = base.DEFAULT_DOMAIN 3618 if tags is None: 3619 tags = {} 3620 if annotations is None: 3621 annotations = [] 3622 3623 # Error checking 3624 if name in self.nameLookup and WARN_OF_NAME_COLLISIONS: 3625 warnings.warn( 3626 ( 3627 f"Adding decision {name!r}: Another decision with" 3628 f" that name already exists." 3629 ), 3630 DecisionCollisionWarning 3631 ) 3632 3633 dID = self._assignID() 3634 3635 # Add the decision 3636 self.add_node( 3637 dID, 3638 name=name, 3639 domain=domain, 3640 tags=tags, 3641 annotations=annotations 3642 ) 3643 #TODO: Elide tags/annotations if they're empty? 3644 3645 # Track it in our `nameLookup` dictionary 3646 self.nameLookup.setdefault(name, []).append(dID) 3647 3648 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
.
3650 def addIdentifiedDecision( 3651 self, 3652 dID: base.DecisionID, 3653 name: base.DecisionName, 3654 domain: Optional[base.Domain] = None, 3655 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 3656 annotations: Optional[List[base.Annotation]] = None 3657 ) -> None: 3658 """ 3659 Adds a new decision to the graph using a specific decision ID, 3660 rather than automatically assigning a new decision ID like 3661 `addDecision` does. Otherwise works like `addDecision`. 3662 3663 Raises a `MechanismCollisionError` if the specified decision ID 3664 is already in use. 3665 """ 3666 # Defaults 3667 if domain is None: 3668 domain = base.DEFAULT_DOMAIN 3669 if tags is None: 3670 tags = {} 3671 if annotations is None: 3672 annotations = [] 3673 3674 # Error checking 3675 if dID in self.nodes: 3676 raise MechanismCollisionError( 3677 f"Cannot add a node with id {dID} and name {name!r}:" 3678 f" that ID is already used by node {self.identityOf(dID)}" 3679 ) 3680 3681 if name in self.nameLookup and WARN_OF_NAME_COLLISIONS: 3682 warnings.warn( 3683 ( 3684 f"Adding decision {name!r}: Another decision with" 3685 f" that name already exists." 3686 ), 3687 DecisionCollisionWarning 3688 ) 3689 3690 # Add the decision 3691 self.add_node( 3692 dID, 3693 name=name, 3694 domain=domain, 3695 tags=tags, 3696 annotations=annotations 3697 ) 3698 #TODO: Elide tags/annotations if they're empty? 3699 3700 # Track it in our `nameLookup` dictionary 3701 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 MechanismCollisionError
if the specified decision ID
is already in use.
3703 def addTransition( 3704 self, 3705 fromDecision: base.AnyDecisionSpecifier, 3706 name: base.Transition, 3707 toDecision: base.AnyDecisionSpecifier, 3708 reciprocal: Optional[base.Transition] = None, 3709 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 3710 annotations: Optional[List[base.Annotation]] = None, 3711 revTags: Optional[Dict[base.Tag, base.TagValue]] = None, 3712 revAnnotations: Optional[List[base.Annotation]] = None, 3713 requires: Optional[base.Requirement] = None, 3714 consequence: Optional[base.Consequence] = None, 3715 revRequires: Optional[base.Requirement] = None, 3716 revConsequece: Optional[base.Consequence] = None 3717 ) -> None: 3718 """ 3719 Adds a transition connecting two decisions. A specifier for each 3720 decision is required, as is a name for the transition. If a 3721 `reciprocal` is provided, a reciprocal edge will be added in the 3722 opposite direction using that name; by default only the specified 3723 edge is added. A `TransitionCollisionError` will be raised if the 3724 `reciprocal` matches the name of an existing edge at the 3725 destination decision. 3726 3727 Both decisions must already exist, or a `MissingDecisionError` 3728 will be raised. 3729 3730 A dictionary of tags and/or a list of annotations may be 3731 provided. Tags and/or annotations for the reverse edge may also 3732 be specified if one is being added. 3733 3734 The `requires`, `consequence`, `revRequires`, and `revConsequece` 3735 arguments specify requirements and/or consequences of the new 3736 outgoing and reciprocal edges. 3737 """ 3738 # Defaults 3739 if tags is None: 3740 tags = {} 3741 if annotations is None: 3742 annotations = [] 3743 if revTags is None: 3744 revTags = {} 3745 if revAnnotations is None: 3746 revAnnotations = [] 3747 3748 # Error checking 3749 fromID = self.resolveDecision(fromDecision) 3750 toID = self.resolveDecision(toDecision) 3751 3752 # Note: have to check this first so we don't add the forward edge 3753 # and then error out after a side effect! 3754 if ( 3755 reciprocal is not None 3756 and self.getDestination(toDecision, reciprocal) is not None 3757 ): 3758 raise TransitionCollisionError( 3759 f"Cannot add a transition from" 3760 f" {self.identityOf(fromDecision)} to" 3761 f" {self.identityOf(toDecision)} with reciprocal edge" 3762 f" {reciprocal!r}: {reciprocal!r} is already used as an" 3763 f" edge name at {self.identityOf(toDecision)}." 3764 ) 3765 3766 # Add the edge 3767 self.add_edge( 3768 fromID, 3769 toID, 3770 key=name, 3771 tags=tags, 3772 annotations=annotations 3773 ) 3774 self.setTransitionRequirement(fromDecision, name, requires) 3775 if consequence is not None: 3776 self.setConsequence(fromDecision, name, consequence) 3777 if reciprocal is not None: 3778 # Add the reciprocal edge 3779 self.add_edge( 3780 toID, 3781 fromID, 3782 key=reciprocal, 3783 tags=revTags, 3784 annotations=revAnnotations 3785 ) 3786 self.setReciprocal(fromID, name, reciprocal) 3787 self.setTransitionRequirement( 3788 toDecision, 3789 reciprocal, 3790 revRequires 3791 ) 3792 if revConsequece is not None: 3793 self.setConsequence(toDecision, 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.
3795 def removeTransition( 3796 self, 3797 fromDecision: base.AnyDecisionSpecifier, 3798 transition: base.Transition, 3799 removeReciprocal=False 3800 ) -> Union[ 3801 TransitionProperties, 3802 Tuple[TransitionProperties, TransitionProperties] 3803 ]: 3804 """ 3805 Removes a transition. If `removeReciprocal` is true (False is the 3806 default) any reciprocal transition will also be removed (but no 3807 error will occur if there wasn't a reciprocal). 3808 3809 For each removed transition, *every* transition that targeted 3810 that transition as its reciprocal will have its reciprocal set to 3811 `None`, to avoid leaving any invalid reciprocal values. 3812 3813 Raises a `KeyError` if either the target decision or the target 3814 transition does not exist. 3815 3816 Returns a transition properties dictionary with the properties 3817 of the removed transition, or if `removeReciprocal` is true, 3818 returns a pair of such dictionaries for the target transition 3819 and its reciprocal. 3820 3821 ## Example 3822 3823 >>> g = DecisionGraph() 3824 >>> g.addDecision('A') 3825 0 3826 >>> g.addDecision('B') 3827 1 3828 >>> g.addTransition('A', 'up', 'B', 'down', tags={'wide'}) 3829 >>> g.addTransition('A', 'in', 'B', 'out') # we won't touch this 3830 >>> g.addTransition('A', 'next', 'B') 3831 >>> g.setReciprocal('A', 'next', 'down', setBoth=False) 3832 >>> p = g.removeTransition('A', 'up') 3833 >>> p['tags'] 3834 {'wide'} 3835 >>> g.destinationsFrom('A') 3836 {'in': 1, 'next': 1} 3837 >>> g.destinationsFrom('B') 3838 {'down': 0, 'out': 0} 3839 >>> g.getReciprocal('B', 'down') is None 3840 True 3841 >>> g.getReciprocal('A', 'next') # Asymmetrical left over 3842 'down' 3843 >>> g.getReciprocal('A', 'in') # not affected 3844 'out' 3845 >>> g.getReciprocal('B', 'out') # not affected 3846 'in' 3847 >>> # Now with removeReciprocal set to True 3848 >>> g.addTransition('A', 'up', 'B') # add this back in 3849 >>> g.setReciprocal('A', 'up', 'down') # sets both 3850 >>> p = g.removeTransition('A', 'up', removeReciprocal=True) 3851 >>> g.destinationsFrom('A') 3852 {'in': 1, 'next': 1} 3853 >>> g.destinationsFrom('B') 3854 {'out': 0} 3855 >>> g.getReciprocal('A', 'next') is None 3856 True 3857 >>> g.getReciprocal('A', 'in') # not affected 3858 'out' 3859 >>> g.getReciprocal('B', 'out') # not affected 3860 'in' 3861 >>> g.removeTransition('A', 'none') 3862 Traceback (most recent call last): 3863 ... 3864 exploration.core.MissingTransitionError... 3865 >>> g.removeTransition('Z', 'nope') 3866 Traceback (most recent call last): 3867 ... 3868 exploration.core.MissingDecisionError... 3869 """ 3870 # Resolve target ID 3871 fromID = self.resolveDecision(fromDecision) 3872 3873 # raises if either is missing: 3874 destination = self.destination(fromID, transition) 3875 reciprocal = self.getReciprocal(fromID, transition) 3876 3877 # Get dictionaries of parallel & antiparallel edges to be 3878 # checked for invalid reciprocals after removing edges 3879 # Note: these will update live as we remove edges 3880 allAntiparallel = self[destination][fromID] 3881 allParallel = self[fromID][destination] 3882 3883 # Remove the target edge 3884 fProps = self.getTransitionProperties(fromID, transition) 3885 self.remove_edge(fromID, destination, transition) 3886 3887 # Clean up any dangling reciprocal values 3888 for tProps in allAntiparallel.values(): 3889 if tProps.get('reciprocal') == transition: 3890 del tProps['reciprocal'] 3891 3892 # Remove the reciprocal if requested 3893 if removeReciprocal and reciprocal is not None: 3894 rProps = self.getTransitionProperties(destination, reciprocal) 3895 self.remove_edge(destination, fromID, reciprocal) 3896 3897 # Clean up any dangling reciprocal values 3898 for tProps in allParallel.values(): 3899 if tProps.get('reciprocal') == reciprocal: 3900 del tProps['reciprocal'] 3901 3902 return (fProps, rProps) 3903 else: 3904 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...
3906 def addMechanism( 3907 self, 3908 name: base.MechanismName, 3909 where: Optional[base.AnyDecisionSpecifier] = None 3910 ) -> base.MechanismID: 3911 """ 3912 Creates a new mechanism with the given name at the specified 3913 decision, returning its assigned ID. If `where` is `None`, it 3914 creates a global mechanism. Raises a `MechanismCollisionError` 3915 if a mechanism with the same name already exists at a specified 3916 decision (or already exists as a global mechanism). 3917 3918 Note that if the decision is deleted, the mechanism will be as 3919 well. 3920 3921 Since `MechanismState`s are not tracked by `DecisionGraph`s but 3922 instead are part of a `State`, the mechanism won't be in any 3923 particular state, which means it will be treated as being in the 3924 `base.DEFAULT_MECHANISM_STATE`. 3925 """ 3926 if where is None: 3927 mechs = self.globalMechanisms 3928 dID = None 3929 else: 3930 dID = self.resolveDecision(where) 3931 mechs = self.nodes[dID].setdefault('mechanisms', {}) 3932 3933 if name in mechs: 3934 if dID is None: 3935 raise MechanismCollisionError( 3936 f"A global mechanism named {name!r} already exists." 3937 ) 3938 else: 3939 raise MechanismCollisionError( 3940 f"A mechanism named {name!r} already exists at" 3941 f" decision {self.identityOf(dID)}." 3942 ) 3943 3944 mID = self._assignMechanismID() 3945 mechs[name] = mID 3946 self.mechanisms[mID] = (dID, name) 3947 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 MechanismState
s are not tracked by DecisionGraph
s 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
.
3949 def mechanismsAt( 3950 self, 3951 decision: base.AnyDecisionSpecifier 3952 ) -> Dict[base.MechanismName, base.MechanismID]: 3953 """ 3954 Returns a dictionary mapping mechanism names to their IDs for 3955 all mechanisms at the specified decision. 3956 """ 3957 dID = self.resolveDecision(decision) 3958 3959 return self.nodes[dID]['mechanisms']
Returns a dictionary mapping mechanism names to their IDs for all mechanisms at the specified decision.
3961 def mechanismDetails( 3962 self, 3963 mID: base.MechanismID 3964 ) -> Optional[Tuple[Optional[base.DecisionID], base.MechanismName]]: 3965 """ 3966 Returns a tuple containing the decision ID and mechanism name 3967 for the specified mechanism. Returns `None` if there is no 3968 mechanism with that ID. For global mechanisms, `None` is used in 3969 place of a decision ID. 3970 """ 3971 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.
3973 def deleteMechanism(self, mID: base.MechanismID) -> None: 3974 """ 3975 Deletes the specified mechanism. 3976 """ 3977 name, dID = self.mechanisms.pop(mID) 3978 3979 del self.nodes[dID]['mechanisms'][name]
Deletes the specified mechanism.
3981 def localLookup( 3982 self, 3983 startFrom: Union[ 3984 base.AnyDecisionSpecifier, 3985 Collection[base.AnyDecisionSpecifier] 3986 ], 3987 findAmong: Callable[ 3988 ['DecisionGraph', Union[Set[base.DecisionID], str]], 3989 Optional[LookupResult] 3990 ], 3991 fallbackLayerName: Optional[str] = "fallback", 3992 fallbackToAllDecisions: bool = True 3993 ) -> Optional[LookupResult]: 3994 """ 3995 Looks up some kind of result in the graph by starting from a 3996 base set of decisions and widening the search iteratively based 3997 on zones. This first searches for result(s) in the set of 3998 decisions given, then in the set of all decisions which are in 3999 level-0 zones containing those decisions, then in level-1 zones, 4000 etc. When it runs out of relevant zones, it will check all 4001 decisions which are in any domain that a decision from the 4002 initial search set is in, and then if `fallbackLayerName` is a 4003 string, it will provide that string instead of a set of decision 4004 IDs to the `findAmong` function as the next layer to search. 4005 After the `fallbackLayerName` is used, if 4006 `fallbackToAllDecisions` is `True` (the default) a final search 4007 will be run on all decisions in the graph. The provided 4008 `findAmong` function is called on each successive decision ID 4009 set, until it generates a non-`None` result. We stop and return 4010 that non-`None` result as soon as one is generated. But if none 4011 of the decision sets consulted generate non-`None` results, then 4012 the entire result will be `None`. 4013 """ 4014 # Normalize starting decisions to a set 4015 if isinstance(startFrom, (int, str, base.DecisionSpecifier)): 4016 startFrom = set([startFrom]) 4017 4018 # Resolve decision IDs; convert to list 4019 searchArea: Union[Set[base.DecisionID], str] = set( 4020 self.resolveDecision(spec) for spec in startFrom 4021 ) 4022 4023 # Find all ancestor zones & all relevant domains 4024 allAncestors = set() 4025 relevantDomains = set() 4026 for startingDecision in searchArea: 4027 allAncestors |= self.zoneAncestors(startingDecision) 4028 relevantDomains.add(self.domainFor(startingDecision)) 4029 4030 # Build layers dictionary 4031 ancestorLayers: Dict[int, Set[base.Zone]] = {} 4032 for zone in allAncestors: 4033 info = self.getZoneInfo(zone) 4034 assert info is not None 4035 level = info.level 4036 ancestorLayers.setdefault(level, set()).add(zone) 4037 4038 searchLayers: LookupLayersList = ( 4039 cast(LookupLayersList, [None]) 4040 + cast(LookupLayersList, sorted(ancestorLayers.keys())) 4041 + cast(LookupLayersList, ["domains"]) 4042 ) 4043 if fallbackLayerName is not None: 4044 searchLayers.append("fallback") 4045 4046 if fallbackToAllDecisions: 4047 searchLayers.append("all") 4048 4049 # Continue our search through zone layers 4050 for layer in searchLayers: 4051 # Update search area on subsequent iterations 4052 if layer == "domains": 4053 searchArea = set() 4054 for relevant in relevantDomains: 4055 searchArea |= self.allDecisionsInDomain(relevant) 4056 elif layer == "fallback": 4057 assert fallbackLayerName is not None 4058 searchArea = fallbackLayerName 4059 elif layer == "all": 4060 searchArea = set(self.nodes) 4061 elif layer is not None: 4062 layer = cast(int, layer) # must be an integer 4063 searchZones = ancestorLayers[layer] 4064 searchArea = set() 4065 for zone in searchZones: 4066 searchArea |= self.allDecisionsInZone(zone) 4067 # else it's the first iteration and we use the starting 4068 # searchArea 4069 4070 searchResult: Optional[LookupResult] = findAmong( 4071 self, 4072 searchArea 4073 ) 4074 4075 if searchResult is not None: 4076 return searchResult 4077 4078 # Didn't find any non-None results. 4079 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
.
4081 @staticmethod 4082 def uniqueMechanismFinder(name: base.MechanismName) -> Callable[ 4083 ['DecisionGraph', Union[Set[base.DecisionID], str]], 4084 Optional[base.MechanismID] 4085 ]: 4086 """ 4087 Returns a search function that looks for the given mechanism ID, 4088 suitable for use with `localLookup`. The finder will raise a 4089 `MechanismCollisionError` if it finds more than one mechanism 4090 with the specified name at the same level of the search. 4091 """ 4092 def namedMechanismFinder( 4093 graph: 'DecisionGraph', 4094 searchIn: Union[Set[base.DecisionID], str] 4095 ) -> Optional[base.MechanismID]: 4096 """ 4097 Generated finder function for `localLookup` to find a unique 4098 mechanism by name. 4099 """ 4100 candidates: List[base.DecisionID] = [] 4101 4102 if searchIn == "fallback": 4103 if name in graph.globalMechanisms: 4104 candidates = [graph.globalMechanisms[name]] 4105 4106 else: 4107 assert isinstance(searchIn, set) 4108 for dID in searchIn: 4109 mechs = graph.nodes[dID].get('mechanisms', {}) 4110 if name in mechs: 4111 candidates.append(mechs[name]) 4112 4113 if len(candidates) > 1: 4114 raise MechanismCollisionError( 4115 f"There are {len(candidates)} mechanisms named {name!r}" 4116 f" in the search area ({len(searchIn)} decisions(s))." 4117 ) 4118 elif len(candidates) == 1: 4119 return candidates[0] 4120 else: 4121 return None 4122 4123 return namedMechanismFinder
Returns a search function that looks for the given mechanism ID,
suitable for use with localLookup
. The finder will raise a
MechanismCollisionError
if it finds more than one mechanism
with the specified name at the same level of the search.
4125 def lookupMechanism( 4126 self, 4127 startFrom: Union[ 4128 base.AnyDecisionSpecifier, 4129 Collection[base.AnyDecisionSpecifier] 4130 ], 4131 name: base.MechanismName 4132 ) -> base.MechanismID: 4133 """ 4134 Looks up the mechanism with the given name 'closest' to the 4135 given decision or set of decisions. First it looks for a 4136 mechanism with that name that's at one of those decisions. Then 4137 it starts looking in level-0 zones which contain any of them, 4138 then in level-1 zones, and so on. If it finds two mechanisms 4139 with the target name during the same search pass, it raises a 4140 `MechanismCollisionError`, but if it finds one it returns it. 4141 Raises a `MissingMechanismError` if there is no mechanisms with 4142 that name among global mechanisms (searched after the last 4143 applicable level of zones) or anywhere in the graph (which is the 4144 final level of search after checking global mechanisms). 4145 4146 For example: 4147 4148 >>> d = DecisionGraph() 4149 >>> d.addDecision('A') 4150 0 4151 >>> d.addDecision('B') 4152 1 4153 >>> d.addDecision('C') 4154 2 4155 >>> d.addDecision('D') 4156 3 4157 >>> d.addDecision('E') 4158 4 4159 >>> d.addMechanism('switch', 'A') 4160 0 4161 >>> d.addMechanism('switch', 'B') 4162 1 4163 >>> d.addMechanism('switch', 'C') 4164 2 4165 >>> d.addMechanism('lever', 'D') 4166 3 4167 >>> d.addMechanism('lever', None) # global 4168 4 4169 >>> d.createZone('Z1', 0) 4170 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 4171 annotations=[]) 4172 >>> d.createZone('Z2', 0) 4173 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 4174 annotations=[]) 4175 >>> d.createZone('Zup', 1) 4176 ZoneInfo(level=1, parents=set(), contents=set(), tags={},\ 4177 annotations=[]) 4178 >>> d.addDecisionToZone('A', 'Z1') 4179 >>> d.addDecisionToZone('B', 'Z1') 4180 >>> d.addDecisionToZone('C', 'Z2') 4181 >>> d.addDecisionToZone('D', 'Z2') 4182 >>> d.addDecisionToZone('E', 'Z1') 4183 >>> d.addZoneToZone('Z1', 'Zup') 4184 >>> d.addZoneToZone('Z2', 'Zup') 4185 >>> d.lookupMechanism(set(), 'switch') # 3x among all decisions 4186 Traceback (most recent call last): 4187 ... 4188 exploration.core.MechanismCollisionError... 4189 >>> d.lookupMechanism(set(), 'lever') # 1x global > 1x all 4190 4 4191 >>> d.lookupMechanism({'D'}, 'lever') # local 4192 3 4193 >>> d.lookupMechanism({'A'}, 'lever') # found at D via Zup 4194 3 4195 >>> d.lookupMechanism({'A', 'D'}, 'lever') # local again 4196 3 4197 >>> d.lookupMechanism({'A'}, 'switch') # local 4198 0 4199 >>> d.lookupMechanism({'B'}, 'switch') # local 4200 1 4201 >>> d.lookupMechanism({'C'}, 'switch') # local 4202 2 4203 >>> d.lookupMechanism({'A', 'B'}, 'switch') # ambiguous 4204 Traceback (most recent call last): 4205 ... 4206 exploration.core.MechanismCollisionError... 4207 >>> d.lookupMechanism({'A', 'B', 'C'}, 'switch') # ambiguous 4208 Traceback (most recent call last): 4209 ... 4210 exploration.core.MechanismCollisionError... 4211 >>> d.lookupMechanism({'B', 'D'}, 'switch') # not ambiguous 4212 1 4213 >>> d.lookupMechanism({'E', 'D'}, 'switch') # ambiguous at L0 zone 4214 Traceback (most recent call last): 4215 ... 4216 exploration.core.MechanismCollisionError... 4217 >>> d.lookupMechanism({'E'}, 'switch') # ambiguous at L0 zone 4218 Traceback (most recent call last): 4219 ... 4220 exploration.core.MechanismCollisionError... 4221 >>> d.lookupMechanism({'D'}, 'switch') # found at L0 zone 4222 2 4223 """ 4224 result = self.localLookup( 4225 startFrom, 4226 DecisionGraph.uniqueMechanismFinder(name) 4227 ) 4228 if result is None: 4229 raise MissingMechanismError( 4230 f"No mechanism named {name!r}" 4231 ) 4232 else: 4233 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
MechanismCollisionError
, 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):
...
MechanismCollisionError...
>>> 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):
...
MechanismCollisionError...
>>> d.lookupMechanism({'A', 'B', 'C'}, 'switch') # ambiguous
Traceback (most recent call last):
...
MechanismCollisionError...
>>> d.lookupMechanism({'B', 'D'}, 'switch') # not ambiguous
1
>>> d.lookupMechanism({'E', 'D'}, 'switch') # ambiguous at L0 zone
Traceback (most recent call last):
...
MechanismCollisionError...
>>> d.lookupMechanism({'E'}, 'switch') # ambiguous at L0 zone
Traceback (most recent call last):
...
MechanismCollisionError...
>>> d.lookupMechanism({'D'}, 'switch') # found at L0 zone
2
4235 def resolveMechanism( 4236 self, 4237 specifier: base.AnyMechanismSpecifier, 4238 startFrom: Union[ 4239 None, 4240 base.AnyDecisionSpecifier, 4241 Collection[base.AnyDecisionSpecifier] 4242 ] = None 4243 ) -> base.MechanismID: 4244 """ 4245 Works like `lookupMechanism`, except it accepts a 4246 `base.AnyMechanismSpecifier` which may have position information 4247 baked in, and so the `startFrom` information is optional. If 4248 position information isn't specified in the mechanism specifier 4249 and startFrom is not provided, the mechanism is searched for at 4250 the global scope and then in the entire graph. On the other 4251 hand, if the specifier includes any position information, the 4252 startFrom value provided here will be ignored. 4253 """ 4254 if isinstance(specifier, base.MechanismID): 4255 return specifier 4256 4257 elif isinstance(specifier, base.MechanismName): 4258 if startFrom is None: 4259 startFrom = set() 4260 return self.lookupMechanism(startFrom, specifier) 4261 4262 elif isinstance(specifier, tuple) and len(specifier) == 4: 4263 domain, zone, decision, mechanism = specifier 4264 if domain is None and zone is None and decision is None: 4265 if startFrom is None: 4266 startFrom = set() 4267 return self.lookupMechanism(startFrom, mechanism) 4268 4269 elif decision is not None: 4270 startFrom = { 4271 self.resolveDecision( 4272 base.DecisionSpecifier(domain, zone, decision) 4273 ) 4274 } 4275 return self.lookupMechanism(startFrom, mechanism) 4276 4277 else: # decision is None but domain and/or zone aren't 4278 startFrom = set() 4279 if zone is not None: 4280 baseStart = self.allDecisionsInZone(zone) 4281 else: 4282 baseStart = set(self) 4283 4284 if domain is None: 4285 startFrom = baseStart 4286 else: 4287 for dID in baseStart: 4288 if self.domainFor(dID) == domain: 4289 startFrom.add(dID) 4290 return self.lookupMechanism(startFrom, mechanism) 4291 4292 else: 4293 raise TypeError( 4294 f"Invalid mechanism specifier: {repr(specifier)}" 4295 f"\n(Must be a mechanism ID, mechanism name, or" 4296 f" mechanism specifier tuple)" 4297 )
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.
4299 def walkConsequenceMechanisms( 4300 self, 4301 consequence: base.Consequence, 4302 searchFrom: Set[base.DecisionID] 4303 ) -> Generator[base.MechanismID, None, None]: 4304 """ 4305 Yields each requirement in the given `base.Consequence`, 4306 including those in `base.Condition`s, `base.ConditionalSkill`s 4307 within `base.Challenge`s, and those set or toggled by 4308 `base.Effect`s. The `searchFrom` argument specifies where to 4309 start searching for mechanisms, since requirements include them 4310 by name, not by ID. 4311 """ 4312 for part in base.walkParts(consequence): 4313 if isinstance(part, dict): 4314 if 'skills' in part: # a Challenge 4315 for cSkill in part['skills'].walk(): 4316 if isinstance(cSkill, base.ConditionalSkill): 4317 yield from self.walkRequirementMechanisms( 4318 cSkill.requirement, 4319 searchFrom 4320 ) 4321 elif 'condition' in part: # a Condition 4322 yield from self.walkRequirementMechanisms( 4323 part['condition'], 4324 searchFrom 4325 ) 4326 elif 'value' in part: # an Effect 4327 val = part['value'] 4328 if part['type'] == 'set': 4329 if ( 4330 isinstance(val, tuple) 4331 and len(val) == 2 4332 and isinstance(val[1], base.State) 4333 ): 4334 yield from self.walkRequirementMechanisms( 4335 base.ReqMechanism(val[0], val[1]), 4336 searchFrom 4337 ) 4338 elif part['type'] == 'toggle': 4339 if isinstance(val, tuple): 4340 assert len(val) == 2 4341 yield from self.walkRequirementMechanisms( 4342 base.ReqMechanism(val[0], '_'), 4343 # state part is ignored here 4344 searchFrom 4345 )
Yields each requirement in the given base.Consequence
,
including those in base.Condition
s, base.ConditionalSkill
s
within base.Challenge
s, and those set or toggled by
base.Effect
s. The searchFrom
argument specifies where to
start searching for mechanisms, since requirements include them
by name, not by ID.
4347 def walkRequirementMechanisms( 4348 self, 4349 req: base.Requirement, 4350 searchFrom: Set[base.DecisionID] 4351 ) -> Generator[base.MechanismID, None, None]: 4352 """ 4353 Given a requirement, yields any mechanisms mentioned in that 4354 requirement, in depth-first traversal order. 4355 """ 4356 for part in req.walk(): 4357 if isinstance(part, base.ReqMechanism): 4358 mech = part.mechanism 4359 yield self.resolveMechanism( 4360 mech, 4361 startFrom=searchFrom 4362 )
Given a requirement, yields any mechanisms mentioned in that requirement, in depth-first traversal order.
4364 def addUnexploredEdge( 4365 self, 4366 fromDecision: base.AnyDecisionSpecifier, 4367 name: base.Transition, 4368 destinationName: Optional[base.DecisionName] = None, 4369 reciprocal: Optional[base.Transition] = 'return', 4370 toDomain: Optional[base.Domain] = None, 4371 placeInZone: Optional[base.Zone] = None, 4372 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 4373 annotations: Optional[List[base.Annotation]] = None, 4374 revTags: Optional[Dict[base.Tag, base.TagValue]] = None, 4375 revAnnotations: Optional[List[base.Annotation]] = None, 4376 requires: Optional[base.Requirement] = None, 4377 consequence: Optional[base.Consequence] = None, 4378 revRequires: Optional[base.Requirement] = None, 4379 revConsequece: Optional[base.Consequence] = None 4380 ) -> base.DecisionID: 4381 """ 4382 Adds a transition connecting to a new decision named `'_u.-n-'` 4383 where '-n-' is the number of unknown decisions (named or not) 4384 that have ever been created in this graph (or using the 4385 specified destination name if one is provided). This represents 4386 a transition to an unknown destination. The destination node 4387 gets tagged 'unconfirmed'. 4388 4389 This also adds a reciprocal transition in the reverse direction, 4390 unless `reciprocal` is set to `None`. The reciprocal will use 4391 the provided name (default is 'return'). The new decision will 4392 be in the same domain as the decision it's connected to, unless 4393 `toDecision` is specified, in which case it will be in that 4394 domain. 4395 4396 The new decision will not be placed into any zones, unless 4397 `placeInZone` is specified, in which case it will be placed into 4398 that zone. If that zone needs to be created, it will be created 4399 at level 0; in that case that zone will be added to any 4400 grandparent zones of the decision we're branching off of. If 4401 `placeInZone` is set to `base.DefaultZone`, then the new 4402 decision will be placed into each parent zone of the decision 4403 we're branching off of, as long as the new decision is in the 4404 same domain as the decision we're branching from (otherwise only 4405 an explicit `placeInZone` would apply). 4406 4407 The ID of the decision that was created is returned. 4408 4409 A `MissingDecisionError` will be raised if the starting decision 4410 does not exist, a `TransitionCollisionError` will be raised if 4411 it exists but already has a transition with the given name, and a 4412 `DecisionCollisionWarning` will be issued if a decision with the 4413 specified destination name already exists (won't happen when 4414 using an automatic name). 4415 4416 Lists of tags and/or annotations (strings in both cases) may be 4417 provided. These may also be provided for the reciprocal edge. 4418 4419 Similarly, requirements and/or consequences for either edge may 4420 be provided. 4421 4422 ## Example 4423 4424 >>> g = DecisionGraph() 4425 >>> g.addDecision('A') 4426 0 4427 >>> g.addUnexploredEdge('A', 'up') 4428 1 4429 >>> g.nameFor(1) 4430 '_u.0' 4431 >>> g.decisionTags(1) 4432 {'unconfirmed': 1} 4433 >>> g.addUnexploredEdge('A', 'right', 'B') 4434 2 4435 >>> g.nameFor(2) 4436 'B' 4437 >>> g.decisionTags(2) 4438 {'unconfirmed': 1} 4439 >>> g.addUnexploredEdge('A', 'down', None, 'up') 4440 3 4441 >>> g.nameFor(3) 4442 '_u.2' 4443 >>> g.addUnexploredEdge( 4444 ... '_u.0', 4445 ... 'beyond', 4446 ... toDomain='otherDomain', 4447 ... tags={'fast':1}, 4448 ... revTags={'slow':1}, 4449 ... annotations=['comment'], 4450 ... revAnnotations=['one', 'two'], 4451 ... requires=base.ReqCapability('dash'), 4452 ... revRequires=base.ReqCapability('super dash'), 4453 ... consequence=[base.effect(gain='super dash')], 4454 ... revConsequece=[base.effect(lose='super dash')] 4455 ... ) 4456 4 4457 >>> g.nameFor(4) 4458 '_u.3' 4459 >>> g.domainFor(4) 4460 'otherDomain' 4461 >>> g.transitionTags('_u.0', 'beyond') 4462 {'fast': 1} 4463 >>> g.transitionAnnotations('_u.0', 'beyond') 4464 ['comment'] 4465 >>> g.getTransitionRequirement('_u.0', 'beyond') 4466 ReqCapability('dash') 4467 >>> e = g.getConsequence('_u.0', 'beyond') 4468 >>> e == [base.effect(gain='super dash')] 4469 True 4470 >>> g.transitionTags('_u.3', 'return') 4471 {'slow': 1} 4472 >>> g.transitionAnnotations('_u.3', 'return') 4473 ['one', 'two'] 4474 >>> g.getTransitionRequirement('_u.3', 'return') 4475 ReqCapability('super dash') 4476 >>> e = g.getConsequence('_u.3', 'return') 4477 >>> e == [base.effect(lose='super dash')] 4478 True 4479 """ 4480 # Defaults 4481 if tags is None: 4482 tags = {} 4483 if annotations is None: 4484 annotations = [] 4485 if revTags is None: 4486 revTags = {} 4487 if revAnnotations is None: 4488 revAnnotations = [] 4489 4490 # Resolve ID 4491 fromID = self.resolveDecision(fromDecision) 4492 if toDomain is None: 4493 toDomain = self.domainFor(fromID) 4494 4495 if name in self.destinationsFrom(fromID): 4496 raise TransitionCollisionError( 4497 f"Cannot add a new edge {name!r}:" 4498 f" {self.identityOf(fromDecision)} already has an" 4499 f" outgoing edge with that name." 4500 ) 4501 4502 if destinationName in self.nameLookup and WARN_OF_NAME_COLLISIONS: 4503 warnings.warn( 4504 ( 4505 f"Cannot add a new unexplored node" 4506 f" {destinationName!r}: A decision with that name" 4507 f" already exists.\n(Leave destinationName as None" 4508 f" to use an automatic name.)" 4509 ), 4510 DecisionCollisionWarning 4511 ) 4512 4513 # Create the new unexplored decision and add the edge 4514 if destinationName is None: 4515 toName = '_u.' + str(self.unknownCount) 4516 else: 4517 toName = destinationName 4518 self.unknownCount += 1 4519 newID = self.addDecision(toName, domain=toDomain) 4520 self.addTransition( 4521 fromID, 4522 name, 4523 newID, 4524 tags=tags, 4525 annotations=annotations 4526 ) 4527 self.setTransitionRequirement(fromID, name, requires) 4528 if consequence is not None: 4529 self.setConsequence(fromID, name, consequence) 4530 4531 # Add it to a zone if requested 4532 if ( 4533 placeInZone == base.DefaultZone 4534 and toDomain == self.domainFor(fromID) 4535 ): 4536 # Add to each parent of the from decision 4537 for parent in self.zoneParents(fromID): 4538 self.addDecisionToZone(newID, parent) 4539 elif placeInZone is not None: 4540 # Otherwise add it to one specific zone, creating that zone 4541 # at level 0 if necessary 4542 assert isinstance(placeInZone, base.Zone) 4543 if self.getZoneInfo(placeInZone) is None: 4544 self.createZone(placeInZone, 0) 4545 # Add new zone to each grandparent of the from decision 4546 for parent in self.zoneParents(fromID): 4547 for grandparent in self.zoneParents(parent): 4548 self.addZoneToZone(placeInZone, grandparent) 4549 self.addDecisionToZone(newID, placeInZone) 4550 4551 # Create the reciprocal edge 4552 if reciprocal is not None: 4553 self.addTransition( 4554 newID, 4555 reciprocal, 4556 fromID, 4557 tags=revTags, 4558 annotations=revAnnotations 4559 ) 4560 self.setTransitionRequirement(newID, reciprocal, revRequires) 4561 if revConsequece is not None: 4562 self.setConsequence(newID, reciprocal, revConsequece) 4563 # Set as a reciprocal 4564 self.setReciprocal(fromID, name, reciprocal) 4565 4566 # Tag the destination as 'unconfirmed' 4567 self.tagDecision(newID, 'unconfirmed') 4568 4569 # Return ID of new destination 4570 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 set to None
. The reciprocal will use
the provided name (default is 'return'). 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.addUnexploredEdge('A', 'right', 'B')
2
>>> g.nameFor(2)
'B'
>>> g.decisionTags(2)
{'unconfirmed': 1}
>>> g.addUnexploredEdge('A', 'down', None, 'up')
3
>>> g.nameFor(3)
'_u.2'
>>> g.addUnexploredEdge(
... '_u.0',
... 'beyond',
... 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
4572 def retargetTransition( 4573 self, 4574 fromDecision: base.AnyDecisionSpecifier, 4575 transition: base.Transition, 4576 newDestination: base.AnyDecisionSpecifier, 4577 swapReciprocal=True, 4578 errorOnNameColision=True 4579 ) -> Optional[base.Transition]: 4580 """ 4581 Given a particular decision and a transition at that decision, 4582 changes that transition so that it goes to the specified new 4583 destination instead of wherever it was connected to before. If 4584 the new destination is the same as the old one, no changes are 4585 made. 4586 4587 If `swapReciprocal` is set to True (the default) then any 4588 reciprocal edge at the old destination will be deleted, and a 4589 new reciprocal edge from the new destination with equivalent 4590 properties to the original reciprocal will be created, pointing 4591 to the origin of the specified transition. If `swapReciprocal` 4592 is set to False, then the reciprocal relationship with any old 4593 reciprocal edge will be removed, but the old reciprocal edge 4594 will not be changed. 4595 4596 Note that if `errorOnNameColision` is True (the default), then 4597 if the reciprocal transition has the same name as a transition 4598 which already exists at the new destination node, a 4599 `TransitionCollisionError` will be thrown. However, if it is set 4600 to False, the reciprocal transition will be renamed with a suffix 4601 to avoid any possible name collisions. Either way, the name of 4602 the reciprocal transition (possibly just changed) will be 4603 returned, or None if there was no reciprocal transition. 4604 4605 ## Example 4606 4607 >>> g = DecisionGraph() 4608 >>> for fr, to, nm in [ 4609 ... ('A', 'B', 'up'), 4610 ... ('A', 'B', 'up2'), 4611 ... ('B', 'A', 'down'), 4612 ... ('B', 'B', 'self'), 4613 ... ('B', 'C', 'next'), 4614 ... ('C', 'B', 'prev') 4615 ... ]: 4616 ... if g.getDecision(fr) is None: 4617 ... g.addDecision(fr) 4618 ... if g.getDecision(to) is None: 4619 ... g.addDecision(to) 4620 ... g.addTransition(fr, nm, to) 4621 0 4622 1 4623 2 4624 >>> g.setReciprocal('A', 'up', 'down') 4625 >>> g.setReciprocal('B', 'next', 'prev') 4626 >>> g.destination('A', 'up') 4627 1 4628 >>> g.destination('B', 'down') 4629 0 4630 >>> g.retargetTransition('A', 'up', 'C') 4631 'down' 4632 >>> g.destination('A', 'up') 4633 2 4634 >>> g.getDestination('B', 'down') is None 4635 True 4636 >>> g.destination('C', 'down') 4637 0 4638 >>> g.addTransition('A', 'next', 'B') 4639 >>> g.addTransition('B', 'prev', 'A') 4640 >>> g.setReciprocal('A', 'next', 'prev') 4641 >>> # Can't swap a reciprocal in a way that would collide names 4642 >>> g.getReciprocal('C', 'prev') 4643 'next' 4644 >>> g.retargetTransition('C', 'prev', 'A') 4645 Traceback (most recent call last): 4646 ... 4647 exploration.core.TransitionCollisionError... 4648 >>> g.retargetTransition('C', 'prev', 'A', swapReciprocal=False) 4649 'next' 4650 >>> g.destination('C', 'prev') 4651 0 4652 >>> g.destination('A', 'next') # not changed 4653 1 4654 >>> # Reciprocal relationship is severed: 4655 >>> g.getReciprocal('C', 'prev') is None 4656 True 4657 >>> g.getReciprocal('B', 'next') is None 4658 True 4659 >>> # Swap back so we can do another demo 4660 >>> g.retargetTransition('C', 'prev', 'B', swapReciprocal=False) 4661 >>> # Note return value was None here because there was no reciprocal 4662 >>> g.setReciprocal('C', 'prev', 'next') 4663 >>> # Swap reciprocal by renaming it 4664 >>> g.retargetTransition('C', 'prev', 'A', errorOnNameColision=False) 4665 'next.1' 4666 >>> g.getReciprocal('C', 'prev') 4667 'next.1' 4668 >>> g.destination('C', 'prev') 4669 0 4670 >>> g.destination('A', 'next.1') 4671 2 4672 >>> g.destination('A', 'next') 4673 1 4674 >>> # Note names are the same but these are from different nodes 4675 >>> g.getReciprocal('A', 'next') 4676 'prev' 4677 >>> g.getReciprocal('A', 'next.1') 4678 'prev' 4679 """ 4680 fromID = self.resolveDecision(fromDecision) 4681 newDestID = self.resolveDecision(newDestination) 4682 4683 # Figure out the old destination of the transition we're swapping 4684 oldDestID = self.destination(fromID, transition) 4685 reciprocal = self.getReciprocal(fromID, transition) 4686 4687 # If thew new destination is the same, we don't do anything! 4688 if oldDestID == newDestID: 4689 return reciprocal 4690 4691 # First figure out reciprocal business so we can error out 4692 # without making changes if we need to 4693 if swapReciprocal and reciprocal is not None: 4694 reciprocal = self.rebaseTransition( 4695 oldDestID, 4696 reciprocal, 4697 newDestID, 4698 swapReciprocal=False, 4699 errorOnNameColision=errorOnNameColision 4700 ) 4701 4702 # Handle the forward transition... 4703 # Find the transition properties 4704 tProps = self.getTransitionProperties(fromID, transition) 4705 4706 # Delete the edge 4707 self.removeEdgeByKey(fromID, transition) 4708 4709 # Add the new edge 4710 self.addTransition(fromID, transition, newDestID) 4711 4712 # Reapply the transition properties 4713 self.setTransitionProperties(fromID, transition, **tProps) 4714 4715 # Handle the reciprocal transition if there is one... 4716 if reciprocal is not None: 4717 if not swapReciprocal: 4718 # Then sever the relationship, but only if that edge 4719 # still exists (we might be in the middle of a rebase) 4720 check = self.getDestination(oldDestID, reciprocal) 4721 if check is not None: 4722 self.setReciprocal( 4723 oldDestID, 4724 reciprocal, 4725 None, 4726 setBoth=False # Other transition was deleted already 4727 ) 4728 else: 4729 # Establish new reciprocal relationship 4730 self.setReciprocal( 4731 fromID, 4732 transition, 4733 reciprocal 4734 ) 4735 4736 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'
4738 def rebaseTransition( 4739 self, 4740 fromDecision: base.AnyDecisionSpecifier, 4741 transition: base.Transition, 4742 newBase: base.AnyDecisionSpecifier, 4743 swapReciprocal=True, 4744 errorOnNameColision=True 4745 ) -> base.Transition: 4746 """ 4747 Given a particular destination and a transition at that 4748 destination, changes that transition's origin to a new base 4749 decision. If the new source is the same as the old one, no 4750 changes are made. 4751 4752 If `swapReciprocal` is set to True (the default) then any 4753 reciprocal edge at the destination will be retargeted to point 4754 to the new source so that it can remain a reciprocal. If 4755 `swapReciprocal` is set to False, then the reciprocal 4756 relationship with any old reciprocal edge will be removed, but 4757 the old reciprocal edge will not be otherwise changed. 4758 4759 Note that if `errorOnNameColision` is True (the default), then 4760 if the transition has the same name as a transition which 4761 already exists at the new source node, a 4762 `TransitionCollisionError` will be raised. However, if it is set 4763 to False, the transition will be renamed with a suffix to avoid 4764 any possible name collisions. Either way, the (possibly new) name 4765 of the transition that was rebased will be returned. 4766 4767 ## Example 4768 4769 >>> g = DecisionGraph() 4770 >>> for fr, to, nm in [ 4771 ... ('A', 'B', 'up'), 4772 ... ('A', 'B', 'up2'), 4773 ... ('B', 'A', 'down'), 4774 ... ('B', 'B', 'self'), 4775 ... ('B', 'C', 'next'), 4776 ... ('C', 'B', 'prev') 4777 ... ]: 4778 ... if g.getDecision(fr) is None: 4779 ... g.addDecision(fr) 4780 ... if g.getDecision(to) is None: 4781 ... g.addDecision(to) 4782 ... g.addTransition(fr, nm, to) 4783 0 4784 1 4785 2 4786 >>> g.setReciprocal('A', 'up', 'down') 4787 >>> g.setReciprocal('B', 'next', 'prev') 4788 >>> g.destination('A', 'up') 4789 1 4790 >>> g.destination('B', 'down') 4791 0 4792 >>> g.rebaseTransition('B', 'down', 'C') 4793 'down' 4794 >>> g.destination('A', 'up') 4795 2 4796 >>> g.getDestination('B', 'down') is None 4797 True 4798 >>> g.destination('C', 'down') 4799 0 4800 >>> g.addTransition('A', 'next', 'B') 4801 >>> g.addTransition('B', 'prev', 'A') 4802 >>> g.setReciprocal('A', 'next', 'prev') 4803 >>> # Can't rebase in a way that would collide names 4804 >>> g.rebaseTransition('B', 'next', 'A') 4805 Traceback (most recent call last): 4806 ... 4807 exploration.core.TransitionCollisionError... 4808 >>> g.rebaseTransition('B', 'next', 'A', errorOnNameColision=False) 4809 'next.1' 4810 >>> g.destination('C', 'prev') 4811 0 4812 >>> g.destination('A', 'next') # not changed 4813 1 4814 >>> # Collision is avoided by renaming 4815 >>> g.destination('A', 'next.1') 4816 2 4817 >>> # Swap without reciprocal 4818 >>> g.getReciprocal('A', 'next.1') 4819 'prev' 4820 >>> g.getReciprocal('C', 'prev') 4821 'next.1' 4822 >>> g.rebaseTransition('A', 'next.1', 'B', swapReciprocal=False) 4823 'next.1' 4824 >>> g.getReciprocal('C', 'prev') is None 4825 True 4826 >>> g.destination('C', 'prev') 4827 0 4828 >>> g.getDestination('A', 'next.1') is None 4829 True 4830 >>> g.destination('A', 'next') 4831 1 4832 >>> g.destination('B', 'next.1') 4833 2 4834 >>> g.getReciprocal('B', 'next.1') is None 4835 True 4836 >>> # Rebase in a way that creates a self-edge 4837 >>> g.rebaseTransition('A', 'next', 'B') 4838 'next' 4839 >>> g.getDestination('A', 'next') is None 4840 True 4841 >>> g.destination('B', 'next') 4842 1 4843 >>> g.destination('B', 'prev') # swapped as a reciprocal 4844 1 4845 >>> g.getReciprocal('B', 'next') # still reciprocals 4846 'prev' 4847 >>> g.getReciprocal('B', 'prev') 4848 'next' 4849 >>> # And rebasing of a self-edge also works 4850 >>> g.rebaseTransition('B', 'prev', 'A') 4851 'prev' 4852 >>> g.destination('A', 'prev') 4853 1 4854 >>> g.destination('B', 'next') 4855 0 4856 >>> g.getReciprocal('B', 'next') # still reciprocals 4857 'prev' 4858 >>> g.getReciprocal('A', 'prev') 4859 'next' 4860 >>> # We've effectively reversed this edge/reciprocal pair 4861 >>> # by rebasing twice 4862 """ 4863 fromID = self.resolveDecision(fromDecision) 4864 newBaseID = self.resolveDecision(newBase) 4865 4866 # If thew new base is the same, we don't do anything! 4867 if newBaseID == fromID: 4868 return transition 4869 4870 # First figure out reciprocal business so we can swap it later 4871 # without making changes if we need to 4872 destination = self.destination(fromID, transition) 4873 reciprocal = self.getReciprocal(fromID, transition) 4874 # Check for an already-deleted reciprocal 4875 if ( 4876 reciprocal is not None 4877 and self.getDestination(destination, reciprocal) is None 4878 ): 4879 reciprocal = None 4880 4881 # Handle the base swap... 4882 # Find the transition properties 4883 tProps = self.getTransitionProperties(fromID, transition) 4884 4885 # Check for a collision 4886 targetDestinations = self.destinationsFrom(newBaseID) 4887 if transition in targetDestinations: 4888 if errorOnNameColision: 4889 raise TransitionCollisionError( 4890 f"Cannot rebase transition {transition!r} from" 4891 f" {self.identityOf(fromDecision)}: it would be a" 4892 f" duplicate transition name at the new base" 4893 f" decision {self.identityOf(newBase)}." 4894 ) 4895 else: 4896 # Figure out a good fresh name 4897 newName = utils.uniqueName( 4898 transition, 4899 targetDestinations 4900 ) 4901 else: 4902 newName = transition 4903 4904 # Delete the edge 4905 self.removeEdgeByKey(fromID, transition) 4906 4907 # Add the new edge 4908 self.addTransition(newBaseID, newName, destination) 4909 4910 # Reapply the transition properties 4911 self.setTransitionProperties(newBaseID, newName, **tProps) 4912 4913 # Handle the reciprocal transition if there is one... 4914 if reciprocal is not None: 4915 if not swapReciprocal: 4916 # Then sever the relationship 4917 self.setReciprocal( 4918 destination, 4919 reciprocal, 4920 None, 4921 setBoth=False # Other transition was deleted already 4922 ) 4923 else: 4924 # Otherwise swap the reciprocal edge 4925 self.retargetTransition( 4926 destination, 4927 reciprocal, 4928 newBaseID, 4929 swapReciprocal=False 4930 ) 4931 4932 # And establish a new reciprocal relationship 4933 self.setReciprocal( 4934 newBaseID, 4935 newName, 4936 reciprocal 4937 ) 4938 4939 # Return the new name in case it was changed 4940 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
4946 def mergeDecisions( 4947 self, 4948 merge: base.AnyDecisionSpecifier, 4949 mergeInto: base.AnyDecisionSpecifier, 4950 errorOnNameColision=True 4951 ) -> Dict[base.Transition, base.Transition]: 4952 """ 4953 Merges two decisions, deleting the first after transferring all 4954 of its incoming and outgoing edges to target the second one, 4955 whose name is retained. The second decision will be added to any 4956 zones that the first decision was a member of. If either decision 4957 does not exist, a `MissingDecisionError` will be raised. If 4958 `merge` and `mergeInto` are the same, then nothing will be 4959 changed. 4960 4961 Unless `errorOnNameColision` is set to False, a 4962 `TransitionCollisionError` will be raised if the two decisions 4963 have outgoing transitions with the same name. If 4964 `errorOnNameColision` is set to False, then such edges will be 4965 renamed using a suffix to avoid name collisions, with edges 4966 connected to the second decision retaining their original names 4967 and edges that were connected to the first decision getting 4968 renamed. 4969 4970 Any mechanisms located at the first decision will be moved to the 4971 merged decision. 4972 4973 The tags and annotations of the merged decision are added to the 4974 tags and annotations of the merge target. If there are shared 4975 tags, the values from the merge target will override those of 4976 the merged decision. If this is undesired behavior, clear/edit 4977 the tags/annotations of the merged decision before the merge. 4978 4979 The 'unconfirmed' tag is treated specially: if both decisions have 4980 it it will be retained, but otherwise it will be dropped even if 4981 one of the situations had it before. 4982 4983 The domain of the second decision is retained. 4984 4985 Returns a dictionary mapping each original transition name to 4986 its new name in cases where transitions get renamed; this will 4987 be empty when no re-naming occurs, including when 4988 `errorOnNameColision` is True. If there were any transitions 4989 connecting the nodes that were merged, these become self-edges 4990 of the merged node (and may be renamed if necessary). 4991 Note that all renamed transitions were originally based on the 4992 first (merged) node, since transitions of the second (merge 4993 target) node are not renamed. 4994 4995 ## Example 4996 4997 >>> g = DecisionGraph() 4998 >>> for fr, to, nm in [ 4999 ... ('A', 'B', 'up'), 5000 ... ('A', 'B', 'up2'), 5001 ... ('B', 'A', 'down'), 5002 ... ('B', 'B', 'self'), 5003 ... ('B', 'C', 'next'), 5004 ... ('C', 'B', 'prev'), 5005 ... ('A', 'C', 'right') 5006 ... ]: 5007 ... if g.getDecision(fr) is None: 5008 ... g.addDecision(fr) 5009 ... if g.getDecision(to) is None: 5010 ... g.addDecision(to) 5011 ... g.addTransition(fr, nm, to) 5012 0 5013 1 5014 2 5015 >>> g.getDestination('A', 'up') 5016 1 5017 >>> g.getDestination('B', 'down') 5018 0 5019 >>> sorted(g) 5020 [0, 1, 2] 5021 >>> g.setReciprocal('A', 'up', 'down') 5022 >>> g.setReciprocal('B', 'next', 'prev') 5023 >>> g.mergeDecisions('C', 'B') 5024 {} 5025 >>> g.destinationsFrom('A') 5026 {'up': 1, 'up2': 1, 'right': 1} 5027 >>> g.destinationsFrom('B') 5028 {'down': 0, 'self': 1, 'prev': 1, 'next': 1} 5029 >>> 'C' in g 5030 False 5031 >>> g.mergeDecisions('A', 'A') # does nothing 5032 {} 5033 >>> # Can't merge non-existent decision 5034 >>> g.mergeDecisions('A', 'Z') 5035 Traceback (most recent call last): 5036 ... 5037 exploration.core.MissingDecisionError... 5038 >>> g.mergeDecisions('Z', 'A') 5039 Traceback (most recent call last): 5040 ... 5041 exploration.core.MissingDecisionError... 5042 >>> # Can't merge decisions w/ shared edge names 5043 >>> g.addDecision('D') 5044 3 5045 >>> g.addTransition('D', 'next', 'A') 5046 >>> g.addTransition('A', 'prev', 'D') 5047 >>> g.setReciprocal('D', 'next', 'prev') 5048 >>> g.mergeDecisions('D', 'B') # both have a 'next' transition 5049 Traceback (most recent call last): 5050 ... 5051 exploration.core.TransitionCollisionError... 5052 >>> # Auto-rename colliding edges 5053 >>> g.mergeDecisions('D', 'B', errorOnNameColision=False) 5054 {'next': 'next.1'} 5055 >>> g.destination('B', 'next') # merge target unchanged 5056 1 5057 >>> g.destination('B', 'next.1') # merged decision name changed 5058 0 5059 >>> g.destination('B', 'prev') # name unchanged (no collision) 5060 1 5061 >>> g.getReciprocal('B', 'next') # unchanged (from B) 5062 'prev' 5063 >>> g.getReciprocal('B', 'next.1') # from A 5064 'prev' 5065 >>> g.getReciprocal('A', 'prev') # from B 5066 'next.1' 5067 5068 ## Folding four nodes into a 2-node loop 5069 5070 >>> g = DecisionGraph() 5071 >>> g.addDecision('X') 5072 0 5073 >>> g.addDecision('Y') 5074 1 5075 >>> g.addTransition('X', 'next', 'Y', 'prev') 5076 >>> g.addDecision('preX') 5077 2 5078 >>> g.addDecision('postY') 5079 3 5080 >>> g.addTransition('preX', 'next', 'X', 'prev') 5081 >>> g.addTransition('Y', 'next', 'postY', 'prev') 5082 >>> g.mergeDecisions('preX', 'Y', errorOnNameColision=False) 5083 {'next': 'next.1'} 5084 >>> g.destinationsFrom('X') 5085 {'next': 1, 'prev': 1} 5086 >>> g.destinationsFrom('Y') 5087 {'prev': 0, 'next': 3, 'next.1': 0} 5088 >>> 2 in g 5089 False 5090 >>> g.destinationsFrom('postY') 5091 {'prev': 1} 5092 >>> g.mergeDecisions('postY', 'X', errorOnNameColision=False) 5093 {'prev': 'prev.1'} 5094 >>> g.destinationsFrom('X') 5095 {'next': 1, 'prev': 1, 'prev.1': 1} 5096 >>> g.destinationsFrom('Y') # order 'cause of 'next' re-target 5097 {'prev': 0, 'next.1': 0, 'next': 0} 5098 >>> 2 in g 5099 False 5100 >>> 3 in g 5101 False 5102 >>> # Reciprocals are tangled... 5103 >>> g.getReciprocal(0, 'prev') 5104 'next.1' 5105 >>> g.getReciprocal(0, 'prev.1') 5106 'next' 5107 >>> g.getReciprocal(1, 'next') 5108 'prev.1' 5109 >>> g.getReciprocal(1, 'next.1') 5110 'prev' 5111 >>> # Note: one merge cannot handle both extra transitions 5112 >>> # because their reciprocals are crossed (e.g., prev.1 <-> next) 5113 >>> # (It would merge both edges but the result would retain 5114 >>> # 'next.1' instead of retaining 'next'.) 5115 >>> g.mergeTransitions('X', 'prev.1', 'prev', mergeReciprocal=False) 5116 >>> g.mergeTransitions('Y', 'next.1', 'next', mergeReciprocal=True) 5117 >>> g.destinationsFrom('X') 5118 {'next': 1, 'prev': 1} 5119 >>> g.destinationsFrom('Y') 5120 {'prev': 0, 'next': 0} 5121 >>> # Reciprocals were salvaged in second merger 5122 >>> g.getReciprocal('X', 'prev') 5123 'next' 5124 >>> g.getReciprocal('Y', 'next') 5125 'prev' 5126 5127 ## Merging with tags/requirements/annotations/consequences 5128 5129 >>> g = DecisionGraph() 5130 >>> g.addDecision('X') 5131 0 5132 >>> g.addDecision('Y') 5133 1 5134 >>> g.addDecision('Z') 5135 2 5136 >>> g.addTransition('X', 'next', 'Y', 'prev') 5137 >>> g.addTransition('X', 'down', 'Z', 'up') 5138 >>> g.tagDecision('X', 'tag0', 1) 5139 >>> g.tagDecision('Y', 'tag1', 10) 5140 >>> g.tagDecision('Y', 'unconfirmed') 5141 >>> g.tagDecision('Z', 'tag1', 20) 5142 >>> g.tagDecision('Z', 'tag2', 30) 5143 >>> g.tagTransition('X', 'next', 'ttag1', 11) 5144 >>> g.tagTransition('Y', 'prev', 'ttag2', 22) 5145 >>> g.tagTransition('X', 'down', 'ttag3', 33) 5146 >>> g.tagTransition('Z', 'up', 'ttag4', 44) 5147 >>> g.annotateDecision('Y', 'annotation 1') 5148 >>> g.annotateDecision('Z', 'annotation 2') 5149 >>> g.annotateDecision('Z', 'annotation 3') 5150 >>> g.annotateTransition('Y', 'prev', 'trans annotation 1') 5151 >>> g.annotateTransition('Y', 'prev', 'trans annotation 2') 5152 >>> g.annotateTransition('Z', 'up', 'trans annotation 3') 5153 >>> g.setTransitionRequirement( 5154 ... 'X', 5155 ... 'next', 5156 ... base.ReqCapability('power') 5157 ... ) 5158 >>> g.setTransitionRequirement( 5159 ... 'Y', 5160 ... 'prev', 5161 ... base.ReqTokens('token', 1) 5162 ... ) 5163 >>> g.setTransitionRequirement( 5164 ... 'X', 5165 ... 'down', 5166 ... base.ReqCapability('power2') 5167 ... ) 5168 >>> g.setTransitionRequirement( 5169 ... 'Z', 5170 ... 'up', 5171 ... base.ReqTokens('token2', 2) 5172 ... ) 5173 >>> g.setConsequence( 5174 ... 'Y', 5175 ... 'prev', 5176 ... [base.effect(gain="power2")] 5177 ... ) 5178 >>> g.mergeDecisions('Y', 'Z') 5179 {} 5180 >>> g.destination('X', 'next') 5181 2 5182 >>> g.destination('X', 'down') 5183 2 5184 >>> g.destination('Z', 'prev') 5185 0 5186 >>> g.destination('Z', 'up') 5187 0 5188 >>> g.decisionTags('X') 5189 {'tag0': 1} 5190 >>> g.decisionTags('Z') # note that 'unconfirmed' is removed 5191 {'tag1': 20, 'tag2': 30} 5192 >>> g.transitionTags('X', 'next') 5193 {'ttag1': 11} 5194 >>> g.transitionTags('X', 'down') 5195 {'ttag3': 33} 5196 >>> g.transitionTags('Z', 'prev') 5197 {'ttag2': 22} 5198 >>> g.transitionTags('Z', 'up') 5199 {'ttag4': 44} 5200 >>> g.decisionAnnotations('Z') 5201 ['annotation 2', 'annotation 3', 'annotation 1'] 5202 >>> g.transitionAnnotations('Z', 'prev') 5203 ['trans annotation 1', 'trans annotation 2'] 5204 >>> g.transitionAnnotations('Z', 'up') 5205 ['trans annotation 3'] 5206 >>> g.getTransitionRequirement('X', 'next') 5207 ReqCapability('power') 5208 >>> g.getTransitionRequirement('Z', 'prev') 5209 ReqTokens('token', 1) 5210 >>> g.getTransitionRequirement('X', 'down') 5211 ReqCapability('power2') 5212 >>> g.getTransitionRequirement('Z', 'up') 5213 ReqTokens('token2', 2) 5214 >>> g.getConsequence('Z', 'prev') == [ 5215 ... { 5216 ... 'type': 'gain', 5217 ... 'applyTo': 'active', 5218 ... 'value': 'power2', 5219 ... 'charges': None, 5220 ... 'delay': None, 5221 ... 'hidden': False 5222 ... } 5223 ... ] 5224 True 5225 5226 ## Merging into node without tags 5227 5228 >>> g = DecisionGraph() 5229 >>> g.addDecision('X') 5230 0 5231 >>> g.addDecision('Y') 5232 1 5233 >>> g.tagDecision('Y', 'unconfirmed') # special handling 5234 >>> g.tagDecision('Y', 'tag', 'value') 5235 >>> g.mergeDecisions('Y', 'X') 5236 {} 5237 >>> g.decisionTags('X') 5238 {'tag': 'value'} 5239 >>> 0 in g # Second argument remains 5240 True 5241 >>> 1 in g # First argument is deleted 5242 False 5243 """ 5244 # Resolve IDs 5245 mergeID = self.resolveDecision(merge) 5246 mergeIntoID = self.resolveDecision(mergeInto) 5247 5248 # Create our result as an empty dictionary 5249 result: Dict[base.Transition, base.Transition] = {} 5250 5251 # Short-circuit if the two decisions are the same 5252 if mergeID == mergeIntoID: 5253 return result 5254 5255 # MissingDecisionErrors from here if either doesn't exist 5256 allNewOutgoing = set(self.destinationsFrom(mergeID)) 5257 allOldOutgoing = set(self.destinationsFrom(mergeIntoID)) 5258 # Find colliding transition names 5259 collisions = allNewOutgoing & allOldOutgoing 5260 if len(collisions) > 0 and errorOnNameColision: 5261 raise TransitionCollisionError( 5262 f"Cannot merge decision {self.identityOf(merge)} into" 5263 f" decision {self.identityOf(mergeInto)}: the decisions" 5264 f" share {len(collisions)} transition names:" 5265 f" {collisions}\n(Note that errorOnNameColision was set" 5266 f" to True, set it to False to allow the operation by" 5267 f" renaming half of those transitions.)" 5268 ) 5269 5270 # Record zones that will have to change after the merge 5271 zoneParents = self.zoneParents(mergeID) 5272 5273 # First, swap all incoming edges, along with their reciprocals 5274 # This will include self-edges, which will be retargeted and 5275 # whose reciprocals will be rebased in the process, leading to 5276 # the possibility of a missing edge during the loop 5277 for source, incoming in self.allEdgesTo(mergeID): 5278 # Skip this edge if it was already swapped away because it's 5279 # a self-loop with a reciprocal whose reciprocal was 5280 # processed earlier in the loop 5281 if incoming not in self.destinationsFrom(source): 5282 continue 5283 5284 # Find corresponding outgoing edge 5285 outgoing = self.getReciprocal(source, incoming) 5286 5287 # Swap both edges to new destination 5288 newOutgoing = self.retargetTransition( 5289 source, 5290 incoming, 5291 mergeIntoID, 5292 swapReciprocal=True, 5293 errorOnNameColision=False # collisions were detected above 5294 ) 5295 # Add to our result if the name of the reciprocal was 5296 # changed 5297 if ( 5298 outgoing is not None 5299 and newOutgoing is not None 5300 and outgoing != newOutgoing 5301 ): 5302 result[outgoing] = newOutgoing 5303 5304 # Next, swap any remaining outgoing edges (which didn't have 5305 # reciprocals, or they'd already be swapped, unless they were 5306 # self-edges previously). Note that in this loop, there can't be 5307 # any self-edges remaining, although there might be connections 5308 # between the merging nodes that need to become self-edges 5309 # because they used to be a self-edge that was half-retargeted 5310 # by the previous loop. 5311 # Note: a copy is used here to avoid iterating over a changing 5312 # dictionary 5313 for stillOutgoing in copy.copy(self.destinationsFrom(mergeID)): 5314 newOutgoing = self.rebaseTransition( 5315 mergeID, 5316 stillOutgoing, 5317 mergeIntoID, 5318 swapReciprocal=True, 5319 errorOnNameColision=False # collisions were detected above 5320 ) 5321 if stillOutgoing != newOutgoing: 5322 result[stillOutgoing] = newOutgoing 5323 5324 # At this point, there shouldn't be any remaining incoming or 5325 # outgoing edges! 5326 assert self.degree(mergeID) == 0 5327 5328 # Merge tags & annotations 5329 # Note that these operations affect the underlying graph 5330 destTags = self.decisionTags(mergeIntoID) 5331 destUnvisited = 'unconfirmed' in destTags 5332 sourceTags = self.decisionTags(mergeID) 5333 sourceUnvisited = 'unconfirmed' in sourceTags 5334 # Copy over only new tags, leaving existing tags alone 5335 for key in sourceTags: 5336 if key not in destTags: 5337 destTags[key] = sourceTags[key] 5338 5339 if int(destUnvisited) + int(sourceUnvisited) == 1: 5340 del destTags['unconfirmed'] 5341 5342 self.decisionAnnotations(mergeIntoID).extend( 5343 self.decisionAnnotations(mergeID) 5344 ) 5345 5346 # Transfer zones 5347 for zone in zoneParents: 5348 self.addDecisionToZone(mergeIntoID, zone) 5349 5350 # Delete the old node 5351 self.removeDecision(mergeID) 5352 5353 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
5355 def removeDecision(self, decision: base.AnyDecisionSpecifier) -> None: 5356 """ 5357 Deletes the specified decision from the graph, updating 5358 attendant structures like zones. Note that the ID of the deleted 5359 node will NOT be reused, unless it's specifically provided to 5360 `addIdentifiedDecision`. 5361 5362 For example: 5363 5364 >>> dg = DecisionGraph() 5365 >>> dg.addDecision('A') 5366 0 5367 >>> dg.addDecision('B') 5368 1 5369 >>> list(dg) 5370 [0, 1] 5371 >>> 1 in dg 5372 True 5373 >>> 'B' in dg.nameLookup 5374 True 5375 >>> dg.removeDecision('B') 5376 >>> 1 in dg 5377 False 5378 >>> list(dg) 5379 [0] 5380 >>> 'B' in dg.nameLookup 5381 False 5382 >>> dg.addDecision('C') # doesn't re-use ID 5383 2 5384 """ 5385 dID = self.resolveDecision(decision) 5386 5387 # Remove the target from all zones: 5388 for zone in self.zones: 5389 self.removeDecisionFromZone(dID, zone) 5390 5391 # Remove the node but record the current name 5392 name = self.nodes[dID]['name'] 5393 self.remove_node(dID) 5394 5395 # Clean up the nameLookup entry 5396 luInfo = self.nameLookup[name] 5397 luInfo.remove(dID) 5398 if len(luInfo) == 0: 5399 self.nameLookup.pop(name) 5400 5401 # 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
5403 def renameDecision( 5404 self, 5405 decision: base.AnyDecisionSpecifier, 5406 newName: base.DecisionName 5407 ): 5408 """ 5409 Renames a decision. The decision retains its old ID. 5410 5411 Generates a `DecisionCollisionWarning` if a decision using the new 5412 name already exists and `WARN_OF_NAME_COLLISIONS` is enabled. 5413 5414 Example: 5415 5416 >>> g = DecisionGraph() 5417 >>> g.addDecision('one') 5418 0 5419 >>> g.addDecision('three') 5420 1 5421 >>> g.addTransition('one', '>', 'three') 5422 >>> g.addTransition('three', '<', 'one') 5423 >>> g.tagDecision('three', 'hi') 5424 >>> g.annotateDecision('three', 'note') 5425 >>> g.destination('one', '>') 5426 1 5427 >>> g.destination('three', '<') 5428 0 5429 >>> g.renameDecision('three', 'two') 5430 >>> g.resolveDecision('one') 5431 0 5432 >>> g.resolveDecision('two') 5433 1 5434 >>> g.resolveDecision('three') 5435 Traceback (most recent call last): 5436 ... 5437 exploration.core.MissingDecisionError... 5438 >>> g.destination('one', '>') 5439 1 5440 >>> g.nameFor(1) 5441 'two' 5442 >>> g.getDecision('three') is None 5443 True 5444 >>> g.destination('two', '<') 5445 0 5446 >>> g.decisionTags('two') 5447 {'hi': 1} 5448 >>> g.decisionAnnotations('two') 5449 ['note'] 5450 """ 5451 dID = self.resolveDecision(decision) 5452 5453 if newName in self.nameLookup and WARN_OF_NAME_COLLISIONS: 5454 warnings.warn( 5455 ( 5456 f"Can't rename {self.identityOf(decision)} as" 5457 f" {newName!r} because a decision with that name" 5458 f" already exists." 5459 ), 5460 DecisionCollisionWarning 5461 ) 5462 5463 # Update name in node 5464 oldName = self.nodes[dID]['name'] 5465 self.nodes[dID]['name'] = newName 5466 5467 # Update nameLookup entries 5468 oldNL = self.nameLookup[oldName] 5469 oldNL.remove(dID) 5470 if len(oldNL) == 0: 5471 self.nameLookup.pop(oldName) 5472 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']
5474 def mergeTransitions( 5475 self, 5476 fromDecision: base.AnyDecisionSpecifier, 5477 merge: base.Transition, 5478 mergeInto: base.Transition, 5479 mergeReciprocal=True 5480 ) -> None: 5481 """ 5482 Given a decision and two transitions that start at that decision, 5483 merges the first transition into the second transition, combining 5484 their transition properties (using `mergeProperties`) and 5485 deleting the first transition. By default any reciprocal of the 5486 first transition is also merged into the reciprocal of the 5487 second, although you can set `mergeReciprocal` to `False` to 5488 disable this in which case the old reciprocal will lose its 5489 reciprocal relationship, even if the transition that was merged 5490 into does not have a reciprocal. 5491 5492 If the two names provided are the same, nothing will happen. 5493 5494 If the two transitions do not share the same destination, they 5495 cannot be merged, and an `InvalidDestinationError` will result. 5496 Use `retargetTransition` beforehand to ensure that they do if you 5497 want to merge transitions with different destinations. 5498 5499 A `MissingDecisionError` or `MissingTransitionError` will result 5500 if the decision or either transition does not exist. 5501 5502 If merging reciprocal properties was requested and the first 5503 transition does not have a reciprocal, then no reciprocal 5504 properties change. However, if the second transition does not 5505 have a reciprocal and the first does, the first transition's 5506 reciprocal will be set to the reciprocal of the second 5507 transition, and that transition will not be deleted as usual. 5508 5509 ## Example 5510 5511 >>> g = DecisionGraph() 5512 >>> g.addDecision('A') 5513 0 5514 >>> g.addDecision('B') 5515 1 5516 >>> g.addTransition('A', 'up', 'B') 5517 >>> g.addTransition('B', 'down', 'A') 5518 >>> g.setReciprocal('A', 'up', 'down') 5519 >>> # Merging a transition with no reciprocal 5520 >>> g.addTransition('A', 'up2', 'B') 5521 >>> g.mergeTransitions('A', 'up2', 'up') 5522 >>> g.getDestination('A', 'up2') is None 5523 True 5524 >>> g.getDestination('A', 'up') 5525 1 5526 >>> # Merging a transition with a reciprocal & tags 5527 >>> g.addTransition('A', 'up2', 'B') 5528 >>> g.addTransition('B', 'down2', 'A') 5529 >>> g.setReciprocal('A', 'up2', 'down2') 5530 >>> g.tagTransition('A', 'up2', 'one') 5531 >>> g.tagTransition('B', 'down2', 'two') 5532 >>> g.mergeTransitions('B', 'down2', 'down') 5533 >>> g.getDestination('A', 'up2') is None 5534 True 5535 >>> g.getDestination('A', 'up') 5536 1 5537 >>> g.getDestination('B', 'down2') is None 5538 True 5539 >>> g.getDestination('B', 'down') 5540 0 5541 >>> # Merging requirements uses ReqAll (i.e., 'and' logic) 5542 >>> g.addTransition('A', 'up2', 'B') 5543 >>> g.setTransitionProperties( 5544 ... 'A', 5545 ... 'up2', 5546 ... requirement=base.ReqCapability('dash') 5547 ... ) 5548 >>> g.setTransitionProperties('A', 'up', 5549 ... requirement=base.ReqCapability('slide')) 5550 >>> g.mergeTransitions('A', 'up2', 'up') 5551 >>> g.getDestination('A', 'up2') is None 5552 True 5553 >>> repr(g.getTransitionRequirement('A', 'up')) 5554 "ReqAll([ReqCapability('dash'), ReqCapability('slide')])" 5555 >>> # Errors if destinations differ, or if something is missing 5556 >>> g.mergeTransitions('A', 'down', 'up') 5557 Traceback (most recent call last): 5558 ... 5559 exploration.core.MissingTransitionError... 5560 >>> g.mergeTransitions('Z', 'one', 'two') 5561 Traceback (most recent call last): 5562 ... 5563 exploration.core.MissingDecisionError... 5564 >>> g.addDecision('C') 5565 2 5566 >>> g.addTransition('A', 'down', 'C') 5567 >>> g.mergeTransitions('A', 'down', 'up') 5568 Traceback (most recent call last): 5569 ... 5570 exploration.core.InvalidDestinationError... 5571 >>> # Merging a reciprocal onto an edge that doesn't have one 5572 >>> g.addTransition('A', 'down2', 'C') 5573 >>> g.addTransition('C', 'up2', 'A') 5574 >>> g.setReciprocal('A', 'down2', 'up2') 5575 >>> g.tagTransition('C', 'up2', 'narrow') 5576 >>> g.getReciprocal('A', 'down') is None 5577 True 5578 >>> g.mergeTransitions('A', 'down2', 'down') 5579 >>> g.getDestination('A', 'down2') is None 5580 True 5581 >>> g.getDestination('A', 'down') 5582 2 5583 >>> g.getDestination('C', 'up2') 5584 0 5585 >>> g.getReciprocal('A', 'down') 5586 'up2' 5587 >>> g.getReciprocal('C', 'up2') 5588 'down' 5589 >>> g.transitionTags('C', 'up2') 5590 {'narrow': 1} 5591 >>> # Merging without a reciprocal 5592 >>> g.addTransition('C', 'up', 'A') 5593 >>> g.mergeTransitions('C', 'up2', 'up', mergeReciprocal=False) 5594 >>> g.getDestination('C', 'up2') is None 5595 True 5596 >>> g.getDestination('C', 'up') 5597 0 5598 >>> g.transitionTags('C', 'up') # tag gets merged 5599 {'narrow': 1} 5600 >>> g.getDestination('A', 'down') 5601 2 5602 >>> g.getReciprocal('A', 'down') is None 5603 True 5604 >>> g.getReciprocal('C', 'up') is None 5605 True 5606 >>> # Merging w/ normal reciprocals 5607 >>> g.addDecision('D') 5608 3 5609 >>> g.addDecision('E') 5610 4 5611 >>> g.addTransition('D', 'up', 'E', 'return') 5612 >>> g.addTransition('E', 'down', 'D') 5613 >>> g.mergeTransitions('E', 'return', 'down') 5614 >>> g.getDestination('D', 'up') 5615 4 5616 >>> g.getDestination('E', 'down') 5617 3 5618 >>> g.getDestination('E', 'return') is None 5619 True 5620 >>> g.getReciprocal('D', 'up') 5621 'down' 5622 >>> g.getReciprocal('E', 'down') 5623 'up' 5624 >>> # Merging w/ weird reciprocals 5625 >>> g.addTransition('E', 'return', 'D') 5626 >>> g.setReciprocal('E', 'return', 'up', setBoth=False) 5627 >>> g.getReciprocal('D', 'up') 5628 'down' 5629 >>> g.getReciprocal('E', 'down') 5630 'up' 5631 >>> g.getReciprocal('E', 'return') # shared 5632 'up' 5633 >>> g.mergeTransitions('E', 'return', 'down') 5634 >>> g.getDestination('D', 'up') 5635 4 5636 >>> g.getDestination('E', 'down') 5637 3 5638 >>> g.getDestination('E', 'return') is None 5639 True 5640 >>> g.getReciprocal('D', 'up') 5641 'down' 5642 >>> g.getReciprocal('E', 'down') 5643 'up' 5644 """ 5645 fromID = self.resolveDecision(fromDecision) 5646 5647 # Short-circuit in the no-op case 5648 if merge == mergeInto: 5649 return 5650 5651 # These lines will raise a MissingDecisionError or 5652 # MissingTransitionError if needed 5653 dest1 = self.destination(fromID, merge) 5654 dest2 = self.destination(fromID, mergeInto) 5655 5656 if dest1 != dest2: 5657 raise InvalidDestinationError( 5658 f"Cannot merge transition {merge!r} into transition" 5659 f" {mergeInto!r} from decision" 5660 f" {self.identityOf(fromDecision)} because their" 5661 f" destinations are different ({self.identityOf(dest1)}" 5662 f" and {self.identityOf(dest2)}).\nNote: you can use" 5663 f" `retargetTransition` to change the destination of a" 5664 f" transition." 5665 ) 5666 5667 # Find and the transition properties 5668 props1 = self.getTransitionProperties(fromID, merge) 5669 props2 = self.getTransitionProperties(fromID, mergeInto) 5670 merged = mergeProperties(props1, props2) 5671 # Note that this doesn't change the reciprocal: 5672 self.setTransitionProperties(fromID, mergeInto, **merged) 5673 5674 # Merge the reciprocal properties if requested 5675 # Get reciprocal to merge into 5676 reciprocal = self.getReciprocal(fromID, mergeInto) 5677 # Get reciprocal that needs cleaning up 5678 altReciprocal = self.getReciprocal(fromID, merge) 5679 # If the reciprocal to be merged actually already was the 5680 # reciprocal to merge into, there's nothing to do here 5681 if altReciprocal != reciprocal: 5682 if not mergeReciprocal: 5683 # In this case, we sever the reciprocal relationship if 5684 # there is a reciprocal 5685 if altReciprocal is not None: 5686 self.setReciprocal(dest1, altReciprocal, None) 5687 # By default setBoth takes care of the other half 5688 else: 5689 # In this case, we try to merge reciprocals 5690 # If altReciprocal is None, we don't need to do anything 5691 if altReciprocal is not None: 5692 # Was there already a reciprocal or not? 5693 if reciprocal is None: 5694 # altReciprocal becomes the new reciprocal and is 5695 # not deleted 5696 self.setReciprocal( 5697 fromID, 5698 mergeInto, 5699 altReciprocal 5700 ) 5701 else: 5702 # merge reciprocal properties 5703 props1 = self.getTransitionProperties( 5704 dest1, 5705 altReciprocal 5706 ) 5707 props2 = self.getTransitionProperties( 5708 dest2, 5709 reciprocal 5710 ) 5711 merged = mergeProperties(props1, props2) 5712 self.setTransitionProperties( 5713 dest1, 5714 reciprocal, 5715 **merged 5716 ) 5717 5718 # delete the old reciprocal transition 5719 self.remove_edge(dest1, fromID, altReciprocal) 5720 5721 # Delete the old transition (reciprocal deletion/severance is 5722 # handled above if necessary) 5723 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 to 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'
5725 def isConfirmed(self, decision: base.AnyDecisionSpecifier) -> bool: 5726 """ 5727 Returns `True` or `False` depending on whether or not the 5728 specified decision has been confirmed. Uses the presence or 5729 absence of the 'unconfirmed' tag to determine this. 5730 5731 Note: 'unconfirmed' is used instead of 'confirmed' so that large 5732 graphs with many confirmed nodes will be smaller when saved. 5733 """ 5734 dID = self.resolveDecision(decision) 5735 5736 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.
5738 def replaceUnconfirmed( 5739 self, 5740 fromDecision: base.AnyDecisionSpecifier, 5741 transition: base.Transition, 5742 connectTo: Optional[base.AnyDecisionSpecifier] = None, 5743 reciprocal: Optional[base.Transition] = None, 5744 requirement: Optional[base.Requirement] = None, 5745 applyConsequence: Optional[base.Consequence] = None, 5746 placeInZone: Optional[base.Zone] = None, 5747 forceNew: bool = False, 5748 tags: Optional[Dict[base.Tag, base.TagValue]] = None, 5749 annotations: Optional[List[base.Annotation]] = None, 5750 revRequires: Optional[base.Requirement] = None, 5751 revConsequence: Optional[base.Consequence] = None, 5752 revTags: Optional[Dict[base.Tag, base.TagValue]] = None, 5753 revAnnotations: Optional[List[base.Annotation]] = None, 5754 decisionTags: Optional[Dict[base.Tag, base.TagValue]] = None, 5755 decisionAnnotations: Optional[List[base.Annotation]] = None 5756 ) -> Tuple[ 5757 Dict[base.Transition, base.Transition], 5758 Dict[base.Transition, base.Transition] 5759 ]: 5760 """ 5761 Given a decision and an edge name in that decision, where the 5762 named edge leads to a decision with an unconfirmed exploration 5763 state (see `isConfirmed`), renames the unexplored decision on 5764 the other end of that edge using the given `connectTo` name, or 5765 if a decision using that name already exists, merges the 5766 unexplored decision into that decision. If `connectTo` is a 5767 `DecisionSpecifier` whose target doesn't exist, it will be 5768 treated as just a name, but if it's an ID and it doesn't exist, 5769 you'll get a `MissingDecisionError`. If a `reciprocal` is provided, 5770 a reciprocal edge will be added using that name connecting the 5771 `connectTo` decision back to the original decision. If this 5772 transition already exists, it must also point to a node which is 5773 also unexplored, and which will also be merged into the 5774 `fromDecision` node. 5775 5776 If `connectTo` is not given (or is set to `None` explicitly) 5777 then the name of the unexplored decision will not be changed, 5778 unless that name has the form `'_u.-n-'` where `-n-` is a positive 5779 integer (i.e., the form given to automatically-named unknown 5780 nodes). In that case, the name will be changed to `'_x.-n-'` using 5781 the same number, or a higher number if that name is already taken. 5782 5783 If the destination is being renamed or if the destination's 5784 exploration state counts as unexplored, the exploration state of 5785 the destination will be set to 'exploring'. 5786 5787 If a `placeInZone` is specified, the destination will be placed 5788 directly into that zone (even if it already existed and has zone 5789 information), and it will be removed from any other zones it had 5790 been a direct member of. If `placeInZone` is set to 5791 `base.DefaultZone`, then the destination will be placed into 5792 each zone which is a direct parent of the origin, but only if 5793 the destination is not an already-explored existing decision AND 5794 it is not already in any zones (in those cases no zone changes 5795 are made). This will also remove it from any previous zones it 5796 had been a part of. If `placeInZone` is left as `None` (the 5797 default) no zone changes are made. 5798 5799 If `placeInZone` is specified and that zone didn't already exist, 5800 it will be created as a new level-0 zone and will be added as a 5801 sub-zone of each zone that's a direct parent of any level-0 zone 5802 that the origin is a member of. 5803 5804 If `forceNew` is specified, then the destination will just be 5805 renamed, even if another decision with the same name already 5806 exists. It's an error to use `forceNew` with a decision ID as 5807 the destination. 5808 5809 Any additional edges pointing to or from the unknown node(s) 5810 being replaced will also be re-targeted at the now-discovered 5811 known destination(s) if necessary. These edges will retain their 5812 reciprocal names, or if this would cause a name clash, they will 5813 be renamed with a suffix (see `retargetTransition`). 5814 5815 The return value is a pair of dictionaries mapping old names to 5816 new ones that just includes the names which were changed. The 5817 first dictionary contains renamed transitions that are outgoing 5818 from the new destination node (which used to be outgoing from 5819 the unexplored node). The second dictionary contains renamed 5820 transitions that are outgoing from the source node (which used 5821 to be outgoing from the unexplored node attached to the 5822 reciprocal transition; if there was no reciprocal transition 5823 specified then this will always be an empty dictionary). 5824 5825 An `ExplorationStatusError` will be raised if the destination 5826 of the specified transition counts as visited (see 5827 `hasBeenVisited`). An `ExplorationStatusError` will also be 5828 raised if the `connectTo`'s `reciprocal` transition does not lead 5829 to an unconfirmed decision (it's okay if this second transition 5830 doesn't exist). A `TransitionCollisionError` will be raised if 5831 the unconfirmed destination decision already has an outgoing 5832 transition with the specified `reciprocal` which does not lead 5833 back to the `fromDecision`. 5834 5835 The transition properties (requirement, consequences, tags, 5836 and/or annotations) of the replaced transition will be copied 5837 over to the new transition. Transition properties from the 5838 reciprocal transition will also be copied for the newly created 5839 reciprocal edge. Properties for any additional edges to/from the 5840 unknown node will also be copied. 5841 5842 Also, any transition properties on existing forward or reciprocal 5843 edges from the destination node with the indicated reverse name 5844 will be merged with those from the target transition. Note that 5845 this merging process may introduce corruption of complex 5846 transition consequences. TODO: Fix that! 5847 5848 Any tags and annotations are added to copied tags/annotations, 5849 but specified requirements, and/or consequences will replace 5850 previous requirements/consequences, rather than being added to 5851 them. 5852 5853 ## Example 5854 5855 >>> g = DecisionGraph() 5856 >>> g.addDecision('A') 5857 0 5858 >>> g.addUnexploredEdge('A', 'up') 5859 1 5860 >>> g.destination('A', 'up') 5861 1 5862 >>> g.destination('_u.0', 'return') 5863 0 5864 >>> g.replaceUnconfirmed('A', 'up', 'B', 'down') 5865 ({}, {}) 5866 >>> g.destination('A', 'up') 5867 1 5868 >>> g.nameFor(1) 5869 'B' 5870 >>> g.destination('B', 'down') 5871 0 5872 >>> g.getDestination('B', 'return') is None 5873 True 5874 >>> '_u.0' in g.nameLookup 5875 False 5876 >>> g.getReciprocal('A', 'up') 5877 'down' 5878 >>> g.getReciprocal('B', 'down') 5879 'up' 5880 >>> # Two unexplored edges to the same node: 5881 >>> g.addDecision('C') 5882 2 5883 >>> g.addTransition('B', 'next', 'C') 5884 >>> g.addTransition('C', 'prev', 'B') 5885 >>> g.setReciprocal('B', 'next', 'prev') 5886 >>> g.addUnexploredEdge('A', 'next', 'D', 'prev') 5887 3 5888 >>> g.addTransition('C', 'down', 'D') 5889 >>> g.addTransition('D', 'up', 'C') 5890 >>> g.setReciprocal('C', 'down', 'up') 5891 >>> g.replaceUnconfirmed('C', 'down') 5892 ({}, {}) 5893 >>> g.destination('C', 'down') 5894 3 5895 >>> g.destination('A', 'next') 5896 3 5897 >>> g.destinationsFrom('D') 5898 {'prev': 0, 'up': 2} 5899 >>> g.decisionTags('D') 5900 {} 5901 >>> # An unexplored transition which turns out to connect to a 5902 >>> # known decision, with name collisions 5903 >>> g.addUnexploredEdge('D', 'next', reciprocal='prev') 5904 4 5905 >>> g.tagDecision('_u.2', 'wet') 5906 >>> g.addUnexploredEdge('B', 'next', reciprocal='prev') # edge taken 5907 Traceback (most recent call last): 5908 ... 5909 exploration.core.TransitionCollisionError... 5910 >>> g.addUnexploredEdge('A', 'prev', reciprocal='next') 5911 5 5912 >>> g.tagDecision('_u.3', 'dry') 5913 >>> # Add transitions that will collide when merged 5914 >>> g.addUnexploredEdge('_u.2', 'up') # collides with A/up 5915 6 5916 >>> g.addUnexploredEdge('_u.3', 'prev') # collides with D/prev 5917 7 5918 >>> g.getReciprocal('A', 'prev') 5919 'next' 5920 >>> g.replaceUnconfirmed('A', 'prev', 'D', 'next') # two gone 5921 ({'prev': 'prev.1'}, {'up': 'up.1'}) 5922 >>> g.destination('A', 'prev') 5923 3 5924 >>> g.destination('D', 'next') 5925 0 5926 >>> g.getReciprocal('A', 'prev') 5927 'next' 5928 >>> g.getReciprocal('D', 'next') 5929 'prev' 5930 >>> # Note that further unexplored structures are NOT merged 5931 >>> # even if they match against existing structures... 5932 >>> g.destination('A', 'up.1') 5933 6 5934 >>> g.destination('D', 'prev.1') 5935 7 5936 >>> '_u.2' in g.nameLookup 5937 False 5938 >>> '_u.3' in g.nameLookup 5939 False 5940 >>> g.decisionTags('D') # tags are merged 5941 {'dry': 1} 5942 >>> g.decisionTags('A') 5943 {'wet': 1} 5944 >>> # Auto-renaming an anonymous unexplored node 5945 >>> g.addUnexploredEdge('B', 'out') 5946 8 5947 >>> g.replaceUnconfirmed('B', 'out') 5948 ({}, {}) 5949 >>> '_u.6' in g 5950 False 5951 >>> g.destination('B', 'out') 5952 8 5953 >>> g.nameFor(8) 5954 '_x.6' 5955 >>> g.destination('_x.6', 'return') 5956 1 5957 >>> # Placing a node into a zone 5958 >>> g.addUnexploredEdge('B', 'through') 5959 9 5960 >>> g.getDecision('E') is None 5961 True 5962 >>> g.replaceUnconfirmed( 5963 ... 'B', 5964 ... 'through', 5965 ... 'E', 5966 ... 'back', 5967 ... placeInZone='Zone' 5968 ... ) 5969 ({}, {}) 5970 >>> g.getDecision('E') 5971 9 5972 >>> g.destination('B', 'through') 5973 9 5974 >>> g.destination('E', 'back') 5975 1 5976 >>> g.zoneParents(9) 5977 {'Zone'} 5978 >>> g.addUnexploredEdge('E', 'farther') 5979 10 5980 >>> g.replaceUnconfirmed( 5981 ... 'E', 5982 ... 'farther', 5983 ... 'F', 5984 ... 'closer', 5985 ... placeInZone=base.DefaultZone 5986 ... ) 5987 ({}, {}) 5988 >>> g.destination('E', 'farther') 5989 10 5990 >>> g.destination('F', 'closer') 5991 9 5992 >>> g.zoneParents(10) 5993 {'Zone'} 5994 >>> g.addUnexploredEdge('F', 'backwards', placeInZone='Enoz') 5995 11 5996 >>> g.replaceUnconfirmed( 5997 ... 'F', 5998 ... 'backwards', 5999 ... 'G', 6000 ... 'forwards', 6001 ... placeInZone=base.DefaultZone 6002 ... ) 6003 ({}, {}) 6004 >>> g.destination('F', 'backwards') 6005 11 6006 >>> g.destination('G', 'forwards') 6007 10 6008 >>> g.zoneParents(11) # not changed since it already had a zone 6009 {'Enoz'} 6010 >>> # TODO: forceNew example 6011 """ 6012 6013 # Defaults 6014 if tags is None: 6015 tags = {} 6016 if annotations is None: 6017 annotations = [] 6018 if revTags is None: 6019 revTags = {} 6020 if revAnnotations is None: 6021 revAnnotations = [] 6022 if decisionTags is None: 6023 decisionTags = {} 6024 if decisionAnnotations is None: 6025 decisionAnnotations = [] 6026 6027 # Resolve source 6028 fromID = self.resolveDecision(fromDecision) 6029 6030 # Figure out destination decision 6031 oldUnexplored = self.destination(fromID, transition) 6032 if self.isConfirmed(oldUnexplored): 6033 raise ExplorationStatusError( 6034 f"Transition {transition!r} from" 6035 f" {self.identityOf(fromDecision)} does not lead to an" 6036 f" unconfirmed decision (it leads to" 6037 f" {self.identityOf(oldUnexplored)} which is not tagged" 6038 f" 'unconfirmed')." 6039 ) 6040 6041 # Resolve destination 6042 newName: Optional[base.DecisionName] = None 6043 connectID: Optional[base.DecisionID] = None 6044 if forceNew: 6045 if isinstance(connectTo, base.DecisionID): 6046 raise TypeError( 6047 f"connectTo cannot be a decision ID when forceNew" 6048 f" is True. Got: {self.identityOf(connectTo)}" 6049 ) 6050 elif isinstance(connectTo, base.DecisionSpecifier): 6051 newName = connectTo.name 6052 elif isinstance(connectTo, base.DecisionName): 6053 newName = connectTo 6054 elif connectTo is None: 6055 oldName = self.nameFor(oldUnexplored) 6056 if ( 6057 oldName.startswith('_u.') 6058 and oldName[3:].isdigit() 6059 ): 6060 newName = utils.uniqueName('_x.' + oldName[3:], self) 6061 else: 6062 newName = oldName 6063 else: 6064 raise TypeError( 6065 f"Invalid connectTo value: {connectTo!r}" 6066 ) 6067 elif connectTo is not None: 6068 try: 6069 connectID = self.resolveDecision(connectTo) 6070 # leave newName as None 6071 except MissingDecisionError: 6072 if isinstance(connectTo, int): 6073 raise 6074 elif isinstance(connectTo, base.DecisionSpecifier): 6075 newName = connectTo.name 6076 # The domain & zone are ignored here 6077 else: # Must just be a string 6078 assert isinstance(connectTo, str) 6079 newName = connectTo 6080 else: 6081 # If connectTo name wasn't specified, use current name of 6082 # unknown node unless it's a default name 6083 oldName = self.nameFor(oldUnexplored) 6084 if ( 6085 oldName.startswith('_u.') 6086 and oldName[3:].isdigit() 6087 ): 6088 newName = utils.uniqueName('_x.' + oldName[3:], self) 6089 else: 6090 newName = oldName 6091 6092 # One or the other should be valid at this point 6093 assert connectID is not None or newName is not None 6094 6095 # Check that the old unknown doesn't have a reciprocal edge that 6096 # would collide with the specified return edge 6097 if reciprocal is not None: 6098 revFromUnknown = self.getDestination(oldUnexplored, reciprocal) 6099 if revFromUnknown not in (None, fromID): 6100 raise TransitionCollisionError( 6101 f"Transition {reciprocal!r} from" 6102 f" {self.identityOf(oldUnexplored)} exists and does" 6103 f" not lead back to {self.identityOf(fromDecision)}" 6104 f" (it leads to {self.identityOf(revFromUnknown)})." 6105 ) 6106 6107 # Remember old reciprocal edge for future merging in case 6108 # it's not reciprocal 6109 oldReciprocal = self.getReciprocal(fromID, transition) 6110 6111 # Apply any new tags or annotations, or create a new node 6112 needsZoneInfo = False 6113 if connectID is not None: 6114 # Before applying tags, check if we need to error out 6115 # because of a reciprocal edge that points to a known 6116 # destination: 6117 if reciprocal is not None: 6118 otherOldUnknown: Optional[ 6119 base.DecisionID 6120 ] = self.getDestination( 6121 connectID, 6122 reciprocal 6123 ) 6124 if ( 6125 otherOldUnknown is not None 6126 and self.isConfirmed(otherOldUnknown) 6127 ): 6128 raise ExplorationStatusError( 6129 f"Reciprocal transition {reciprocal!r} from" 6130 f" {self.identityOf(connectTo)} does not lead" 6131 f" to an unconfirmed decision (it leads to" 6132 f" {self.identityOf(otherOldUnknown)})." 6133 ) 6134 self.tagDecision(connectID, decisionTags) 6135 self.annotateDecision(connectID, decisionAnnotations) 6136 # Still needs zone info if the place we're connecting to was 6137 # unconfirmed up until now, since unconfirmed nodes don't 6138 # normally get zone info when they're created. 6139 if not self.isConfirmed(connectID): 6140 needsZoneInfo = True 6141 6142 # First, merge the old unknown with the connectTo node... 6143 destRenames = self.mergeDecisions( 6144 oldUnexplored, 6145 connectID, 6146 errorOnNameColision=False 6147 ) 6148 else: 6149 needsZoneInfo = True 6150 if len(self.zoneParents(oldUnexplored)) > 0: 6151 needsZoneInfo = False 6152 assert newName is not None 6153 self.renameDecision(oldUnexplored, newName) 6154 connectID = oldUnexplored 6155 # In this case there can't be an other old unknown 6156 otherOldUnknown = None 6157 destRenames = {} # empty 6158 6159 # Check for domain mismatch to stifle zone updates: 6160 fromDomain = self.domainFor(fromID) 6161 if connectID is None: 6162 destDomain = self.domainFor(oldUnexplored) 6163 else: 6164 destDomain = self.domainFor(connectID) 6165 6166 # Stifle zone updates if there's a mismatch 6167 if fromDomain != destDomain: 6168 needsZoneInfo = False 6169 6170 # Records renames that happen at the source (from node) 6171 sourceRenames = {} # empty for now 6172 6173 assert connectID is not None 6174 6175 # Apply the new zone if there is one 6176 if placeInZone is not None: 6177 if placeInZone == base.DefaultZone: 6178 # When using DefaultZone, changes are only made for new 6179 # destinations which don't already have any zones and 6180 # which are in the same domain as the departing node: 6181 # they get placed into each zone parent of the source 6182 # decision. 6183 if needsZoneInfo: 6184 # Remove destination from all current parents 6185 removeFrom = set(self.zoneParents(connectID)) # copy 6186 for parent in removeFrom: 6187 self.removeDecisionFromZone(connectID, parent) 6188 # Add it to parents of origin 6189 for parent in self.zoneParents(fromID): 6190 self.addDecisionToZone(connectID, parent) 6191 else: 6192 placeInZone = cast(base.Zone, placeInZone) 6193 # Create the zone if it doesn't already exist 6194 if self.getZoneInfo(placeInZone) is None: 6195 self.createZone(placeInZone, 0) 6196 # Add it to each grandparent of the from decision 6197 for parent in self.zoneParents(fromID): 6198 for grandparent in self.zoneParents(parent): 6199 self.addZoneToZone(placeInZone, grandparent) 6200 # Remove destination from all current parents 6201 for parent in set(self.zoneParents(connectID)): 6202 self.removeDecisionFromZone(connectID, parent) 6203 # Add it to the specified zone 6204 self.addDecisionToZone(connectID, placeInZone) 6205 6206 # Next, if there is a reciprocal name specified, we do more... 6207 if reciprocal is not None: 6208 # Figure out what kind of merging needs to happen 6209 if otherOldUnknown is None: 6210 if revFromUnknown is None: 6211 # Just create the desired reciprocal transition, which 6212 # we know does not already exist 6213 self.addTransition(connectID, reciprocal, fromID) 6214 otherOldReciprocal = None 6215 else: 6216 # Reciprocal exists, as revFromUnknown 6217 otherOldReciprocal = None 6218 else: 6219 otherOldReciprocal = self.getReciprocal( 6220 connectID, 6221 reciprocal 6222 ) 6223 # we need to merge otherOldUnknown into our fromDecision 6224 sourceRenames = self.mergeDecisions( 6225 otherOldUnknown, 6226 fromID, 6227 errorOnNameColision=False 6228 ) 6229 # Unvisited tag after merge only if both were 6230 6231 # No matter what happened we ensure the reciprocal 6232 # relationship is set up: 6233 self.setReciprocal(fromID, transition, reciprocal) 6234 6235 # Now we might need to merge some transitions: 6236 # - Any reciprocal of the target transition should be merged 6237 # with reciprocal (if it was already reciprocal, that's a 6238 # no-op). 6239 # - Any reciprocal of the reciprocal transition from the target 6240 # node (leading to otherOldUnknown) should be merged with 6241 # the target transition, even if it shared a name and was 6242 # renamed as a result. 6243 # - If reciprocal was renamed during the initial merge, those 6244 # transitions should be merged. 6245 6246 # Merge old reciprocal into reciprocal 6247 if oldReciprocal is not None: 6248 oldRev = destRenames.get(oldReciprocal, oldReciprocal) 6249 if self.getDestination(connectID, oldRev) is not None: 6250 # Note that we don't want to auto-merge the reciprocal, 6251 # which is the target transition 6252 self.mergeTransitions( 6253 connectID, 6254 oldRev, 6255 reciprocal, 6256 mergeReciprocal=False 6257 ) 6258 # Remove it from the renames map 6259 if oldReciprocal in destRenames: 6260 del destRenames[oldReciprocal] 6261 6262 # Merge reciprocal reciprocal from otherOldUnknown 6263 if otherOldReciprocal is not None: 6264 otherOldRev = sourceRenames.get( 6265 otherOldReciprocal, 6266 otherOldReciprocal 6267 ) 6268 # Note that the reciprocal is reciprocal, which we don't 6269 # need to merge 6270 self.mergeTransitions( 6271 fromID, 6272 otherOldRev, 6273 transition, 6274 mergeReciprocal=False 6275 ) 6276 # Remove it from the renames map 6277 if otherOldReciprocal in sourceRenames: 6278 del sourceRenames[otherOldReciprocal] 6279 6280 # Merge any renamed reciprocal onto reciprocal 6281 if reciprocal in destRenames: 6282 extraRev = destRenames[reciprocal] 6283 self.mergeTransitions( 6284 connectID, 6285 extraRev, 6286 reciprocal, 6287 mergeReciprocal=False 6288 ) 6289 # Remove it from the renames map 6290 del destRenames[reciprocal] 6291 6292 # Accumulate new tags & annotations for the transitions 6293 self.tagTransition(fromID, transition, tags) 6294 self.annotateTransition(fromID, transition, annotations) 6295 6296 if reciprocal is not None: 6297 self.tagTransition(connectID, reciprocal, revTags) 6298 self.annotateTransition(connectID, reciprocal, revAnnotations) 6299 6300 # Override copied requirement/consequences for the transitions 6301 if requirement is not None: 6302 self.setTransitionRequirement( 6303 fromID, 6304 transition, 6305 requirement 6306 ) 6307 if applyConsequence is not None: 6308 self.setConsequence( 6309 fromID, 6310 transition, 6311 applyConsequence 6312 ) 6313 6314 if reciprocal is not None: 6315 if revRequires is not None: 6316 self.setTransitionRequirement( 6317 connectID, 6318 reciprocal, 6319 revRequires 6320 ) 6321 if revConsequence is not None: 6322 self.setConsequence( 6323 connectID, 6324 reciprocal, 6325 revConsequence 6326 ) 6327 6328 # Remove 'unconfirmed' tag if it was present 6329 self.untagDecision(connectID, 'unconfirmed') 6330 6331 # Final checks 6332 assert self.getDestination(fromDecision, transition) == connectID 6333 useConnect: base.AnyDecisionSpecifier 6334 useRev: Optional[str] 6335 if connectTo is None: 6336 useConnect = connectID 6337 else: 6338 useConnect = connectTo 6339 if reciprocal is None: 6340 useRev = self.getReciprocal(fromDecision, transition) 6341 else: 6342 useRev = reciprocal 6343 if useRev is not None: 6344 try: 6345 assert self.getDestination(useConnect, useRev) == fromID 6346 except AmbiguousDecisionSpecifierError: 6347 assert self.getDestination(connectID, useRev) == fromID 6348 6349 # Return our final rename dictionaries 6350 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.destination('_u.0', 'return')
0
>>> 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')
({}, {})
>>> '_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
6352 def endingID(self, name: base.DecisionName) -> base.DecisionID: 6353 """ 6354 Returns the decision ID for the ending with the specified name. 6355 Endings are disconnected decisions in the `ENDINGS_DOMAIN`; they 6356 don't normally include any zone information. If no ending with 6357 the specified name already existed, then a new ending with that 6358 name will be created and its Decision ID will be returned. 6359 6360 If a new decision is created, it will be tagged as unconfirmed. 6361 6362 Note that endings mostly aren't special: they're normal 6363 decisions in a separate singular-focalized domain. However, some 6364 parts of the exploration and journal machinery treat them 6365 differently (in particular, taking certain actions via 6366 `advanceSituation` while any decision in the `ENDINGS_DOMAIN` is 6367 active is an error. 6368 """ 6369 # Create our new ending decision if we need to 6370 try: 6371 endID = self.resolveDecision( 6372 base.DecisionSpecifier(ENDINGS_DOMAIN, None, name) 6373 ) 6374 except MissingDecisionError: 6375 # Create a new decision for the ending 6376 endID = self.addDecision(name, domain=ENDINGS_DOMAIN) 6377 # Tag it as unconfirmed 6378 self.tagDecision(endID, 'unconfirmed') 6379 6380 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.
6382 def triggerGroupID(self, name: base.DecisionName) -> base.DecisionID: 6383 """ 6384 Given the name of a trigger group, returns the ID of the special 6385 node representing that trigger group in the `TRIGGERS_DOMAIN`. 6386 If the specified group didn't already exist, it will be created. 6387 6388 Trigger group decisions are not special: they just exist in a 6389 separate spreading-focalized domain and have a few API methods to 6390 access them, but all the normal decision-related API methods 6391 still work. Their intended use is for sets of global triggers, 6392 by attaching actions with the 'trigger' tag to them and then 6393 activating or deactivating them as needed. 6394 """ 6395 result = self.getDecision( 6396 base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name) 6397 ) 6398 if result is None: 6399 return self.addDecision(name, domain=TRIGGERS_DOMAIN) 6400 else: 6401 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.
6403 @staticmethod 6404 def example(which: Literal['simple', 'abc']) -> 'DecisionGraph': 6405 """ 6406 Returns one of a number of example decision graphs, depending on 6407 the string given. It returns a fresh copy each time. The graphs 6408 are: 6409 6410 - 'simple': Three nodes named 'A', 'B', and 'C' with IDs 0, 1, 6411 and 2, each connected to the next in the sequence by a 6412 'next' transition with reciprocal 'prev'. In other words, a 6413 simple little triangle. There are no tags, annotations, 6414 requirements, consequences, mechanisms, or equivalences. 6415 - 'abc': A more complicated 3-node setup that introduces a 6416 little bit of everything. In this graph, we have the same 6417 three nodes, but different transitions: 6418 6419 * From A you can go 'left' to B with reciprocal 'right'. 6420 * From A you can also go 'up_left' to B with reciprocal 6421 'up_right'. These transitions both require the 6422 'grate' mechanism (which is at decision A) to be in 6423 state 'open'. 6424 * From A you can go 'down' to C with reciprocal 'up'. 6425 6426 (In this graph, B and C are not directly connected to each 6427 other.) 6428 6429 The graph has two level-0 zones 'zoneA' and 'zoneB', along 6430 with a level-1 zone 'upZone'. Decisions A and C are in 6431 zoneA while B is in zoneB; zoneA is in upZone, but zoneB is 6432 not. 6433 6434 The decision A has annotation: 6435 6436 'This is a multi-word "annotation."' 6437 6438 The transition 'down' from A has annotation: 6439 6440 "Transition 'annotation.'" 6441 6442 Decision B has tags 'b' with value 1 and 'tag2' with value 6443 '"value"'. 6444 6445 Decision C has tag 'aw"ful' with value "ha'ha'". 6446 6447 Transition 'up' from C has tag 'fast' with value 1. 6448 6449 At decision C there are actions 'grab_helmet' and 6450 'pull_lever'. 6451 6452 The 'grab_helmet' transition requires that you don't have 6453 the 'helmet' capability, and gives you that capability, 6454 deactivating with delay 3. 6455 6456 The 'pull_lever' transition requires that you do have the 6457 'helmet' capability, and takes away that capability, but it 6458 also gives you 1 token, and if you have 2 tokens (before 6459 getting the one extra), it sets the 'grate' mechanism (which 6460 is a decision A) to state 'open' and deactivates. 6461 6462 The graph has an equivalence: having the 'helmet' capability 6463 satisfies requirements for the 'grate' mechanism to be in the 6464 'open' state. 6465 6466 """ 6467 result = DecisionGraph() 6468 if which == 'simple': 6469 result.addDecision('A') # id 0 6470 result.addDecision('B') # id 1 6471 result.addDecision('C') # id 2 6472 result.addTransition('A', 'next', 'B', 'prev') 6473 result.addTransition('B', 'next', 'C', 'prev') 6474 result.addTransition('C', 'next', 'A', 'prev') 6475 elif which == 'abc': 6476 result.addDecision('A') # id 0 6477 result.addDecision('B') # id 1 6478 result.addDecision('C') # id 2 6479 result.createZone('zoneA', 0) 6480 result.createZone('zoneB', 0) 6481 result.createZone('upZone', 1) 6482 result.addZoneToZone('zoneA', 'upZone') 6483 result.addDecisionToZone('A', 'zoneA') 6484 result.addDecisionToZone('B', 'zoneB') 6485 result.addDecisionToZone('C', 'zoneA') 6486 result.addTransition('A', 'left', 'B', 'right') 6487 result.addTransition('A', 'up_left', 'B', 'up_right') 6488 result.addTransition('A', 'down', 'C', 'up') 6489 result.setTransitionRequirement( 6490 'A', 6491 'up_left', 6492 base.ReqMechanism('grate', 'open') 6493 ) 6494 result.setTransitionRequirement( 6495 'B', 6496 'up_right', 6497 base.ReqMechanism('grate', 'open') 6498 ) 6499 result.annotateDecision('A', 'This is a multi-word "annotation."') 6500 result.annotateTransition('A', 'down', "Transition 'annotation.'") 6501 result.tagDecision('B', 'b') 6502 result.tagDecision('B', 'tag2', '"value"') 6503 result.tagDecision('C', 'aw"ful', "ha'ha") 6504 result.tagTransition('C', 'up', 'fast') 6505 result.addMechanism('grate', 'A') 6506 result.addAction( 6507 'C', 6508 'grab_helmet', 6509 base.ReqNot(base.ReqCapability('helmet')), 6510 [ 6511 base.effect(gain='helmet'), 6512 base.effect(deactivate=True, delay=3) 6513 ] 6514 ) 6515 result.addAction( 6516 'C', 6517 'pull_lever', 6518 base.ReqCapability('helmet'), 6519 [ 6520 base.effect(lose='helmet'), 6521 base.effect(gain=('token', 1)), 6522 base.condition( 6523 base.ReqTokens('token', 2), 6524 [ 6525 base.effect(set=('grate', 'open')), 6526 base.effect(deactivate=True) 6527 ] 6528 ) 6529 ] 6530 ) 6531 result.addEquivalence( 6532 base.ReqCapability('helmet'), 6533 (0, 'open') 6534 ) 6535 else: 6536 raise ValueError(f"Invalid example name: {which!r}") 6537 6538 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, 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
6545def emptySituation() -> base.Situation: 6546 """ 6547 Creates and returns an empty situation: A situation that has an 6548 empty `DecisionGraph`, an empty `State`, a 'pending' decision type 6549 with `None` as the action taken, no tags, and no annotations. 6550 """ 6551 return base.Situation( 6552 graph=DecisionGraph(), 6553 state=base.emptyState(), 6554 type='pending', 6555 action=None, 6556 saves={}, 6557 tags={}, 6558 annotations=[] 6559 )
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.
6562class DiscreteExploration: 6563 """ 6564 A list of `Situations` each of which contains a `DecisionGraph` 6565 representing exploration over time, with `States` containing 6566 `FocalContext` information for each step and 'taken' values for the 6567 transition selected (at a particular decision) in that step. Each 6568 decision graph represents a new state of the world (and/or new 6569 knowledge about a persisting state of the world), and the 'taken' 6570 transition in one situation transition indicates which option was 6571 selected, or what event happened to cause update(s). Depending on the 6572 resolution, it could represent a close record of every decision made 6573 or a more coarse set of snapshots from gameplay with more time in 6574 between. 6575 6576 The steps of the exploration can also be tagged and annotated (see 6577 `tagStep` and `annotateStep`). 6578 6579 It also holds a `layouts` field that includes zero or more 6580 `base.Layout`s by name. 6581 6582 When a new `DiscreteExploration` is created, it starts out with an 6583 empty `Situation` that contains an empty `DecisionGraph`. Use the 6584 `start` method to name the starting decision point and set things up 6585 for other methods. 6586 6587 Tracking of player goals and destinations is also planned (see the 6588 `quest`, `progress`, `complete`, `destination`, and `arrive` methods). 6589 TODO: That 6590 """ 6591 def __init__(self) -> None: 6592 self.situations: List[base.Situation] = [ 6593 base.Situation( 6594 graph=DecisionGraph(), 6595 state=base.emptyState(), 6596 type='pending', 6597 action=None, 6598 saves={}, 6599 tags={}, 6600 annotations=[] 6601 ) 6602 ] 6603 self.layouts: Dict[str, base.Layout] = {} 6604 6605 # Note: not hashable 6606 6607 def __eq__(self, other): 6608 """ 6609 Equality checker. `DiscreteExploration`s can only be equal to 6610 other `DiscreteExploration`s, not to other kinds of things. 6611 """ 6612 if not isinstance(other, DiscreteExploration): 6613 return False 6614 else: 6615 return self.situations == other.situations 6616 6617 @staticmethod 6618 def fromGraph( 6619 graph: DecisionGraph, 6620 state: Optional[base.State] = None 6621 ) -> 'DiscreteExploration': 6622 """ 6623 Creates an exploration which has just a single step whose graph 6624 is the entire specified graph, with the specified decision as 6625 the primary decision (if any). The graph is copied, so that 6626 changes to the exploration will not modify it. A starting state 6627 may also be specified if desired, although if not an empty state 6628 will be used (a provided starting state is NOT copied, but used 6629 directly). 6630 6631 Example: 6632 6633 >>> g = DecisionGraph() 6634 >>> g.addDecision('Room1') 6635 0 6636 >>> g.addDecision('Room2') 6637 1 6638 >>> g.addTransition('Room1', 'door', 'Room2', 'door') 6639 >>> e = DiscreteExploration.fromGraph(g) 6640 >>> len(e) 6641 1 6642 >>> e.getSituation().graph == g 6643 True 6644 >>> e.getActiveDecisions() 6645 set() 6646 >>> e.primaryDecision() is None 6647 True 6648 >>> e.observe('Room1', 'hatch') 6649 2 6650 >>> e.getSituation().graph == g 6651 False 6652 >>> e.getSituation().graph.destinationsFrom('Room1') 6653 {'door': 1, 'hatch': 2} 6654 >>> g.destinationsFrom('Room1') 6655 {'door': 1} 6656 """ 6657 result = DiscreteExploration() 6658 result.situations[0] = base.Situation( 6659 graph=copy.deepcopy(graph), 6660 state=base.emptyState() if state is None else state, 6661 type='pending', 6662 action=None, 6663 saves={}, 6664 tags={}, 6665 annotations=[] 6666 ) 6667 return result 6668 6669 def __len__(self) -> int: 6670 """ 6671 The 'length' of an exploration is the number of steps. 6672 """ 6673 return len(self.situations) 6674 6675 def __getitem__(self, i: int) -> base.Situation: 6676 """ 6677 Indexing an exploration returns the situation at that step. 6678 """ 6679 return self.situations[i] 6680 6681 def __iter__(self) -> Iterator[base.Situation]: 6682 """ 6683 Iterating over an exploration yields each `Situation` in order. 6684 """ 6685 for i in range(len(self)): 6686 yield self[i] 6687 6688 def getSituation(self, step: int = -1) -> base.Situation: 6689 """ 6690 Returns a `base.Situation` named tuple detailing the state of 6691 the exploration at a given step (or at the current step if no 6692 argument is given). Note that this method works the same 6693 way as indexing the exploration: see `__getitem__`. 6694 6695 Raises an `IndexError` if asked for a step that's out-of-range. 6696 """ 6697 return self[step] 6698 6699 def primaryDecision(self, step: int = -1) -> Optional[base.DecisionID]: 6700 """ 6701 Returns the current primary `base.DecisionID`, or the primary 6702 decision from a specific step if one is specified. This may be 6703 `None` for some steps, but mostly it's the destination of the 6704 transition taken in the previous step. 6705 """ 6706 return self[step].state['primaryDecision'] 6707 6708 def effectiveCapabilities( 6709 self, 6710 step: int = -1 6711 ) -> base.CapabilitySet: 6712 """ 6713 Returns the effective capability set for the specified step 6714 (default is the last/current step). See 6715 `base.effectiveCapabilities`. 6716 """ 6717 return base.effectiveCapabilitySet(self.getSituation(step).state) 6718 6719 def getCommonContext( 6720 self, 6721 step: Optional[int] = None 6722 ) -> base.FocalContext: 6723 """ 6724 Returns the common `FocalContext` at the specified step, or at 6725 the current step if no argument is given. Raises an `IndexError` 6726 if an invalid step is specified. 6727 """ 6728 if step is None: 6729 step = -1 6730 state = self.getSituation(step).state 6731 return state['common'] 6732 6733 def getActiveContext( 6734 self, 6735 step: Optional[int] = None 6736 ) -> base.FocalContext: 6737 """ 6738 Returns the active `FocalContext` at the specified step, or at 6739 the current step if no argument is provided. Raises an 6740 `IndexError` if an invalid step is specified. 6741 """ 6742 if step is None: 6743 step = -1 6744 state = self.getSituation(step).state 6745 return state['contexts'][state['activeContext']] 6746 6747 def addFocalContext(self, name: base.FocalContextName) -> None: 6748 """ 6749 Adds a new empty focal context to our set of focal contexts (see 6750 `emptyFocalContext`). Use `setActiveContext` to swap to it. 6751 Raises a `FocalContextCollisionError` if the name is already in 6752 use. 6753 """ 6754 contextMap = self.getSituation().state['contexts'] 6755 if name in contextMap: 6756 raise FocalContextCollisionError( 6757 f"Cannot add focal context {name!r}: a focal context" 6758 f" with that name already exists." 6759 ) 6760 contextMap[name] = base.emptyFocalContext() 6761 6762 def setActiveContext(self, which: base.FocalContextName) -> None: 6763 """ 6764 Sets the active context to the named focal context, creating it 6765 if it did not already exist (makes changes to the current 6766 situation only). Does not add an exploration step (use 6767 `advanceSituation` with a 'swap' action for that). 6768 """ 6769 state = self.getSituation().state 6770 contextMap = state['contexts'] 6771 if which not in contextMap: 6772 self.addFocalContext(which) 6773 state['activeContext'] = which 6774 6775 def createDomain( 6776 self, 6777 name: base.Domain, 6778 focalization: base.DomainFocalization = 'singular', 6779 makeActive: bool = False, 6780 inCommon: Union[bool, Literal["both"]] = "both" 6781 ) -> None: 6782 """ 6783 Creates a new domain with the given focalization type, in either 6784 the common context (`inCommon` = `True`) the active context 6785 (`inCommon` = `False`) or both (the default; `inCommon` = 'both'). 6786 The domain's focalization will be set to the given 6787 `focalization` value (default 'singular') and it will have no 6788 active decisions. Raises a `DomainCollisionError` if a domain 6789 with the specified name already exists. 6790 6791 Creates the domain in the current situation. 6792 6793 If `makeActive` is set to `True` (default is `False`) then the 6794 domain will be made active in whichever context(s) it's created 6795 in. 6796 """ 6797 now = self.getSituation() 6798 state = now.state 6799 modify = [] 6800 if inCommon in (True, "both"): 6801 modify.append(('common', state['common'])) 6802 if inCommon in (False, "both"): 6803 acName = state['activeContext'] 6804 modify.append( 6805 ('current ({repr(acName)})', state['contexts'][acName]) 6806 ) 6807 6808 for (fcType, fc) in modify: 6809 if name in fc['focalization']: 6810 raise DomainCollisionError( 6811 f"Cannot create domain {repr(name)} because a" 6812 f" domain with that name already exists in the" 6813 f" {fcType} focal context." 6814 ) 6815 fc['focalization'][name] = focalization 6816 if makeActive: 6817 fc['activeDomains'].add(name) 6818 if focalization == "spreading": 6819 fc['activeDecisions'][name] = set() 6820 elif focalization == "plural": 6821 fc['activeDecisions'][name] = {} 6822 else: 6823 fc['activeDecisions'][name] = None 6824 6825 def activateDomain( 6826 self, 6827 domain: base.Domain, 6828 activate: bool = True, 6829 inContext: base.ContextSpecifier = "active" 6830 ) -> None: 6831 """ 6832 Sets the given domain as active (or inactive if 'activate' is 6833 given as `False`) in the specified context (default "active"). 6834 6835 Modifies the current situation. 6836 """ 6837 fc: base.FocalContext 6838 if inContext == "active": 6839 fc = self.getActiveContext() 6840 elif inContext == "common": 6841 fc = self.getCommonContext() 6842 6843 if activate: 6844 fc['activeDomains'].add(domain) 6845 else: 6846 try: 6847 fc['activeDomains'].remove(domain) 6848 except KeyError: 6849 pass 6850 6851 def createTriggerGroup( 6852 self, 6853 name: base.DecisionName 6854 ) -> base.DecisionID: 6855 """ 6856 Creates a new trigger group with the given name, returning the 6857 decision ID for that trigger group. If this is the first trigger 6858 group being created, also creates the `TRIGGERS_DOMAIN` domain 6859 as a spreading-focalized domain that's active in the common 6860 context (but does NOT set the created trigger group as an active 6861 decision in that domain). 6862 6863 You can use 'goto' effects to activate trigger domains via 6864 consequences, and 'retreat' effects to deactivate them. 6865 6866 Creating a second trigger group with the same name as another 6867 results in a `ValueError`. 6868 6869 TODO: Retreat effects 6870 """ 6871 ctx = self.getCommonContext() 6872 if TRIGGERS_DOMAIN not in ctx['focalization']: 6873 self.createDomain( 6874 TRIGGERS_DOMAIN, 6875 focalization='spreading', 6876 makeActive=True, 6877 inCommon=True 6878 ) 6879 6880 graph = self.getSituation().graph 6881 if graph.getDecision( 6882 base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name) 6883 ) is not None: 6884 raise ValueError( 6885 f"Cannot create trigger group {name!r}: a trigger group" 6886 f" with that name already exists." 6887 ) 6888 6889 return self.getSituation().graph.triggerGroupID(name) 6890 6891 def toggleTriggerGroup( 6892 self, 6893 name: base.DecisionName, 6894 setActive: Union[bool, None] = None 6895 ): 6896 """ 6897 Toggles whether the specified trigger group (a decision in the 6898 `TRIGGERS_DOMAIN`) is active or not. Pass `True` or `False` as 6899 the `setActive` argument (instead of the default `None`) to set 6900 the state directly instead of toggling it. 6901 6902 Note that trigger groups are decisions in a spreading-focalized 6903 domain, so they can be activated or deactivated by the 'goto' 6904 and 'retreat' effects as well. 6905 6906 This does not affect whether the `TRIGGERS_DOMAIN` itself is 6907 active (normally it would always be active). 6908 6909 Raises a `MissingDecisionError` if the specified trigger group 6910 does not exist yet, including when the entire `TRIGGERS_DOMAIN` 6911 does not exist. Raises a `KeyError` if the target group exists 6912 but the `TRIGGERS_DOMAIN` has not been set up properly. 6913 """ 6914 ctx = self.getCommonContext() 6915 tID = self.getSituation().graph.resolveDecision( 6916 base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name) 6917 ) 6918 activeGroups = ctx['activeDecisions'][TRIGGERS_DOMAIN] 6919 assert isinstance(activeGroups, set) 6920 if tID in activeGroups: 6921 if setActive is not True: 6922 activeGroups.remove(tID) 6923 else: 6924 if setActive is not False: 6925 activeGroups.add(tID) 6926 6927 def getActiveDecisions( 6928 self, 6929 step: Optional[int] = None, 6930 inCommon: Union[bool, Literal["both"]] = "both" 6931 ) -> Set[base.DecisionID]: 6932 """ 6933 Returns the set of active decisions at the given step index, or 6934 at the current step if no step is specified. Raises an 6935 `IndexError` if the step index is out of bounds (see `__len__`). 6936 May return an empty set if no decisions are active. 6937 6938 If `inCommon` is set to "both" (the default) then decisions 6939 active in either the common or active context are returned. Set 6940 it to `True` or `False` to return only decisions active in the 6941 common (when `True`) or active (when `False`) context. 6942 """ 6943 if step is None: 6944 step = -1 6945 state = self.getSituation(step).state 6946 if inCommon == "both": 6947 return base.combinedDecisionSet(state) 6948 elif inCommon is True: 6949 return base.activeDecisionSet(state['common']) 6950 elif inCommon is False: 6951 return base.activeDecisionSet( 6952 state['contexts'][state['activeContext']] 6953 ) 6954 else: 6955 raise ValueError( 6956 f"Invalid inCommon value {repr(inCommon)} (must be" 6957 f" 'both', True, or False)." 6958 ) 6959 6960 def setActiveDecisionsAtStep( 6961 self, 6962 step: int, 6963 domain: base.Domain, 6964 activate: Union[ 6965 base.DecisionID, 6966 Dict[base.FocalPointName, Optional[base.DecisionID]], 6967 Set[base.DecisionID] 6968 ], 6969 inCommon: bool = False 6970 ) -> None: 6971 """ 6972 Changes the activation status of decisions in the active 6973 `FocalContext` at the specified step, for the specified domain 6974 (see `currentActiveContext`). Does this without adding an 6975 exploration step, which is unusual: normally you should use 6976 another method like `warp` to update active decisions. 6977 6978 Note that this does not change which domains are active, and 6979 setting active decisions in inactive domains does not make those 6980 decisions active overall. 6981 6982 Which decisions to activate or deactivate are specified as 6983 either a single `DecisionID`, a list of them, or a set of them, 6984 depending on the `DomainFocalization` setting in the selected 6985 `FocalContext` for the specified domain. A `TypeError` will be 6986 raised if the wrong kind of decision information is provided. If 6987 the focalization context does not have any focalization value for 6988 the domain in question, it will be set based on the kind of 6989 active decision information specified. 6990 6991 A `MissingDecisionError` will be raised if a decision is 6992 included which is not part of the current `DecisionGraph`. 6993 The provided information will overwrite the previous active 6994 decision information. 6995 6996 If `inCommon` is set to `True`, then decisions are activated or 6997 deactivated in the common context, instead of in the active 6998 context. 6999 7000 Example: 7001 7002 >>> e = DiscreteExploration() 7003 >>> e.getActiveDecisions() 7004 set() 7005 >>> graph = e.getSituation().graph 7006 >>> graph.addDecision('A') 7007 0 7008 >>> graph.addDecision('B') 7009 1 7010 >>> graph.addDecision('C') 7011 2 7012 >>> e.setActiveDecisionsAtStep(0, 'main', 0) 7013 >>> e.getActiveDecisions() 7014 {0} 7015 >>> e.setActiveDecisionsAtStep(0, 'main', 1) 7016 >>> e.getActiveDecisions() 7017 {1} 7018 >>> graph = e.getSituation().graph 7019 >>> graph.addDecision('One', domain='numbers') 7020 3 7021 >>> graph.addDecision('Two', domain='numbers') 7022 4 7023 >>> graph.addDecision('Three', domain='numbers') 7024 5 7025 >>> graph.addDecision('Bear', domain='animals') 7026 6 7027 >>> graph.addDecision('Spider', domain='animals') 7028 7 7029 >>> graph.addDecision('Eel', domain='animals') 7030 8 7031 >>> ac = e.getActiveContext() 7032 >>> ac['focalization']['numbers'] = 'plural' 7033 >>> ac['focalization']['animals'] = 'spreading' 7034 >>> ac['activeDecisions']['numbers'] = {'a': None, 'b': None} 7035 >>> ac['activeDecisions']['animals'] = set() 7036 >>> cc = e.getCommonContext() 7037 >>> cc['focalization']['numbers'] = 'plural' 7038 >>> cc['focalization']['animals'] = 'spreading' 7039 >>> cc['activeDecisions']['numbers'] = {'z': None} 7040 >>> cc['activeDecisions']['animals'] = set() 7041 >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 3, 'b': 3}) 7042 >>> e.getActiveDecisions() 7043 {1} 7044 >>> e.activateDomain('numbers') 7045 >>> e.getActiveDecisions() 7046 {1, 3} 7047 >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 4, 'b': None}) 7048 >>> e.getActiveDecisions() 7049 {1, 4} 7050 >>> # Wrong domain for the decision ID: 7051 >>> e.setActiveDecisionsAtStep(0, 'main', 3) 7052 Traceback (most recent call last): 7053 ... 7054 ValueError... 7055 >>> # Wrong domain for one of the decision IDs: 7056 >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 2, 'b': None}) 7057 Traceback (most recent call last): 7058 ... 7059 ValueError... 7060 >>> # Wrong kind of decision information provided. 7061 >>> e.setActiveDecisionsAtStep(0, 'numbers', 3) 7062 Traceback (most recent call last): 7063 ... 7064 TypeError... 7065 >>> e.getActiveDecisions() 7066 {1, 4} 7067 >>> e.setActiveDecisionsAtStep(0, 'animals', {6, 7}) 7068 >>> e.getActiveDecisions() 7069 {1, 4} 7070 >>> e.activateDomain('animals') 7071 >>> e.getActiveDecisions() 7072 {1, 4, 6, 7} 7073 >>> e.setActiveDecisionsAtStep(0, 'animals', {8}) 7074 >>> e.getActiveDecisions() 7075 {8, 1, 4} 7076 >>> e.setActiveDecisionsAtStep(1, 'main', 2) # invalid step 7077 Traceback (most recent call last): 7078 ... 7079 IndexError... 7080 >>> e.setActiveDecisionsAtStep(0, 'novel', 0) # domain mismatch 7081 Traceback (most recent call last): 7082 ... 7083 ValueError... 7084 7085 Example of active/common contexts: 7086 7087 >>> e = DiscreteExploration() 7088 >>> graph = e.getSituation().graph 7089 >>> graph.addDecision('A') 7090 0 7091 >>> graph.addDecision('B') 7092 1 7093 >>> e.activateDomain('main', inContext="common") 7094 >>> e.setActiveDecisionsAtStep(0, 'main', 0, inCommon=True) 7095 >>> e.getActiveDecisions() 7096 {0} 7097 >>> e.setActiveDecisionsAtStep(0, 'main', None) 7098 >>> e.getActiveDecisions() 7099 {0} 7100 >>> # (Still active since it's active in the common context) 7101 >>> e.setActiveDecisionsAtStep(0, 'main', 1) 7102 >>> e.getActiveDecisions() 7103 {0, 1} 7104 >>> e.setActiveDecisionsAtStep(0, 'main', 1, inCommon=True) 7105 >>> e.getActiveDecisions() 7106 {1} 7107 >>> e.setActiveDecisionsAtStep(0, 'main', None, inCommon=True) 7108 >>> e.getActiveDecisions() 7109 {1} 7110 >>> # (Still active since it's active in the active context) 7111 >>> e.setActiveDecisionsAtStep(0, 'main', None) 7112 >>> e.getActiveDecisions() 7113 set() 7114 """ 7115 now = self.getSituation(step) 7116 graph = now.graph 7117 if inCommon: 7118 context = self.getCommonContext(step) 7119 else: 7120 context = self.getActiveContext(step) 7121 7122 defaultFocalization: base.DomainFocalization = 'singular' 7123 if isinstance(activate, base.DecisionID): 7124 defaultFocalization = 'singular' 7125 elif isinstance(activate, dict): 7126 defaultFocalization = 'plural' 7127 elif isinstance(activate, set): 7128 defaultFocalization = 'spreading' 7129 elif domain not in context['focalization']: 7130 raise TypeError( 7131 f"Domain {domain!r} has no focalization in the" 7132 f" {'common' if inCommon else 'active'} context," 7133 f" and the specified position doesn't imply one." 7134 ) 7135 7136 focalization = base.getDomainFocalization( 7137 context, 7138 domain, 7139 defaultFocalization 7140 ) 7141 7142 # Check domain & existence of decision(s) in question 7143 if activate is None: 7144 pass 7145 elif isinstance(activate, base.DecisionID): 7146 if activate not in graph: 7147 raise MissingDecisionError( 7148 f"There is no decision {activate} at step {step}." 7149 ) 7150 if graph.domainFor(activate) != domain: 7151 raise ValueError( 7152 f"Can't set active decisions in domain {domain!r}" 7153 f" to decision {graph.identityOf(activate)} because" 7154 f" that decision is in actually in domain" 7155 f" {graph.domainFor(activate)!r}." 7156 ) 7157 elif isinstance(activate, dict): 7158 for fpName, pos in activate.items(): 7159 if pos is None: 7160 continue 7161 if pos not in graph: 7162 raise MissingDecisionError( 7163 f"There is no decision {pos} at step {step}." 7164 ) 7165 if graph.domainFor(pos) != domain: 7166 raise ValueError( 7167 f"Can't set active decision for focal point" 7168 f" {fpName!r} in domain {domain!r}" 7169 f" to decision {graph.identityOf(pos)} because" 7170 f" that decision is in actually in domain" 7171 f" {graph.domainFor(pos)!r}." 7172 ) 7173 elif isinstance(activate, set): 7174 for pos in activate: 7175 if pos not in graph: 7176 raise MissingDecisionError( 7177 f"There is no decision {pos} at step {step}." 7178 ) 7179 if graph.domainFor(pos) != domain: 7180 raise ValueError( 7181 f"Can't set {graph.identityOf(pos)} as an" 7182 f" active decision in domain {domain!r} to" 7183 f" decision because that decision is in" 7184 f" actually in domain {graph.domainFor(pos)!r}." 7185 ) 7186 else: 7187 raise TypeError( 7188 f"Domain {domain!r} has no focalization in the" 7189 f" {'common' if inCommon else 'active'} context," 7190 f" and the specified position doesn't imply one:" 7191 f"\n{activate!r}" 7192 ) 7193 7194 if focalization == 'singular': 7195 if activate is None or isinstance(activate, base.DecisionID): 7196 if activate is not None: 7197 targetDomain = graph.domainFor(activate) 7198 if activate not in graph: 7199 raise MissingDecisionError( 7200 f"There is no decision {activate} in the" 7201 f" graph at step {step}." 7202 ) 7203 elif targetDomain != domain: 7204 raise ValueError( 7205 f"At step {step}, decision {activate} cannot" 7206 f" be the active decision for domain" 7207 f" {repr(domain)} because it is in a" 7208 f" different domain ({repr(targetDomain)})." 7209 ) 7210 context['activeDecisions'][domain] = activate 7211 else: 7212 raise TypeError( 7213 f"{'Common' if inCommon else 'Active'} focal" 7214 f" context at step {step} has {repr(focalization)}" 7215 f" focalization for domain {repr(domain)}, so the" 7216 f" active decision must be a single decision or" 7217 f" None.\n(You provided: {repr(activate)})" 7218 ) 7219 elif focalization == 'plural': 7220 if ( 7221 isinstance(activate, dict) 7222 and all( 7223 isinstance(k, base.FocalPointName) 7224 for k in activate.keys() 7225 ) 7226 and all( 7227 v is None or isinstance(v, base.DecisionID) 7228 for v in activate.values() 7229 ) 7230 ): 7231 for v in activate.values(): 7232 if v is not None: 7233 targetDomain = graph.domainFor(v) 7234 if v not in graph: 7235 raise MissingDecisionError( 7236 f"There is no decision {v} in the graph" 7237 f" at step {step}." 7238 ) 7239 elif targetDomain != domain: 7240 raise ValueError( 7241 f"At step {step}, decision {activate}" 7242 f" cannot be an active decision for" 7243 f" domain {repr(domain)} because it is" 7244 f" in a different domain" 7245 f" ({repr(targetDomain)})." 7246 ) 7247 context['activeDecisions'][domain] = activate 7248 else: 7249 raise TypeError( 7250 f"{'Common' if inCommon else 'Active'} focal" 7251 f" context at step {step} has {repr(focalization)}" 7252 f" focalization for domain {repr(domain)}, so the" 7253 f" active decision must be a dictionary mapping" 7254 f" focal point names to decision IDs (or Nones)." 7255 f"\n(You provided: {repr(activate)})" 7256 ) 7257 elif focalization == 'spreading': 7258 if ( 7259 isinstance(activate, set) 7260 and all(isinstance(x, base.DecisionID) for x in activate) 7261 ): 7262 for x in activate: 7263 targetDomain = graph.domainFor(x) 7264 if x not in graph: 7265 raise MissingDecisionError( 7266 f"There is no decision {x} in the graph" 7267 f" at step {step}." 7268 ) 7269 elif targetDomain != domain: 7270 raise ValueError( 7271 f"At step {step}, decision {activate}" 7272 f" cannot be an active decision for" 7273 f" domain {repr(domain)} because it is" 7274 f" in a different domain" 7275 f" ({repr(targetDomain)})." 7276 ) 7277 context['activeDecisions'][domain] = activate 7278 else: 7279 raise TypeError( 7280 f"{'Common' if inCommon else 'Active'} focal" 7281 f" context at step {step} has {repr(focalization)}" 7282 f" focalization for domain {repr(domain)}, so the" 7283 f" active decision must be a set of decision IDs" 7284 f"\n(You provided: {repr(activate)})" 7285 ) 7286 else: 7287 raise RuntimeError( 7288 f"Invalid focalization value {repr(focalization)} for" 7289 f" domain {repr(domain)} at step {step}." 7290 ) 7291 7292 def movementAtStep(self, step: int = -1) -> Tuple[ 7293 Union[base.DecisionID, Set[base.DecisionID], None], 7294 Optional[base.Transition], 7295 Union[base.DecisionID, Set[base.DecisionID], None] 7296 ]: 7297 """ 7298 Given a step number, returns information about the starting 7299 decision, transition taken, and destination decision for that 7300 step. Not all steps have all of those, so some items may be 7301 `None`. 7302 7303 For steps where there is no action, where a decision is still 7304 pending, or where the action type is 'focus', 'swap', 'focalize', 7305 or 'revertTo', the result will be `(None, None, None)`, unless a 7306 primary decision is available in which case the first item in the 7307 tuple will be that decision. For 'start' actions, the starting 7308 position and transition will be `None` (again unless the step had 7309 a primary decision) but the destination will be the ID of the 7310 node started at. For 'revertTo' actions, the destination will be 7311 the primary decision of the state reverted to, if available. 7312 7313 Also, if the action taken has multiple potential or actual start 7314 or end points, these may be sets of decision IDs instead of 7315 single IDs. 7316 7317 Note that the primary decision of the starting state is usually 7318 used as the from-decision, but in some cases an action dictates 7319 taking a transition from a different decision, and this function 7320 will return that decision as the from-decision. 7321 7322 TODO: Examples! 7323 7324 TODO: Account for bounce/follow/goto effects!!! 7325 """ 7326 now = self.getSituation(step) 7327 action = now.action 7328 graph = now.graph 7329 primary = now.state['primaryDecision'] 7330 7331 if action is None: 7332 return (primary, None, None) 7333 7334 aType = action[0] 7335 fromID: Optional[base.DecisionID] 7336 destID: Optional[base.DecisionID] 7337 transition: base.Transition 7338 outcomes: List[bool] 7339 7340 if aType in ('noAction', 'focus', 'swap', 'focalize'): 7341 return (primary, None, None) 7342 elif aType == 'start': 7343 assert len(action) == 7 7344 where = cast( 7345 Union[ 7346 base.DecisionID, 7347 Dict[base.FocalPointName, base.DecisionID], 7348 Set[base.DecisionID] 7349 ], 7350 action[1] 7351 ) 7352 if isinstance(where, dict): 7353 where = set(where.values()) 7354 return (primary, None, where) 7355 elif aType in ('take', 'explore'): 7356 if ( 7357 (len(action) == 4 or len(action) == 7) 7358 and isinstance(action[2], base.DecisionID) 7359 ): 7360 fromID = action[2] 7361 assert isinstance(action[3], tuple) 7362 transition, outcomes = action[3] 7363 if ( 7364 action[0] == "explore" 7365 and isinstance(action[4], base.DecisionID) 7366 ): 7367 destID = action[4] 7368 else: 7369 destID = graph.getDestination(fromID, transition) 7370 return (fromID, transition, destID) 7371 elif ( 7372 (len(action) == 3 or len(action) == 6) 7373 and isinstance(action[1], tuple) 7374 and isinstance(action[2], base.Transition) 7375 and len(action[1]) == 3 7376 and action[1][0] in get_args(base.ContextSpecifier) 7377 and isinstance(action[1][1], base.Domain) 7378 and isinstance(action[1][2], base.FocalPointName) 7379 ): 7380 fromID = base.resolvePosition(now, action[1]) 7381 if fromID is None: 7382 raise InvalidActionError( 7383 f"{aType!r} action at step {step} has position" 7384 f" {action[1]!r} which cannot be resolved to a" 7385 f" decision." 7386 ) 7387 transition, outcomes = action[2] 7388 if ( 7389 action[0] == "explore" 7390 and isinstance(action[3], base.DecisionID) 7391 ): 7392 destID = action[3] 7393 else: 7394 destID = graph.getDestination(fromID, transition) 7395 return (fromID, transition, destID) 7396 else: 7397 raise InvalidActionError( 7398 f"Malformed {aType!r} action:\n{repr(action)}" 7399 ) 7400 elif aType == 'warp': 7401 if len(action) != 3: 7402 raise InvalidActionError( 7403 f"Malformed 'warp' action:\n{repr(action)}" 7404 ) 7405 dest = action[2] 7406 assert isinstance(dest, base.DecisionID) 7407 if action[1] in get_args(base.ContextSpecifier): 7408 # Unspecified starting point; find active decisions in 7409 # same domain if primary is None 7410 if primary is not None: 7411 return (primary, None, dest) 7412 else: 7413 toDomain = now.graph.domainFor(dest) 7414 # TODO: Could check destination focalization here... 7415 active = self.getActiveDecisions(step) 7416 sameDomain = set( 7417 dID 7418 for dID in active 7419 if now.graph.domainFor(dID) == toDomain 7420 ) 7421 if len(sameDomain) == 1: 7422 return ( 7423 list(sameDomain)[0], 7424 None, 7425 dest 7426 ) 7427 else: 7428 return ( 7429 sameDomain, 7430 None, 7431 dest 7432 ) 7433 else: 7434 if ( 7435 not isinstance(action[1], tuple) 7436 or not len(action[1]) == 3 7437 or not action[1][0] in get_args(base.ContextSpecifier) 7438 or not isinstance(action[1][1], base.Domain) 7439 or not isinstance(action[1][2], base.FocalPointName) 7440 ): 7441 raise InvalidActionError( 7442 f"Malformed 'warp' action:\n{repr(action)}" 7443 ) 7444 return ( 7445 base.resolvePosition(now, action[1]), 7446 None, 7447 dest 7448 ) 7449 elif aType == 'revertTo': 7450 assert len(action) == 3 # type, save slot, & aspects 7451 if primary is not None: 7452 cameFrom = primary 7453 nextSituation = self.getSituation(step + 1) 7454 wentTo = nextSituation.state['primaryDecision'] 7455 return (primary, None, wentTo) 7456 else: 7457 raise InvalidActionError( 7458 f"Action taken had invalid action type {repr(aType)}:" 7459 f"\n{repr(action)}" 7460 ) 7461 7462 def latestStepWithDecision( 7463 self, 7464 dID: base.DecisionID, 7465 startFrom: int = -1 7466 ) -> int: 7467 """ 7468 Scans backwards through exploration steps until it finds a graph 7469 that contains a decision with the specified ID, and returns the 7470 step number of that step. Instead of starting from the last step, 7471 you can tell it to start from a different step (either positive 7472 or negative index) via `startFrom`. Raises a 7473 `MissingDecisionError` if there is no such step. 7474 """ 7475 if startFrom < 0: 7476 startFrom = len(self) + startFrom 7477 for step in range(startFrom, -1, -1): 7478 graph = self.getSituation(step).graph 7479 try: 7480 return step 7481 except MissingDecisionError: 7482 continue 7483 raise MissingDecisionError( 7484 f"Decision {dID!r} does not exist at any step of the" 7485 f" exploration." 7486 ) 7487 7488 def latestDecisionInfo(self, dID: base.DecisionID) -> DecisionInfo: 7489 """ 7490 Looks up decision info for the given decision in the latest step 7491 in which that decision exists (which will usually be the final 7492 exploration step, unless the decision was merged or otherwise 7493 removed along the way). This will raise a `MissingDecisionError` 7494 only if there is no step at which the specified decision exists. 7495 """ 7496 for step in range(len(self) - 1, -1, -1): 7497 graph = self.getSituation(step).graph 7498 try: 7499 return graph.decisionInfo(dID) 7500 except MissingDecisionError: 7501 continue 7502 raise MissingDecisionError( 7503 f"Decision {dID!r} does not exist at any step of the" 7504 f" exploration." 7505 ) 7506 7507 def latestTransitionProperties( 7508 self, 7509 dID: base.DecisionID, 7510 transition: base.Transition 7511 ) -> TransitionProperties: 7512 """ 7513 Looks up transition properties for the transition with the given 7514 name outgoing from the decision with the given ID, in the latest 7515 step in which a transiiton with that name from that decision 7516 exists (which will usually be the final exploration step, unless 7517 transitions get removed/renamed along the way). Note that because 7518 a transition can be deleted and later added back (unlike 7519 decisions where an ID will not be re-used), it's possible there 7520 are two or more different transitions that meet the 7521 specifications at different points in time, and this will always 7522 return the properties of the last of them. This will raise a 7523 `MissingDecisionError` if there is no step at which the specified 7524 decision exists, and a `MissingTransitionError` if the target 7525 decision exists at some step but never has a transition with the 7526 specified name. 7527 """ 7528 sawDecision: Optional[int] = None 7529 for step in range(len(self) - 1, -1, -1): 7530 graph = self.getSituation(step).graph 7531 try: 7532 return graph.getTransitionProperties(dID, transition) 7533 except (MissingDecisionError, MissingTransitionError) as e: 7534 if ( 7535 sawDecision is None 7536 and isinstance(e, MissingTransitionError) 7537 ): 7538 sawDecision = step 7539 continue 7540 if sawDecision is None: 7541 raise MissingDecisionError( 7542 f"Decision {dID!r} does not exist at any step of the" 7543 f" exploration." 7544 ) 7545 else: 7546 raise MissingTransitionError( 7547 f"Decision {dID!r} does exist (last seen at step" 7548 f" {sawDecision}) but it never has an outgoing" 7549 f" transition named {transition!r}." 7550 ) 7551 7552 def tagStep( 7553 self, 7554 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 7555 tagValue: Union[ 7556 base.TagValue, 7557 type[base.NoTagValue] 7558 ] = base.NoTagValue, 7559 step: int = -1 7560 ) -> None: 7561 """ 7562 Adds a tag (or multiple tags) to the current step, or to a 7563 specific step if `n` is given as an integer rather than the 7564 default `None`. A tag value should be supplied when a tag is 7565 given (unless you want to use the default of `1`), but it's a 7566 `ValueError` to supply a tag value when a dictionary of tags to 7567 update is provided. 7568 """ 7569 if isinstance(tagOrTags, base.Tag): 7570 if tagValue is base.NoTagValue: 7571 tagValue = 1 7572 7573 # Not sure why this is necessary... 7574 tagValue = cast(base.TagValue, tagValue) 7575 7576 self.getSituation(step).tags.update({tagOrTags: tagValue}) 7577 else: 7578 self.getSituation(step).tags.update(tagOrTags) 7579 7580 def annotateStep( 7581 self, 7582 annotationOrAnnotations: Union[ 7583 base.Annotation, 7584 Sequence[base.Annotation] 7585 ], 7586 step: Optional[int] = None 7587 ) -> None: 7588 """ 7589 Adds an annotation to the current exploration step, or to a 7590 specific step if `n` is given as an integer rather than the 7591 default `None`. 7592 """ 7593 if step is None: 7594 step = -1 7595 if isinstance(annotationOrAnnotations, base.Annotation): 7596 self.getSituation(step).annotations.append( 7597 annotationOrAnnotations 7598 ) 7599 else: 7600 self.getSituation(step).annotations.extend( 7601 annotationOrAnnotations 7602 ) 7603 7604 def hasCapability( 7605 self, 7606 capability: base.Capability, 7607 step: Optional[int] = None, 7608 inCommon: Union[bool, Literal['both']] = "both" 7609 ) -> bool: 7610 """ 7611 Returns True if the player currently had the specified 7612 capability, at the specified exploration step, and False 7613 otherwise. Checks the current state if no step is given. Does 7614 NOT return true if the game state means that the player has an 7615 equivalent for that capability (see 7616 `hasCapabilityOrEquivalent`). 7617 7618 Normally, `inCommon` is set to 'both' by default and so if 7619 either the common `FocalContext` or the active one has the 7620 capability, this will return `True`. `inCommon` may instead be 7621 set to `True` or `False` to ask about just the common (or 7622 active) focal context. 7623 """ 7624 state = self.getSituation().state 7625 commonCapabilities = state['common']['capabilities']\ 7626 ['capabilities'] # noqa 7627 activeCapabilities = state['contexts'][state['activeContext']]\ 7628 ['capabilities']['capabilities'] # noqa 7629 7630 if inCommon == 'both': 7631 return ( 7632 capability in commonCapabilities 7633 or capability in activeCapabilities 7634 ) 7635 elif inCommon is True: 7636 return capability in commonCapabilities 7637 elif inCommon is False: 7638 return capability in activeCapabilities 7639 else: 7640 raise ValueError( 7641 f"Invalid inCommon value (must be False, True, or" 7642 f" 'both'; got {repr(inCommon)})." 7643 ) 7644 7645 def hasCapabilityOrEquivalent( 7646 self, 7647 capability: base.Capability, 7648 step: Optional[int] = None, 7649 location: Optional[Set[base.DecisionID]] = None 7650 ) -> bool: 7651 """ 7652 Works like `hasCapability`, but also returns `True` if the 7653 player counts as having the specified capability via an equivalence 7654 that's part of the current graph. As with `hasCapability`, the 7655 optional `step` argument is used to specify which step to check, 7656 with the current step being used as the default. 7657 7658 The `location` set can specify where to start looking for 7659 mechanisms; if left unspecified active decisions for that step 7660 will be used. 7661 """ 7662 if step is None: 7663 step = -1 7664 if location is None: 7665 location = self.getActiveDecisions(step) 7666 situation = self.getSituation(step) 7667 return base.hasCapabilityOrEquivalent( 7668 capability, 7669 base.RequirementContext( 7670 state=situation.state, 7671 graph=situation.graph, 7672 searchFrom=location 7673 ) 7674 ) 7675 7676 def gainCapabilityNow( 7677 self, 7678 capability: base.Capability, 7679 inCommon: bool = False 7680 ) -> None: 7681 """ 7682 Modifies the current game state to add the specified `Capability` 7683 to the player's capabilities. No changes are made to the current 7684 graph. 7685 7686 If `inCommon` is set to `True` (default is `False`) then the 7687 capability will be added to the common `FocalContext` and will 7688 therefore persist even when a focal context switch happens. 7689 Normally, it will be added to the currently-active focal 7690 context. 7691 """ 7692 state = self.getSituation().state 7693 if inCommon: 7694 context = state['common'] 7695 else: 7696 context = state['contexts'][state['activeContext']] 7697 context['capabilities']['capabilities'].add(capability) 7698 7699 def loseCapabilityNow( 7700 self, 7701 capability: base.Capability, 7702 inCommon: Union[bool, Literal['both']] = "both" 7703 ) -> None: 7704 """ 7705 Modifies the current game state to remove the specified `Capability` 7706 from the player's capabilities. Does nothing if the player 7707 doesn't already have that capability. 7708 7709 By default, this removes the capability from both the common 7710 capabilities set and the active `FocalContext`'s capabilities 7711 set, so that afterwards the player will definitely not have that 7712 capability. However, if you set `inCommon` to either `True` or 7713 `False`, it will remove the capability from just the common 7714 capabilities set (if `True`) or just the active capabilities set 7715 (if `False`). In these cases, removing the capability from just 7716 one capability set will not actually remove it in terms of the 7717 `hasCapability` result if it had been present in the other set. 7718 Set `inCommon` to "both" to use the default behavior explicitly. 7719 """ 7720 now = self.getSituation() 7721 if inCommon in ("both", True): 7722 context = now.state['common'] 7723 try: 7724 context['capabilities']['capabilities'].remove(capability) 7725 except KeyError: 7726 pass 7727 elif inCommon in ("both", False): 7728 context = now.state['contexts'][now.state['activeContext']] 7729 try: 7730 context['capabilities']['capabilities'].remove(capability) 7731 except KeyError: 7732 pass 7733 else: 7734 raise ValueError( 7735 f"Invalid inCommon value (must be False, True, or" 7736 f" 'both'; got {repr(inCommon)})." 7737 ) 7738 7739 def tokenCountNow(self, tokenType: base.Token) -> Optional[int]: 7740 """ 7741 Returns the number of tokens the player currently has of a given 7742 type. Returns `None` if the player has never acquired or lost 7743 tokens of that type. 7744 7745 This method adds together tokens from the common and active 7746 focal contexts. 7747 """ 7748 state = self.getSituation().state 7749 commonContext = state['common'] 7750 activeContext = state['contexts'][state['activeContext']] 7751 base = commonContext['capabilities']['tokens'].get(tokenType) 7752 if base is None: 7753 return activeContext['capabilities']['tokens'].get(tokenType) 7754 else: 7755 return base + activeContext['capabilities']['tokens'].get( 7756 tokenType, 7757 0 7758 ) 7759 7760 def adjustTokensNow( 7761 self, 7762 tokenType: base.Token, 7763 amount: int, 7764 inCommon: bool = False 7765 ) -> None: 7766 """ 7767 Modifies the current game state to add the specified number of 7768 `Token`s of the given type to the player's tokens. No changes are 7769 made to the current graph. Reduce the number of tokens by 7770 supplying a negative amount; note that negative token amounts 7771 are possible. 7772 7773 By default, the number of tokens for the current active 7774 `FocalContext` will be adjusted. However, if `inCommon` is set 7775 to `True`, then the number of tokens for the common context will 7776 be adjusted instead. 7777 """ 7778 # TODO: Custom token caps! 7779 state = self.getSituation().state 7780 if inCommon: 7781 context = state['common'] 7782 else: 7783 context = state['contexts'][state['activeContext']] 7784 tokens = context['capabilities']['tokens'] 7785 tokens[tokenType] = tokens.get(tokenType, 0) + amount 7786 7787 def setTokensNow( 7788 self, 7789 tokenType: base.Token, 7790 amount: int, 7791 inCommon: bool = False 7792 ) -> None: 7793 """ 7794 Modifies the current game state to set number of `Token`s of the 7795 given type to a specific amount, regardless of the old value. No 7796 changes are made to the current graph. 7797 7798 By default this sets the number of tokens for the active 7799 `FocalContext`. But if you set `inCommon` to `True`, it will 7800 set the number of tokens in the common context instead. 7801 """ 7802 # TODO: Custom token caps! 7803 state = self.getSituation().state 7804 if inCommon: 7805 context = state['common'] 7806 else: 7807 context = state['contexts'][state['activeContext']] 7808 context['capabilities']['tokens'][tokenType] = amount 7809 7810 def lookupMechanism( 7811 self, 7812 mechanism: base.MechanismName, 7813 step: Optional[int] = None, 7814 where: Union[ 7815 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]], 7816 Collection[base.AnyDecisionSpecifier], 7817 None 7818 ] = None 7819 ) -> base.MechanismID: 7820 """ 7821 Looks up a mechanism ID by name, in the graph for the specified 7822 step. The `where` argument specifies where to start looking, 7823 which helps disambiguate. It can be a tuple with a decision 7824 specifier and `None` to start from a single decision, or with a 7825 decision specifier and a transition name to start from either 7826 end of that transition. It can also be `None` to look at global 7827 mechanisms and then all decisions directly, although this 7828 increases the chance of a `MechanismCollisionError`. Finally, it 7829 can be some other non-tuple collection of decision specifiers to 7830 start from that set. 7831 7832 If no step is specified, uses the current step. 7833 """ 7834 if step is None: 7835 step = -1 7836 situation = self.getSituation(step) 7837 graph = situation.graph 7838 searchFrom: Collection[base.AnyDecisionSpecifier] 7839 if where is None: 7840 searchFrom = set() 7841 elif isinstance(where, tuple): 7842 if len(where) != 2: 7843 raise ValueError( 7844 f"Mechanism lookup location was a tuple with an" 7845 f" invalid length (must be length-2 if it's a" 7846 f" tuple):\n {repr(where)}" 7847 ) 7848 where = cast( 7849 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]], 7850 where 7851 ) 7852 if where[1] is None: 7853 searchFrom = {graph.resolveDecision(where[0])} 7854 else: 7855 searchFrom = graph.bothEnds(where[0], where[1]) 7856 else: # must be a collection of specifiers 7857 searchFrom = cast(Collection[base.AnyDecisionSpecifier], where) 7858 return graph.lookupMechanism(searchFrom, mechanism) 7859 7860 def mechanismState( 7861 self, 7862 mechanism: base.AnyMechanismSpecifier, 7863 where: Optional[Set[base.DecisionID]] = None, 7864 step: int = -1 7865 ) -> Optional[base.MechanismState]: 7866 """ 7867 Returns the current state for the specified mechanism (or the 7868 state at the specified step if a step index is given). `where` 7869 may be provided as a set of decision IDs to indicate where to 7870 search for the named mechanism, or a mechanism ID may be provided 7871 in the first place. Mechanism states are properties of a `State` 7872 but are not associated with focal contexts. 7873 """ 7874 situation = self.getSituation(step) 7875 mID = situation.graph.resolveMechanism(mechanism, startFrom=where) 7876 return situation.state['mechanisms'].get( 7877 mID, 7878 base.DEFAULT_MECHANISM_STATE 7879 ) 7880 7881 def setMechanismStateNow( 7882 self, 7883 mechanism: base.AnyMechanismSpecifier, 7884 toState: base.MechanismState, 7885 where: Optional[Set[base.DecisionID]] = None 7886 ) -> None: 7887 """ 7888 Sets the state of the specified mechanism to the specified 7889 state. Mechanisms can only be in one state at once, so this 7890 removes any previous states for that mechanism (note that via 7891 equivalences multiple mechanism states can count as active). 7892 7893 The mechanism can be any kind of mechanism specifier (see 7894 `base.AnyMechanismSpecifier`). If it's not a mechanism ID and 7895 doesn't have its own position information, the 'where' argument 7896 can be used to hint where to search for the mechanism. 7897 """ 7898 now = self.getSituation() 7899 mID = now.graph.resolveMechanism(mechanism, startFrom=where) 7900 if mID is None: 7901 raise MissingMechanismError( 7902 f"Couldn't find mechanism for {repr(mechanism)}." 7903 ) 7904 now.state['mechanisms'][mID] = toState 7905 7906 def skillLevel( 7907 self, 7908 skill: base.Skill, 7909 step: Optional[int] = None 7910 ) -> Optional[base.Level]: 7911 """ 7912 Returns the skill level the player had in a given skill at a 7913 given step, or for the current step if no step is specified. 7914 Returns `None` if the player had never acquired or lost levels 7915 in that skill before the specified step (skill level would count 7916 as 0 in that case). 7917 7918 This method adds together levels from the common and active 7919 focal contexts. 7920 """ 7921 if step is None: 7922 step = -1 7923 state = self.getSituation(step).state 7924 commonContext = state['common'] 7925 activeContext = state['contexts'][state['activeContext']] 7926 base = commonContext['capabilities']['skills'].get(skill) 7927 if base is None: 7928 return activeContext['capabilities']['skills'].get(skill) 7929 else: 7930 return base + activeContext['capabilities']['skills'].get( 7931 skill, 7932 0 7933 ) 7934 7935 def adjustSkillLevelNow( 7936 self, 7937 skill: base.Skill, 7938 levels: base.Level, 7939 inCommon: bool = False 7940 ) -> None: 7941 """ 7942 Modifies the current game state to add the specified number of 7943 `Level`s of the given skill. No changes are made to the current 7944 graph. Reduce the skill level by supplying negative levels; note 7945 that negative skill levels are possible. 7946 7947 By default, the skill level for the current active 7948 `FocalContext` will be adjusted. However, if `inCommon` is set 7949 to `True`, then the skill level for the common context will be 7950 adjusted instead. 7951 """ 7952 # TODO: Custom level caps? 7953 state = self.getSituation().state 7954 if inCommon: 7955 context = state['common'] 7956 else: 7957 context = state['contexts'][state['activeContext']] 7958 skills = context['capabilities']['skills'] 7959 skills[skill] = skills.get(skill, 0) + levels 7960 7961 def setSkillLevelNow( 7962 self, 7963 skill: base.Skill, 7964 level: base.Level, 7965 inCommon: bool = False 7966 ) -> None: 7967 """ 7968 Modifies the current game state to set `Skill` `Level` for the 7969 given skill, regardless of the old value. No changes are made to 7970 the current graph. 7971 7972 By default this sets the skill level for the active 7973 `FocalContext`. But if you set `inCommon` to `True`, it will set 7974 the skill level in the common context instead. 7975 """ 7976 # TODO: Custom level caps? 7977 state = self.getSituation().state 7978 if inCommon: 7979 context = state['common'] 7980 else: 7981 context = state['contexts'][state['activeContext']] 7982 skills = context['capabilities']['skills'] 7983 skills[skill] = level 7984 7985 def updateRequirementNow( 7986 self, 7987 decision: base.AnyDecisionSpecifier, 7988 transition: base.Transition, 7989 requirement: Optional[base.Requirement] 7990 ) -> None: 7991 """ 7992 Updates the requirement for a specific transition in a specific 7993 decision. Use `None` to remove the requirement for that edge. 7994 """ 7995 if requirement is None: 7996 requirement = base.ReqNothing() 7997 self.getSituation().graph.setTransitionRequirement( 7998 decision, 7999 transition, 8000 requirement 8001 ) 8002 8003 def isTraversable( 8004 self, 8005 decision: base.AnyDecisionSpecifier, 8006 transition: base.Transition, 8007 step: int = -1 8008 ) -> bool: 8009 """ 8010 Returns True if the specified transition from the specified 8011 decision had its requirement satisfied by the game state at the 8012 specified step (or at the current step if no step is specified). 8013 Raises an `IndexError` if the specified step doesn't exist, and 8014 a `KeyError` if the decision or transition specified does not 8015 exist in the `DecisionGraph` at that step. 8016 """ 8017 situation = self.getSituation(step) 8018 req = situation.graph.getTransitionRequirement(decision, transition) 8019 ctx = base.contextForTransition(situation, decision, transition) 8020 fromID = situation.graph.resolveDecision(decision) 8021 return ( 8022 req.satisfied(ctx) 8023 and (fromID, transition) not in situation.state['deactivated'] 8024 ) 8025 8026 def applyTransitionEffect( 8027 self, 8028 whichEffect: base.EffectSpecifier, 8029 moveWhich: Optional[base.FocalPointName] = None 8030 ) -> Optional[base.DecisionID]: 8031 """ 8032 Applies an effect attached to a transition, taking charges and 8033 delay into account based on the current `Situation`. 8034 Modifies the effect's trigger count (but may not actually 8035 trigger the effect if the charges and/or delay values indicate 8036 not to; see `base.doTriggerEffect`). 8037 8038 If a specific focal point in a plural-focalized domain is 8039 triggering the effect, the focal point name should be specified 8040 via `moveWhich` so that goto `Effect`s can know which focal 8041 point to move when it's not explicitly specified in the effect. 8042 TODO: Test this! 8043 8044 Returns None most of the time, but if a 'goto', 'bounce', or 8045 'follow' effect was applied, it returns the decision ID for that 8046 effect's destination, which would override a transition's normal 8047 destination. If it returns a destination ID, then the exploration 8048 state will already have been updated to set the position there, 8049 and further position updates are not needed. 8050 8051 Note that transition effects which update active decisions will 8052 also update the exploration status of those decisions to 8053 'exploring' if they had been in an unvisited status (see 8054 `updatePosition` and `hasBeenVisited`). 8055 8056 Note: callers should immediately update situation-based variables 8057 that might have been changes by a 'revert' effect. 8058 """ 8059 now = self.getSituation() 8060 effect, triggerCount = base.doTriggerEffect(now, whichEffect) 8061 if triggerCount is not None: 8062 return self.applyExtraneousEffect( 8063 effect, 8064 where=whichEffect[:2], 8065 moveWhich=moveWhich 8066 ) 8067 else: 8068 return None 8069 8070 def applyExtraneousEffect( 8071 self, 8072 effect: base.Effect, 8073 where: Optional[ 8074 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]] 8075 ] = None, 8076 moveWhich: Optional[base.FocalPointName] = None, 8077 challengePolicy: base.ChallengePolicy = "specified" 8078 ) -> Optional[base.DecisionID]: 8079 """ 8080 Applies a single extraneous effect to the state & graph, 8081 *without* accounting for charges or delay values, since the 8082 effect is not part of the graph (use `applyTransitionEffect` to 8083 apply effects that are attached to transitions, which is almost 8084 always the function you should be using). An associated 8085 transition for the extraneous effect can be supplied using the 8086 `where` argument, and effects like 'deactivate' and 'edit' will 8087 affect it (but the effect's charges and delay values will still 8088 be ignored). 8089 8090 If the effect would change the destination of a transition, the 8091 altered destination ID is returned: 'bounce' effects return the 8092 provided decision part of `where`, 'goto' effects return their 8093 target, and 'follow' effects return the destination followed to 8094 (possibly via chained follows in the extreme case). In all other 8095 cases, `None` is returned indicating no change to a normal 8096 destination. 8097 8098 If a specific focal point in a plural-focalized domain is 8099 triggering the effect, the focal point name should be specified 8100 via `moveWhich` so that goto `Effect`s can know which focal 8101 point to move when it's not explicitly specified in the effect. 8102 TODO: Test this! 8103 8104 Note that transition effects which update active decisions will 8105 also update the exploration status of those decisions to 8106 'exploring' if they had been in an unvisited status and will 8107 remove any 'unconfirmed' tag they might still have (see 8108 `updatePosition` and `hasBeenVisited`). 8109 8110 The given `challengePolicy` is applied when traversing further 8111 transitions due to 'follow' effects. 8112 8113 Note: Anyone calling `applyExtraneousEffect` should update any 8114 situation-based variables immediately after the call, as a 8115 'revert' effect may have changed the current graph and/or state. 8116 """ 8117 typ = effect['type'] 8118 value = effect['value'] 8119 applyTo = effect['applyTo'] 8120 inCommon = applyTo == 'common' 8121 8122 now = self.getSituation() 8123 8124 if where is not None: 8125 if where[1] is not None: 8126 searchFrom = now.graph.bothEnds(where[0], where[1]) 8127 else: 8128 searchFrom = {now.graph.resolveDecision(where[0])} 8129 else: 8130 searchFrom = None 8131 8132 # Note: Delay and charges are ignored! 8133 8134 if typ in ("gain", "lose"): 8135 value = cast( 8136 Union[ 8137 base.Capability, 8138 Tuple[base.Token, base.TokenCount], 8139 Tuple[Literal['skill'], base.Skill, base.Level], 8140 ], 8141 value 8142 ) 8143 if isinstance(value, base.Capability): 8144 if typ == "gain": 8145 self.gainCapabilityNow(value, inCommon) 8146 else: 8147 self.loseCapabilityNow(value, inCommon) 8148 elif len(value) == 2: # must be a token, amount pair 8149 token, amount = cast( 8150 Tuple[base.Token, base.TokenCount], 8151 value 8152 ) 8153 if typ == "lose": 8154 amount *= -1 8155 self.adjustTokensNow(token, amount, inCommon) 8156 else: # must be a 'skill', skill, level triple 8157 _, skill, levels = cast( 8158 Tuple[Literal['skill'], base.Skill, base.Level], 8159 value 8160 ) 8161 if typ == "lose": 8162 levels *= -1 8163 self.adjustSkillLevelNow(skill, levels, inCommon) 8164 8165 elif typ == "set": 8166 value = cast( 8167 Union[ 8168 Tuple[base.Token, base.TokenCount], 8169 Tuple[base.AnyMechanismSpecifier, base.MechanismState], 8170 Tuple[Literal['skill'], base.Skill, base.Level], 8171 ], 8172 value 8173 ) 8174 if len(value) == 2: # must be a token or mechanism pair 8175 if isinstance(value[1], base.TokenCount): # token 8176 token, amount = cast( 8177 Tuple[base.Token, base.TokenCount], 8178 value 8179 ) 8180 self.setTokensNow(token, amount, inCommon) 8181 else: # mechanism 8182 mechanism, state = cast( 8183 Tuple[ 8184 base.AnyMechanismSpecifier, 8185 base.MechanismState 8186 ], 8187 value 8188 ) 8189 self.setMechanismStateNow(mechanism, state, searchFrom) 8190 else: # must be a 'skill', skill, level triple 8191 _, skill, level = cast( 8192 Tuple[Literal['skill'], base.Skill, base.Level], 8193 value 8194 ) 8195 self.setSkillLevelNow(skill, level, inCommon) 8196 8197 elif typ == "toggle": 8198 # Length-1 list just toggles a capability on/off based on current 8199 # state (not attending to equivalents): 8200 if isinstance(value, List): # capabilities list 8201 value = cast(List[base.Capability], value) 8202 if len(value) == 0: 8203 raise ValueError( 8204 "Toggle effect has empty capabilities list." 8205 ) 8206 if len(value) == 1: 8207 capability = value[0] 8208 if self.hasCapability(capability, inCommon=False): 8209 self.loseCapabilityNow(capability, inCommon=False) 8210 else: 8211 self.gainCapabilityNow(capability) 8212 else: 8213 # Otherwise toggle all powers off, then one on, 8214 # based on the first capability that's currently on. 8215 # Note we do NOT count equivalences. 8216 8217 # Find first capability that's on: 8218 firstIndex: Optional[int] = None 8219 for i, capability in enumerate(value): 8220 if self.hasCapability(capability): 8221 firstIndex = i 8222 break 8223 8224 # Turn them all off: 8225 for capability in value: 8226 self.loseCapabilityNow(capability, inCommon=False) 8227 # TODO: inCommon for the check? 8228 8229 if firstIndex is None: 8230 self.gainCapabilityNow(value[0]) 8231 else: 8232 self.gainCapabilityNow( 8233 value[(firstIndex + 1) % len(value)] 8234 ) 8235 else: # must be a mechanism w/ states list 8236 mechanism, states = cast( 8237 Tuple[ 8238 base.AnyMechanismSpecifier, 8239 List[base.MechanismState] 8240 ], 8241 value 8242 ) 8243 currentState = self.mechanismState(mechanism, where=searchFrom) 8244 if len(states) == 1: 8245 if currentState == states[0]: 8246 # default alternate state 8247 self.setMechanismStateNow( 8248 mechanism, 8249 base.DEFAULT_MECHANISM_STATE, 8250 searchFrom 8251 ) 8252 else: 8253 self.setMechanismStateNow( 8254 mechanism, 8255 states[0], 8256 searchFrom 8257 ) 8258 else: 8259 # Find our position in the list, if any 8260 try: 8261 currentIndex = states.index(cast(str, currentState)) 8262 # Cast here just because we know that None will 8263 # raise a ValueError but we'll catch it, and we 8264 # want to suppress the mypy warning about the 8265 # option 8266 except ValueError: 8267 currentIndex = len(states) - 1 8268 # Set next state in list as current state 8269 nextIndex = (currentIndex + 1) % len(states) 8270 self.setMechanismStateNow( 8271 mechanism, 8272 states[nextIndex], 8273 searchFrom 8274 ) 8275 8276 elif typ == "deactivate": 8277 if where is None or where[1] is None: 8278 raise ValueError( 8279 "Can't apply a deactivate effect without specifying" 8280 " which transition it applies to." 8281 ) 8282 8283 decision, transition = cast( 8284 Tuple[base.AnyDecisionSpecifier, base.Transition], 8285 where 8286 ) 8287 8288 dID = now.graph.resolveDecision(decision) 8289 now.state['deactivated'].add((dID, transition)) 8290 8291 elif typ == "edit": 8292 value = cast(List[List[commands.Command]], value) 8293 # If there are no blocks, do nothing 8294 if len(value) > 0: 8295 # Apply the first block of commands and then rotate the list 8296 scope: commands.Scope = {} 8297 if where is not None: 8298 here: base.DecisionID = now.graph.resolveDecision( 8299 where[0] 8300 ) 8301 outwards: Optional[base.Transition] = where[1] 8302 scope['@'] = here 8303 scope['@t'] = outwards 8304 if outwards is not None: 8305 reciprocal = now.graph.getReciprocal(here, outwards) 8306 destination = now.graph.getDestination(here, outwards) 8307 else: 8308 reciprocal = None 8309 destination = None 8310 scope['@r'] = reciprocal 8311 scope['@d'] = destination 8312 self.runCommandBlock(value[0], scope) 8313 value.append(value.pop(0)) 8314 8315 elif typ == "goto": 8316 if isinstance(value, base.DecisionSpecifier): 8317 target: base.AnyDecisionSpecifier = value 8318 # use moveWhich provided as argument 8319 elif isinstance(value, tuple): 8320 target, moveWhich = cast( 8321 Tuple[base.AnyDecisionSpecifier, base.FocalPointName], 8322 value 8323 ) 8324 else: 8325 target = cast(base.AnyDecisionSpecifier, value) 8326 # use moveWhich provided as argument 8327 8328 destID = now.graph.resolveDecision(target) 8329 base.updatePosition(now, destID, applyTo, moveWhich) 8330 return destID 8331 8332 elif typ == "bounce": 8333 # Just need to let the caller know they should cancel 8334 if where is None: 8335 raise ValueError( 8336 "Can't apply a 'bounce' effect without a position" 8337 " to apply it from." 8338 ) 8339 return now.graph.resolveDecision(where[0]) 8340 8341 elif typ == "follow": 8342 if where is None: 8343 raise ValueError( 8344 f"Can't follow transition {value!r} because there" 8345 f" is no position information when applying the" 8346 f" effect." 8347 ) 8348 if where[1] is not None: 8349 followFrom = now.graph.getDestination(where[0], where[1]) 8350 if followFrom is None: 8351 raise ValueError( 8352 f"Can't follow transition {value!r} because the" 8353 f" position information specifies transition" 8354 f" {where[1]!r} from decision" 8355 f" {now.graph.identityOf(where[0])} but that" 8356 f" transition does not exist." 8357 ) 8358 else: 8359 followFrom = now.graph.resolveDecision(where[0]) 8360 8361 following = cast(base.Transition, value) 8362 8363 followTo = now.graph.getDestination(followFrom, following) 8364 8365 if followTo is None: 8366 raise ValueError( 8367 f"Can't follow transition {following!r} because" 8368 f" that transition doesn't exist at the specified" 8369 f" destination {now.graph.identityOf(followFrom)}." 8370 ) 8371 8372 if self.isTraversable(followFrom, following): # skip if not 8373 # Perform initial position update before following new 8374 # transition: 8375 base.updatePosition( 8376 now, 8377 followFrom, 8378 applyTo, 8379 moveWhich 8380 ) 8381 8382 # Apply consequences of followed transition 8383 fullFollowTo = self.applyTransitionConsequence( 8384 followFrom, 8385 following, 8386 moveWhich, 8387 challengePolicy 8388 ) 8389 8390 # Now update to end of followed transition 8391 if fullFollowTo is None: 8392 base.updatePosition( 8393 now, 8394 followTo, 8395 applyTo, 8396 moveWhich 8397 ) 8398 fullFollowTo = followTo 8399 8400 # Skip the normal update: we've taken care of that plus more 8401 return fullFollowTo 8402 else: 8403 # Normal position updates still applies since follow 8404 # transition wasn't possible 8405 return None 8406 8407 elif typ == "save": 8408 assert isinstance(value, base.SaveSlot) 8409 now.saves[value] = copy.deepcopy((now.graph, now.state)) 8410 8411 else: 8412 raise ValueError(f"Invalid effect type {typ!r}.") 8413 8414 return None # default return value if we didn't return above 8415 8416 def applyExtraneousConsequence( 8417 self, 8418 consequence: base.Consequence, 8419 where: Optional[ 8420 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]] 8421 ] = None, 8422 moveWhich: Optional[base.FocalPointName] = None 8423 ) -> Optional[base.DecisionID]: 8424 """ 8425 Applies an extraneous consequence not associated with a 8426 transition. Unlike `applyTransitionConsequence`, the provided 8427 `base.Consequence` must already have observed outcomes (see 8428 `base.observeChallengeOutcomes`). Returns the decision ID for a 8429 decision implied by a goto, follow, or bounce effect, or `None` 8430 if no effect implies a destination. 8431 8432 The `where` and `moveWhich` optional arguments specify which 8433 decision and/or transition to use as the application position, 8434 and/or which focal point to move. This affects mechanism lookup 8435 as well as the end position when 'follow' effects are used. 8436 Specifically: 8437 8438 - A 'follow' trigger will search for transitions to follow from 8439 the destination of the specified transition, or if only a 8440 decision was supplied, from that decision. 8441 - Mechanism lookups will start with both ends of the specified 8442 transition as their search field (or with just the specified 8443 decision if no transition is included). 8444 8445 'bounce' effects will cause an error unless position information 8446 is provided, and will set the position to the base decision 8447 provided in `where`. 8448 8449 Note: callers should update any situation-based variables 8450 immediately after calling this as a 'revert' effect could change 8451 the current graph and/or state and other changes could get lost 8452 if they get applied to a stale graph/state. 8453 8454 # TODO: Examples for goto and follow effects. 8455 """ 8456 now = self.getSituation() 8457 searchFrom = set() 8458 if where is not None: 8459 if where[1] is not None: 8460 searchFrom = now.graph.bothEnds(where[0], where[1]) 8461 else: 8462 searchFrom = {now.graph.resolveDecision(where[0])} 8463 8464 context = base.RequirementContext( 8465 state=now.state, 8466 graph=now.graph, 8467 searchFrom=searchFrom 8468 ) 8469 8470 effectIndices = base.observedEffects(context, consequence) 8471 destID = None 8472 for index in effectIndices: 8473 effect = base.consequencePart(consequence, index) 8474 if not isinstance(effect, dict) or 'value' not in effect: 8475 raise RuntimeError( 8476 f"Invalid effect index {index}: Consequence part at" 8477 f" that index is not an Effect. Got:\n{effect}" 8478 ) 8479 effect = cast(base.Effect, effect) 8480 destID = self.applyExtraneousEffect( 8481 effect, 8482 where, 8483 moveWhich 8484 ) 8485 # technically this variable is not used later in this 8486 # function, but the `applyExtraneousEffect` call means it 8487 # needs an update, so we're doing that in case someone later 8488 # adds code to this function that uses 'now' after this 8489 # point. 8490 now = self.getSituation() 8491 8492 return destID 8493 8494 def applyTransitionConsequence( 8495 self, 8496 decision: base.AnyDecisionSpecifier, 8497 transition: base.AnyTransition, 8498 moveWhich: Optional[base.FocalPointName] = None, 8499 policy: base.ChallengePolicy = "specified", 8500 fromIndex: Optional[int] = None, 8501 toIndex: Optional[int] = None 8502 ) -> Optional[base.DecisionID]: 8503 """ 8504 Applies the effects of the specified transition to the current 8505 graph and state, possibly overriding observed outcomes using 8506 outcomes specified as part of a `base.TransitionWithOutcomes`. 8507 8508 The `where` and `moveWhich` function serve the same purpose as 8509 for `applyExtraneousEffect`. If `where` is `None`, then the 8510 effects will be applied as extraneous effects, meaning that 8511 their delay and charges values will be ignored and their trigger 8512 count will not be tracked. If `where` is supplied 8513 8514 Returns either None to indicate that the position update for the 8515 transition should apply as usual, or a decision ID indicating 8516 another destination which has already been applied by a 8517 transition effect. 8518 8519 If `fromIndex` and/or `toIndex` are specified, then only effects 8520 which have indices between those two (inclusive) will be 8521 applied, and other effects will neither apply nor be updated in 8522 any way. Note that `onlyPart` does not override the challenge 8523 policy: if the effects in the specified part are not applied due 8524 to a challenge outcome, they still won't happen, including 8525 challenge outcomes outside of that part. Also, outcomes for 8526 challenges of the entire consequence are re-observed if the 8527 challenge policy implies it. 8528 8529 Note: Anyone calling this should update any situation-based 8530 variables immediately after the call, as a 'revert' effect may 8531 have changed the current graph and/or state. 8532 """ 8533 now = self.getSituation() 8534 dID = now.graph.resolveDecision(decision) 8535 8536 transitionName, outcomes = base.nameAndOutcomes(transition) 8537 8538 searchFrom = set() 8539 searchFrom = now.graph.bothEnds(dID, transitionName) 8540 8541 context = base.RequirementContext( 8542 state=now.state, 8543 graph=now.graph, 8544 searchFrom=searchFrom 8545 ) 8546 8547 consequence = now.graph.getConsequence(dID, transitionName) 8548 8549 # Make sure that challenge outcomes are known 8550 if policy != "specified": 8551 base.resetChallengeOutcomes(consequence) 8552 useUp = outcomes[:] 8553 base.observeChallengeOutcomes( 8554 context, 8555 consequence, 8556 location=searchFrom, 8557 policy=policy, 8558 knownOutcomes=useUp 8559 ) 8560 if len(useUp) > 0: 8561 raise ValueError( 8562 f"More outcomes specified than challenges observed in" 8563 f" consequence:\n{consequence}" 8564 f"\nRemaining outcomes:\n{useUp}" 8565 ) 8566 8567 # Figure out which effects apply, and apply each of them 8568 effectIndices = base.observedEffects(context, consequence) 8569 if fromIndex is None: 8570 fromIndex = 0 8571 8572 altDest = None 8573 for index in effectIndices: 8574 if ( 8575 index >= fromIndex 8576 and (toIndex is None or index <= toIndex) 8577 ): 8578 thisDest = self.applyTransitionEffect( 8579 (dID, transitionName, index), 8580 moveWhich 8581 ) 8582 if thisDest is not None: 8583 altDest = thisDest 8584 # TODO: What if this updates state with 'revert' to a 8585 # graph that doesn't contain the same effects? 8586 # TODO: Update 'now' and 'context'?! 8587 return altDest 8588 8589 def allDecisions(self) -> List[base.DecisionID]: 8590 """ 8591 Returns the list of all decisions which existed at any point 8592 within the exploration. Example: 8593 8594 >>> ex = DiscreteExploration() 8595 >>> ex.start('A') 8596 0 8597 >>> ex.observe('A', 'right') 8598 1 8599 >>> ex.explore('right', 'B', 'left') 8600 1 8601 >>> ex.observe('B', 'right') 8602 2 8603 >>> ex.allDecisions() # 'A', 'B', and the unnamed 'right of B' 8604 [0, 1, 2] 8605 """ 8606 seen = set() 8607 result = [] 8608 for situation in self: 8609 for decision in situation.graph: 8610 if decision not in seen: 8611 result.append(decision) 8612 seen.add(decision) 8613 8614 return result 8615 8616 def allExploredDecisions(self) -> List[base.DecisionID]: 8617 """ 8618 Returns the list of all decisions which existed at any point 8619 within the exploration, excluding decisions whose highest 8620 exploration status was `noticed` or lower. May still include 8621 decisions which don't exist in the final situation's graph due to 8622 things like decision merging. Example: 8623 8624 >>> ex = DiscreteExploration() 8625 >>> ex.start('A') 8626 0 8627 >>> ex.observe('A', 'right') 8628 1 8629 >>> ex.explore('right', 'B', 'left') 8630 1 8631 >>> ex.observe('B', 'right') 8632 2 8633 >>> graph = ex.getSituation().graph 8634 >>> graph.addDecision('C') # add isolated decision; doesn't set status 8635 3 8636 >>> ex.hasBeenVisited('C') 8637 False 8638 >>> ex.allExploredDecisions() 8639 [0, 1] 8640 >>> ex.setExplorationStatus('C', 'exploring') 8641 >>> ex.allExploredDecisions() # 2 is the decision right from 'B' 8642 [0, 1, 3] 8643 >>> ex.setExplorationStatus('A', 'explored') 8644 >>> ex.allExploredDecisions() 8645 [0, 1, 3] 8646 >>> ex.setExplorationStatus('A', 'unknown') 8647 >>> # remains visisted in an earlier step 8648 >>> ex.allExploredDecisions() 8649 [0, 1, 3] 8650 >>> ex.setExplorationStatus('C', 'unknown') # not explored earlier 8651 >>> ex.allExploredDecisions() # 2 is the decision right from 'B' 8652 [0, 1] 8653 """ 8654 seen = set() 8655 result = [] 8656 for situation in self: 8657 graph = situation.graph 8658 for decision in graph: 8659 if ( 8660 decision not in seen 8661 and base.hasBeenVisited(situation, decision) 8662 ): 8663 result.append(decision) 8664 seen.add(decision) 8665 8666 return result 8667 8668 def allVisitedDecisions(self) -> List[base.DecisionID]: 8669 """ 8670 Returns the list of all decisions which existed at any point 8671 within the exploration and which were visited at least once. 8672 Orders them in the same order they were visited in. 8673 8674 Usually all of these decisions will be present in the final 8675 situation's graph, but sometimes merging or other factors means 8676 there might be some that won't be. Being present on the game 8677 state's 'active' list in a step for its domain is what counts as 8678 "being visited," which means that nodes which were passed through 8679 directly via a 'follow' effect won't be counted, for example. 8680 8681 This should usually correspond with the absence of the 8682 'unconfirmed' tag. 8683 8684 Example: 8685 8686 >>> ex = DiscreteExploration() 8687 >>> ex.start('A') 8688 0 8689 >>> ex.observe('A', 'right') 8690 1 8691 >>> ex.explore('right', 'B', 'left') 8692 1 8693 >>> ex.observe('B', 'right') 8694 2 8695 >>> ex.getSituation().graph.addDecision('C') # add isolated decision 8696 3 8697 >>> av = ex.allVisitedDecisions() 8698 >>> av 8699 [0, 1] 8700 >>> all( # no decisions in the 'visited' list are tagged 8701 ... 'unconfirmed' not in ex.getSituation().graph.decisionTags(d) 8702 ... for d in av 8703 ... ) 8704 True 8705 >>> graph = ex.getSituation().graph 8706 >>> 'unconfirmed' in graph.decisionTags(0) 8707 False 8708 >>> 'unconfirmed' in graph.decisionTags(1) 8709 False 8710 >>> 'unconfirmed' in graph.decisionTags(2) 8711 True 8712 >>> 'unconfirmed' in graph.decisionTags(3) # not tagged; not explored 8713 False 8714 """ 8715 seen = set() 8716 result = [] 8717 for step in range(len(self)): 8718 active = self.getActiveDecisions(step) 8719 for dID in active: 8720 if dID not in seen: 8721 result.append(dID) 8722 seen.add(dID) 8723 8724 return result 8725 8726 def allTransitions(self) -> List[ 8727 Tuple[base.DecisionID, base.Transition, base.DecisionID] 8728 ]: 8729 """ 8730 Returns the list of all transitions which existed at any point 8731 within the exploration, as 3-tuples with source decision ID, 8732 transition name, and destination decision ID. Note that since 8733 transitions can be deleted or re-targeted, and a transition name 8734 can be re-used after being deleted, things can get messy in the 8735 edges cases. When the same transition name is used in different 8736 steps with different decision targets, we end up including each 8737 possible source-transition-destination triple. Example: 8738 8739 >>> ex = DiscreteExploration() 8740 >>> ex.start('A') 8741 0 8742 >>> ex.observe('A', 'right') 8743 1 8744 >>> ex.explore('right', 'B', 'left') 8745 1 8746 >>> ex.observe('B', 'right') 8747 2 8748 >>> ex.wait() # leave behind a step where 'B' has a 'right' 8749 >>> ex.primaryDecision(0) 8750 >>> ex.primaryDecision(1) 8751 0 8752 >>> ex.primaryDecision(2) 8753 1 8754 >>> ex.primaryDecision(3) 8755 1 8756 >>> len(ex) 8757 4 8758 >>> ex[3].graph.removeDecision(2) # delete 'right of B' 8759 >>> ex.observe('B', 'down') 8760 3 8761 >>> # Decisions are: 'A', 'B', and the unnamed 'right of B' 8762 >>> # (now-deleted), and the unnamed 'down from B' 8763 >>> ex.allDecisions() 8764 [0, 1, 2, 3] 8765 >>> for tr in ex.allTransitions(): 8766 ... print(tr) 8767 ... 8768 (0, 'right', 1) 8769 (1, 'return', 0) 8770 (1, 'left', 0) 8771 (1, 'right', 2) 8772 (2, 'return', 1) 8773 (1, 'down', 3) 8774 (3, 'return', 1) 8775 >>> # Note transitions from now-deleted nodes, and 'return' 8776 >>> # transitions for unexplored nodes before they get explored 8777 """ 8778 seen = set() 8779 result = [] 8780 for situation in self: 8781 graph = situation.graph 8782 for (src, dst, transition) in graph.allEdges(): # type:ignore 8783 trans = (src, transition, dst) 8784 if trans not in seen: 8785 result.append(trans) 8786 seen.add(trans) 8787 8788 return result 8789 8790 def start( 8791 self, 8792 decision: base.AnyDecisionSpecifier, 8793 startCapabilities: Optional[base.CapabilitySet] = None, 8794 setMechanismStates: Optional[ 8795 Dict[base.MechanismID, base.MechanismState] 8796 ] = None, 8797 setCustomState: Optional[dict] = None, 8798 decisionType: base.DecisionType = "imposed" 8799 ) -> base.DecisionID: 8800 """ 8801 Sets the initial position information for a newly-relevant 8802 domain for the current focal context. Creates a new decision 8803 if the decision is specified by name or `DecisionSpecifier` and 8804 that decision doesn't already exist. Returns the decision ID for 8805 the newly-placed decision (or for the specified decision if it 8806 already existed). 8807 8808 Raises a `BadStart` error if the current focal context already 8809 has position information for the specified domain. 8810 8811 - The given `startCapabilities` replaces any existing 8812 capabilities for the current focal context, although you can 8813 leave it as the default `None` to avoid that and retain any 8814 capabilities that have been set up already. 8815 - The given `setMechanismStates` and `setCustomState` 8816 dictionaries override all previous mechanism states & custom 8817 states in the new situation. Leave these as the default 8818 `None` to maintain those states. 8819 - If created, the decision will be placed in the DEFAULT_DOMAIN 8820 domain unless it's specified as a `base.DecisionSpecifier` 8821 with a domain part, in which case that domain is used. 8822 - If specified as a `base.DecisionSpecifier` with a zone part 8823 and a new decision needs to be created, the decision will be 8824 added to that zone, creating it at level 0 if necessary, 8825 although otherwise no zone information will be changed. 8826 - Resets the decision type to "pending" and the action taken to 8827 `None`. Sets the decision type of the previous situation to 8828 'imposed' (or the specified `decisionType`) and sets an 8829 appropriate 'start' action for that situation. 8830 - Tags the step with 'start'. 8831 - Even in a plural- or spreading-focalized domain, you still need 8832 to pick one decision to start at. 8833 """ 8834 now = self.getSituation() 8835 8836 startID = now.graph.getDecision(decision) 8837 zone = None 8838 domain = base.DEFAULT_DOMAIN 8839 if startID is None: 8840 if isinstance(decision, base.DecisionID): 8841 raise MissingDecisionError( 8842 f"Cannot start at decision {decision} because no" 8843 f" decision with that ID exists. Supply a name or" 8844 f" DecisionSpecifier if you need the start decision" 8845 f" to be created automatically." 8846 ) 8847 elif isinstance(decision, base.DecisionName): 8848 decision = base.DecisionSpecifier( 8849 domain=None, 8850 zone=None, 8851 name=decision 8852 ) 8853 startID = now.graph.addDecision( 8854 decision.name, 8855 domain=decision.domain 8856 ) 8857 zone = decision.zone 8858 if decision.domain is not None: 8859 domain = decision.domain 8860 8861 if zone is not None: 8862 if now.graph.getZoneInfo(zone) is None: 8863 now.graph.createZone(zone, 0) 8864 now.graph.addDecisionToZone(startID, zone) 8865 8866 action: base.ExplorationAction = ( 8867 'start', 8868 startID, 8869 startID, 8870 domain, 8871 startCapabilities, 8872 setMechanismStates, 8873 setCustomState 8874 ) 8875 8876 self.advanceSituation(action, decisionType) 8877 8878 return startID 8879 8880 def hasBeenVisited( 8881 self, 8882 decision: base.AnyDecisionSpecifier, 8883 step: int = -1 8884 ): 8885 """ 8886 Returns whether or not the specified decision has been visited in 8887 the specified step (default current step). 8888 """ 8889 return base.hasBeenVisited(self.getSituation(step), decision) 8890 8891 def setExplorationStatus( 8892 self, 8893 decision: base.AnyDecisionSpecifier, 8894 status: base.ExplorationStatus, 8895 upgradeOnly: bool = False 8896 ): 8897 """ 8898 Updates the current exploration status of a specific decision in 8899 the current situation. If `upgradeOnly` is true (default is 8900 `False` then the update will only apply if the new exploration 8901 status counts as 'more-explored' than the old one (see 8902 `base.moreExplored`). 8903 """ 8904 base.setExplorationStatus( 8905 self.getSituation(), 8906 decision, 8907 status, 8908 upgradeOnly 8909 ) 8910 8911 def getExplorationStatus( 8912 self, 8913 decision: base.AnyDecisionSpecifier, 8914 step: int = -1 8915 ): 8916 """ 8917 Returns the exploration status of the specified decision at the 8918 specified step (default is last step). Decisions whose 8919 exploration status has never been set will have a default status 8920 of 'unknown'. 8921 """ 8922 situation = self.getSituation(step) 8923 dID = situation.graph.resolveDecision(decision) 8924 return situation.state['exploration'].get(dID, 'unknown') 8925 8926 def deduceTransitionDetailsAtStep( 8927 self, 8928 step: int, 8929 transition: base.Transition, 8930 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 8931 whichFocus: Optional[base.FocalPointSpecifier] = None, 8932 inCommon: Union[bool, Literal["auto"]] = "auto" 8933 ) -> Tuple[ 8934 base.ContextSpecifier, 8935 base.DecisionID, 8936 base.DecisionID, 8937 Optional[base.FocalPointSpecifier] 8938 ]: 8939 """ 8940 Given just a transition name which the player intends to take in 8941 a specific step, deduces the `ContextSpecifier` for which 8942 context should be updated, the source and destination 8943 `DecisionID`s for the transition, and if the destination 8944 decision's domain is plural-focalized, the `FocalPointName` 8945 specifying which focal point should be moved. 8946 8947 Because many of those things are ambiguous, you may get an 8948 `AmbiguousTransitionError` when things are underspecified, and 8949 there are options for specifying some of the extra information 8950 directly: 8951 8952 - `fromDecision` may be used to specify the source decision. 8953 - `whichFocus` may be used to specify the focal point (within a 8954 particular context/domain) being updated. When focal point 8955 ambiguity remains and this is unspecified, the 8956 alphabetically-earliest relevant focal point will be used 8957 (either among all focal points which activate the source 8958 decision, if there are any, or among all focal points for 8959 the entire domain of the destination decision). 8960 - `inCommon` (a `ContextSpecifier`) may be used to specify which 8961 context to update. The default of "auto" will cause the 8962 active context to be selected unless it does not activate 8963 the source decision, in which case the common context will 8964 be selected. 8965 8966 A `MissingDecisionError` will be raised if there are no current 8967 active decisions (e.g., before `start` has been called), and a 8968 `MissingTransitionError` will be raised if the listed transition 8969 does not exist from any active decision (or from the specified 8970 decision if `fromDecision` is used). 8971 """ 8972 now = self.getSituation(step) 8973 active = self.getActiveDecisions(step) 8974 if len(active) == 0: 8975 raise MissingDecisionError( 8976 f"There are no active decisions from which transition" 8977 f" {repr(transition)} could be taken at step {step}." 8978 ) 8979 8980 # All source/destination decision pairs for transitions with the 8981 # given transition name. 8982 allDecisionPairs: Dict[base.DecisionID, base.DecisionID] = {} 8983 8984 # TODO: When should we be trimming the active decisions to match 8985 # any alterations to the graph? 8986 for dID in active: 8987 outgoing = now.graph.destinationsFrom(dID) 8988 if transition in outgoing: 8989 allDecisionPairs[dID] = outgoing[transition] 8990 8991 if len(allDecisionPairs) == 0: 8992 raise MissingTransitionError( 8993 f"No transitions named {repr(transition)} are outgoing" 8994 f" from active decisions at step {step}." 8995 f"\nActive decisions are:" 8996 f"\n{now.graph.namesListing(active)}" 8997 ) 8998 8999 if ( 9000 fromDecision is not None 9001 and fromDecision not in allDecisionPairs 9002 ): 9003 raise MissingTransitionError( 9004 f"{fromDecision} was specified as the source decision" 9005 f" for traversing transition {repr(transition)} but" 9006 f" there is no transition of that name from that" 9007 f" decision at step {step}." 9008 f"\nValid source decisions are:" 9009 f"\n{now.graph.namesListing(allDecisionPairs)}" 9010 ) 9011 elif fromDecision is not None: 9012 fromID = now.graph.resolveDecision(fromDecision) 9013 destID = allDecisionPairs[fromID] 9014 fromDomain = now.graph.domainFor(fromID) 9015 elif len(allDecisionPairs) == 1: 9016 fromID, destID = list(allDecisionPairs.items())[0] 9017 fromDomain = now.graph.domainFor(fromID) 9018 else: 9019 fromID = None 9020 destID = None 9021 fromDomain = None 9022 # Still ambiguous; resolve this below 9023 9024 # Use whichFocus if provided 9025 if whichFocus is not None: 9026 # Type/value check for whichFocus 9027 if ( 9028 not isinstance(whichFocus, tuple) 9029 or len(whichFocus) != 3 9030 or whichFocus[0] not in ("active", "common") 9031 or not isinstance(whichFocus[1], base.Domain) 9032 or not isinstance(whichFocus[2], base.FocalPointName) 9033 ): 9034 raise ValueError( 9035 f"Invalid whichFocus value {repr(whichFocus)}." 9036 f"\nMust be a length-3 tuple with 'active' or 'common'" 9037 f" as the first element, a Domain as the second" 9038 f" element, and a FocalPointName as the third" 9039 f" element." 9040 ) 9041 9042 # Resolve focal point specified 9043 fromID = base.resolvePosition( 9044 now, 9045 whichFocus 9046 ) 9047 if fromID is None: 9048 raise MissingTransitionError( 9049 f"Focal point {repr(whichFocus)} was specified as" 9050 f" the transition source, but that focal point does" 9051 f" not have a position." 9052 ) 9053 else: 9054 destID = now.graph.destination(fromID, transition) 9055 fromDomain = now.graph.domainFor(fromID) 9056 9057 elif fromID is None: # whichFocus is None, so it can't disambiguate 9058 raise AmbiguousTransitionError( 9059 f"Transition {repr(transition)} was selected for" 9060 f" disambiguation, but there are multiple transitions" 9061 f" with that name from currently-active decisions, and" 9062 f" neither fromDecision nor whichFocus adequately" 9063 f" disambiguates the specific transition taken." 9064 f"\nValid source decisions at step {step} are:" 9065 f"\n{now.graph.namesListing(allDecisionPairs)}" 9066 ) 9067 9068 # At this point, fromID, destID, and fromDomain have 9069 # been resolved. 9070 if fromID is None or destID is None or fromDomain is None: 9071 raise RuntimeError( 9072 f"One of fromID, destID, or fromDomain was None after" 9073 f" disambiguation was finished:" 9074 f"\nfromID: {fromID}, destID: {destID}, fromDomain:" 9075 f" {repr(fromDomain)}" 9076 ) 9077 9078 # Now figure out which context activated the source so we know 9079 # which focal point we're moving: 9080 context = self.getActiveContext() 9081 active = base.activeDecisionSet(context) 9082 using: base.ContextSpecifier = "active" 9083 if fromID not in active: 9084 context = self.getCommonContext(step) 9085 using = "common" 9086 9087 destDomain = now.graph.domainFor(destID) 9088 if ( 9089 whichFocus is None 9090 and base.getDomainFocalization(context, destDomain) == 'plural' 9091 ): 9092 # Need to figure out which focal point is moving; use the 9093 # alphabetically earliest one that's positioned at the 9094 # fromID, or just the earliest one overall if none of them 9095 # are there. 9096 contextFocalPoints: Dict[ 9097 base.FocalPointName, 9098 Optional[base.DecisionID] 9099 ] = cast( 9100 Dict[base.FocalPointName, Optional[base.DecisionID]], 9101 context['activeDecisions'][destDomain] 9102 ) 9103 if not isinstance(contextFocalPoints, dict): 9104 raise RuntimeError( 9105 f"Active decisions specifier for domain" 9106 f" {repr(destDomain)} with plural focalization has" 9107 f" a non-dictionary value." 9108 ) 9109 9110 if fromDomain == destDomain: 9111 focalCandidates = [ 9112 fp 9113 for fp, pos in contextFocalPoints.items() 9114 if pos == fromID 9115 ] 9116 else: 9117 focalCandidates = list(contextFocalPoints) 9118 9119 whichFocus = (using, destDomain, min(focalCandidates)) 9120 9121 # Now whichFocus has been set if it wasn't already specified; 9122 # might still be None if it's not relevant. 9123 return (using, fromID, destID, whichFocus) 9124 9125 def advanceSituation( 9126 self, 9127 action: base.ExplorationAction, 9128 decisionType: base.DecisionType = "active", 9129 challengePolicy: base.ChallengePolicy = "specified" 9130 ) -> Tuple[base.Situation, Set[base.DecisionID]]: 9131 """ 9132 Given an `ExplorationAction`, sets that as the action taken in 9133 the current situation, and adds a new situation with the results 9134 of that action. A `DoubleActionError` will be raised if the 9135 current situation already has an action specified, and/or has a 9136 decision type other than 'pending'. By default the type of the 9137 decision will be 'active' but another `DecisionType` can be 9138 specified via the `decisionType` parameter. 9139 9140 If the action specified is `('noAction',)`, then the new 9141 situation will be a copy of the old one; this represents waiting 9142 or being at an ending (a decision type other than 'pending' 9143 should be used). 9144 9145 Although `None` can appear as the action entry in situations 9146 with pending decisions, you cannot call `advanceSituation` with 9147 `None` as the action. 9148 9149 If the action includes taking a transition whose requirements 9150 are not satisfied, the transition will still be taken (and any 9151 consequences applied) but a `TransitionBlockedWarning` will be 9152 issued. 9153 9154 A `ChallengePolicy` may be specified, the default is 'specified' 9155 which requires that outcomes are pre-specified. If any other 9156 policy is set, the challenge outcomes will be reset before 9157 re-resolving them according to the provided policy. 9158 9159 The new situation will have decision type 'pending' and `None` 9160 as the action. 9161 9162 The new situation created as a result of the action is returned, 9163 along with the set of destination decision IDs, including 9164 possibly a modified destination via 'bounce', 'goto', and/or 9165 'follow' effects. For actions that don't have a destination, the 9166 second part of the returned tuple will be an empty set. Multiple 9167 IDs may be in the set when using a start action in a plural- or 9168 spreading-focalized domain, for example. 9169 9170 If the action updates active decisions (including via transition 9171 effects) this will also update the exploration status of those 9172 decisions to 'exploring' if they had been in an unvisited 9173 status (see `updatePosition` and `hasBeenVisited`). This 9174 includes decisions traveled through but not ultimately arrived 9175 at via 'follow' effects. 9176 9177 If any decisions are active in the `ENDINGS_DOMAIN`, attempting 9178 to 'warp', 'explore', 'take', or 'start' will raise an 9179 `InvalidActionError`. 9180 """ 9181 now = self.getSituation() 9182 if now.type != 'pending' or now.action is not None: 9183 raise DoubleActionError( 9184 f"Attempted to take action {repr(action)} at step" 9185 f" {len(self) - 1}, but an action and/or decision type" 9186 f" had already been specified:" 9187 f"\nAction: {repr(now.action)}" 9188 f"\nType: {repr(now.type)}" 9189 ) 9190 9191 # Update the now situation to add in the decision type and 9192 # action taken: 9193 revised = base.Situation( 9194 now.graph, 9195 now.state, 9196 decisionType, 9197 action, 9198 now.saves, 9199 now.tags, 9200 now.annotations 9201 ) 9202 self.situations[-1] = revised 9203 9204 # Separate update process when reverting (this branch returns) 9205 if ( 9206 action is not None 9207 and isinstance(action, tuple) 9208 and len(action) == 3 9209 and action[0] == 'revertTo' 9210 and isinstance(action[1], base.SaveSlot) 9211 and isinstance(action[2], set) 9212 and all(isinstance(x, str) for x in action[2]) 9213 ): 9214 _, slot, aspects = action 9215 if slot not in now.saves: 9216 raise KeyError( 9217 f"Cannot load save slot {slot!r} because no save" 9218 f" data has been established for that slot." 9219 ) 9220 load = now.saves[slot] 9221 rGraph, rState = base.revertedState( 9222 (now.graph, now.state), 9223 load, 9224 aspects 9225 ) 9226 reverted = base.Situation( 9227 graph=rGraph, 9228 state=rState, 9229 type='pending', 9230 action=None, 9231 saves=copy.deepcopy(now.saves), 9232 tags={}, 9233 annotations=[] 9234 ) 9235 self.situations.append(reverted) 9236 # Apply any active triggers (edits reverted) 9237 self.applyActiveTriggers() 9238 # Figure out destinations set to return 9239 newDestinations = set() 9240 newPr = rState['primaryDecision'] 9241 if newPr is not None: 9242 newDestinations.add(newPr) 9243 return (reverted, newDestinations) 9244 9245 # TODO: These deep copies are expensive time-wise. Can we avoid 9246 # them? Probably not. 9247 newGraph = copy.deepcopy(now.graph) 9248 newState = copy.deepcopy(now.state) 9249 newSaves = copy.copy(now.saves) # a shallow copy 9250 newTags: Dict[base.Tag, base.TagValue] = {} 9251 newAnnotations: List[base.Annotation] = [] 9252 updated = base.Situation( 9253 graph=newGraph, 9254 state=newState, 9255 type='pending', 9256 action=None, 9257 saves=newSaves, 9258 tags=newTags, 9259 annotations=newAnnotations 9260 ) 9261 9262 targetContext: base.FocalContext 9263 9264 # Now that action effects have been imprinted into the updated 9265 # situation, append it to our situations list 9266 self.situations.append(updated) 9267 9268 # Figure out effects of the action: 9269 if action is None: 9270 raise InvalidActionError( 9271 "None cannot be used as an action when advancing the" 9272 " situation." 9273 ) 9274 9275 aLen = len(action) 9276 9277 destIDs = set() 9278 9279 if ( 9280 action[0] in ('start', 'take', 'explore', 'warp') 9281 and any( 9282 newGraph.domainFor(d) == ENDINGS_DOMAIN 9283 for d in self.getActiveDecisions() 9284 ) 9285 ): 9286 activeEndings = [ 9287 d 9288 for d in self.getActiveDecisions() 9289 if newGraph.domainFor(d) == ENDINGS_DOMAIN 9290 ] 9291 raise InvalidActionError( 9292 f"Attempted to {action[0]!r} while an ending was" 9293 f" active. Active endings are:" 9294 f"\n{newGraph.namesListing(activeEndings)}" 9295 ) 9296 9297 if action == ('noAction',): 9298 # No updates needed 9299 pass 9300 9301 elif ( 9302 not isinstance(action, tuple) 9303 or (action[0] not in get_args(base.ExplorationActionType)) 9304 or not (2 <= aLen <= 7) 9305 ): 9306 raise InvalidActionError( 9307 f"Invalid ExplorationAction tuple (must be a tuple that" 9308 f" starts with an ExplorationActionType and has 2-6" 9309 f" entries if it's not ('noAction',)):" 9310 f"\n{repr(action)}" 9311 ) 9312 9313 elif action[0] == 'start': 9314 ( 9315 _, 9316 positionSpecifier, 9317 primary, 9318 domain, 9319 capabilities, 9320 mechanismStates, 9321 customState 9322 ) = cast( 9323 Tuple[ 9324 Literal['start'], 9325 Union[ 9326 base.DecisionID, 9327 Dict[base.FocalPointName, base.DecisionID], 9328 Set[base.DecisionID] 9329 ], 9330 Optional[base.DecisionID], 9331 base.Domain, 9332 Optional[base.CapabilitySet], 9333 Optional[Dict[base.MechanismID, base.MechanismState]], 9334 Optional[dict] 9335 ], 9336 action 9337 ) 9338 targetContext = newState['contexts'][ 9339 newState['activeContext'] 9340 ] 9341 9342 targetFocalization = base.getDomainFocalization( 9343 targetContext, 9344 domain 9345 ) # sets up 'singular' as default if 9346 9347 # Check if there are any already-active decisions. 9348 if targetContext['activeDecisions'][domain] is not None: 9349 raise BadStart( 9350 f"Cannot start in domain {repr(domain)} because" 9351 f" that domain already has a position. 'start' may" 9352 f" only be used with domains that don't yet have" 9353 f" any position information." 9354 ) 9355 9356 # Make the domain active 9357 if domain not in targetContext['activeDomains']: 9358 targetContext['activeDomains'].add(domain) 9359 9360 # Check position info matches focalization type and update 9361 # exploration statuses 9362 if isinstance(positionSpecifier, base.DecisionID): 9363 if targetFocalization != 'singular': 9364 raise BadStart( 9365 f"Invalid position specifier" 9366 f" {repr(positionSpecifier)} (type" 9367 f" {type(positionSpecifier)}). Domain" 9368 f" {repr(domain)} has {targetFocalization}" 9369 f" focalization." 9370 ) 9371 base.setExplorationStatus( 9372 updated, 9373 positionSpecifier, 9374 'exploring', 9375 upgradeOnly=True 9376 ) 9377 destIDs.add(positionSpecifier) 9378 elif isinstance(positionSpecifier, dict): 9379 if targetFocalization != 'plural': 9380 raise BadStart( 9381 f"Invalid position specifier" 9382 f" {repr(positionSpecifier)} (type" 9383 f" {type(positionSpecifier)}). Domain" 9384 f" {repr(domain)} has {targetFocalization}" 9385 f" focalization." 9386 ) 9387 destIDs |= set(positionSpecifier.values()) 9388 elif isinstance(positionSpecifier, set): 9389 if targetFocalization != 'spreading': 9390 raise BadStart( 9391 f"Invalid position specifier" 9392 f" {repr(positionSpecifier)} (type" 9393 f" {type(positionSpecifier)}). Domain" 9394 f" {repr(domain)} has {targetFocalization}" 9395 f" focalization." 9396 ) 9397 destIDs |= positionSpecifier 9398 else: 9399 raise TypeError( 9400 f"Invalid position specifier" 9401 f" {repr(positionSpecifier)} (type" 9402 f" {type(positionSpecifier)}). It must be a" 9403 f" DecisionID, a dictionary from FocalPointNames to" 9404 f" DecisionIDs, or a set of DecisionIDs, according" 9405 f" to the focalization of the relevant domain." 9406 ) 9407 9408 # Put specified position(s) in place 9409 # TODO: This cast is really silly... 9410 targetContext['activeDecisions'][domain] = cast( 9411 Union[ 9412 None, 9413 base.DecisionID, 9414 Dict[base.FocalPointName, Optional[base.DecisionID]], 9415 Set[base.DecisionID] 9416 ], 9417 positionSpecifier 9418 ) 9419 9420 # Set primary decision 9421 newState['primaryDecision'] = primary 9422 9423 # Set capabilities 9424 if capabilities is not None: 9425 targetContext['capabilities'] = capabilities 9426 9427 # Set mechanism states 9428 if mechanismStates is not None: 9429 newState['mechanisms'] = mechanismStates 9430 9431 # Set custom state 9432 if customState is not None: 9433 newState['custom'] = customState 9434 9435 elif action[0] in ('explore', 'take', 'warp'): # similar handling 9436 assert ( 9437 len(action) == 3 9438 or len(action) == 4 9439 or len(action) == 6 9440 or len(action) == 7 9441 ) 9442 # Set up necessary variables 9443 cSpec: base.ContextSpecifier = "active" 9444 fromID: Optional[base.DecisionID] = None 9445 takeTransition: Optional[base.Transition] = None 9446 outcomes: List[bool] = [] 9447 destID: base.DecisionID # No starting value as it's not optional 9448 moveInDomain: Optional[base.Domain] = None 9449 moveWhich: Optional[base.FocalPointName] = None 9450 9451 # Figure out target context 9452 if isinstance(action[1], str): 9453 if action[1] not in get_args(base.ContextSpecifier): 9454 raise InvalidActionError( 9455 f"Action specifies {repr(action[1])} context," 9456 f" but that's not a valid context specifier." 9457 f" The valid options are:" 9458 f"\n{repr(get_args(base.ContextSpecifier))}" 9459 ) 9460 else: 9461 cSpec = cast(base.ContextSpecifier, action[1]) 9462 else: # Must be a `FocalPointSpecifier` 9463 cSpec, moveInDomain, moveWhich = cast( 9464 base.FocalPointSpecifier, 9465 action[1] 9466 ) 9467 assert moveInDomain is not None 9468 9469 # Grab target context to work in 9470 if cSpec == 'common': 9471 targetContext = newState['common'] 9472 else: 9473 targetContext = newState['contexts'][ 9474 newState['activeContext'] 9475 ] 9476 9477 # Check focalization of the target domain 9478 if moveInDomain is not None: 9479 fType = base.getDomainFocalization( 9480 targetContext, 9481 moveInDomain 9482 ) 9483 if ( 9484 ( 9485 isinstance(action[1], str) 9486 and fType == 'plural' 9487 ) or ( 9488 not isinstance(action[1], str) 9489 and fType != 'plural' 9490 ) 9491 ): 9492 raise ImpossibleActionError( 9493 f"Invalid ExplorationAction (moves in" 9494 f" plural-focalized domains must include a" 9495 f" FocalPointSpecifier, while moves in" 9496 f" non-plural-focalized domains must not." 9497 f" Domain {repr(moveInDomain)} is" 9498 f" {fType}-focalized):" 9499 f"\n{repr(action)}" 9500 ) 9501 9502 if action[0] == "warp": 9503 # It's a warp, so destination is specified directly 9504 if not isinstance(action[2], base.DecisionID): 9505 raise TypeError( 9506 f"Invalid ExplorationAction tuple (third part" 9507 f" must be a decision ID for 'warp' actions):" 9508 f"\n{repr(action)}" 9509 ) 9510 else: 9511 destID = cast(base.DecisionID, action[2]) 9512 9513 elif aLen == 4 or aLen == 7: 9514 # direct 'take' or 'explore' 9515 fromID = cast(base.DecisionID, action[2]) 9516 takeTransition, outcomes = cast( 9517 base.TransitionWithOutcomes, 9518 action[3] # type: ignore [misc] 9519 ) 9520 if ( 9521 not isinstance(fromID, base.DecisionID) 9522 or not isinstance(takeTransition, base.Transition) 9523 ): 9524 raise InvalidActionError( 9525 f"Invalid ExplorationAction tuple (for 'take' or" 9526 f" 'explore', if the length is 4/7, parts 2-4" 9527 f" must be a context specifier, a decision ID, and a" 9528 f" transition name. Got:" 9529 f"\n{repr(action)}" 9530 ) 9531 9532 try: 9533 destID = newGraph.destination(fromID, takeTransition) 9534 except MissingDecisionError: 9535 raise ImpossibleActionError( 9536 f"Invalid ExplorationAction: move from decision" 9537 f" {fromID} is invalid because there is no" 9538 f" decision with that ID in the current" 9539 f" graph." 9540 f"\nValid decisions are:" 9541 f"\n{newGraph.namesListing(newGraph)}" 9542 ) 9543 except MissingTransitionError: 9544 valid = newGraph.destinationsFrom(fromID) 9545 listing = newGraph.destinationsListing(valid) 9546 raise ImpossibleActionError( 9547 f"Invalid ExplorationAction: move from decision" 9548 f" {newGraph.identityOf(fromID)}" 9549 f" along transition {repr(takeTransition)} is" 9550 f" invalid because there is no such transition" 9551 f" at that decision." 9552 f"\nValid transitions there are:" 9553 f"\n{listing}" 9554 ) 9555 targetActive = targetContext['activeDecisions'] 9556 if moveInDomain is not None: 9557 activeInDomain = targetActive[moveInDomain] 9558 if ( 9559 ( 9560 isinstance(activeInDomain, base.DecisionID) 9561 and fromID != activeInDomain 9562 ) 9563 or ( 9564 isinstance(activeInDomain, set) 9565 and fromID not in activeInDomain 9566 ) 9567 or ( 9568 isinstance(activeInDomain, dict) 9569 and fromID not in activeInDomain.values() 9570 ) 9571 ): 9572 raise ImpossibleActionError( 9573 f"Invalid ExplorationAction: move from" 9574 f" decision {fromID} is invalid because" 9575 f" that decision is not active in domain" 9576 f" {repr(moveInDomain)} in the current" 9577 f" graph." 9578 f"\nValid decisions are:" 9579 f"\n{newGraph.namesListing(newGraph)}" 9580 ) 9581 9582 elif aLen == 3 or aLen == 6: 9583 # 'take' or 'explore' focal point 9584 # We know that moveInDomain is not None here. 9585 assert moveInDomain is not None 9586 if not isinstance(action[2], base.Transition): 9587 raise InvalidActionError( 9588 f"Invalid ExplorationAction tuple (for 'take'" 9589 f" actions if the second part is a" 9590 f" FocalPointSpecifier the third part must be a" 9591 f" transition name):" 9592 f"\n{repr(action)}" 9593 ) 9594 9595 takeTransition, outcomes = cast( 9596 base.TransitionWithOutcomes, 9597 action[2] 9598 ) 9599 targetActive = targetContext['activeDecisions'] 9600 activeInDomain = cast( 9601 Dict[base.FocalPointName, Optional[base.DecisionID]], 9602 targetActive[moveInDomain] 9603 ) 9604 if ( 9605 moveInDomain is not None 9606 and ( 9607 not isinstance(activeInDomain, dict) 9608 or moveWhich not in activeInDomain 9609 ) 9610 ): 9611 raise ImpossibleActionError( 9612 f"Invalid ExplorationAction: move of focal" 9613 f" point {repr(moveWhich)} in domain" 9614 f" {repr(moveInDomain)} is invalid because" 9615 f" that domain does not have a focal point" 9616 f" with that name." 9617 ) 9618 fromID = activeInDomain[moveWhich] 9619 if fromID is None: 9620 raise ImpossibleActionError( 9621 f"Invalid ExplorationAction: move of focal" 9622 f" point {repr(moveWhich)} in domain" 9623 f" {repr(moveInDomain)} is invalid because" 9624 f" that focal point does not have a position" 9625 f" at this step." 9626 ) 9627 try: 9628 destID = newGraph.destination(fromID, takeTransition) 9629 except MissingDecisionError: 9630 raise ImpossibleActionError( 9631 f"Invalid exploration state: focal point" 9632 f" {repr(moveWhich)} in domain" 9633 f" {repr(moveInDomain)} specifies decision" 9634 f" {fromID} as the current position, but" 9635 f" that decision does not exist!" 9636 ) 9637 except MissingTransitionError: 9638 valid = newGraph.destinationsFrom(fromID) 9639 listing = newGraph.destinationsListing(valid) 9640 raise ImpossibleActionError( 9641 f"Invalid ExplorationAction: move of focal" 9642 f" point {repr(moveWhich)} in domain" 9643 f" {repr(moveInDomain)} along transition" 9644 f" {repr(takeTransition)} is invalid because" 9645 f" that focal point is at decision" 9646 f" {newGraph.identityOf(fromID)} and that" 9647 f" decision does not have an outgoing" 9648 f" transition with that name.\nValid" 9649 f" transitions from that decision are:" 9650 f"\n{listing}" 9651 ) 9652 9653 else: 9654 raise InvalidActionError( 9655 f"Invalid ExplorationAction: unrecognized" 9656 f" 'explore', 'take' or 'warp' format:" 9657 f"\n{action}" 9658 ) 9659 9660 # If we're exploring, update information for the destination 9661 if action[0] == 'explore': 9662 zone = cast(Optional[base.Zone], action[-1]) 9663 recipName = cast(Optional[base.Transition], action[-2]) 9664 destOrName = cast( 9665 Union[base.DecisionName, base.DecisionID, None], 9666 action[-3] 9667 ) 9668 if isinstance(destOrName, base.DecisionID): 9669 destID = destOrName 9670 9671 if fromID is None or takeTransition is None: 9672 raise ImpossibleActionError( 9673 f"Invalid ExplorationAction: exploration" 9674 f" has unclear origin decision or transition." 9675 f" Got:\n{action}" 9676 ) 9677 9678 currentDest = newGraph.destination(fromID, takeTransition) 9679 if not newGraph.isConfirmed(currentDest): 9680 newGraph.replaceUnconfirmed( 9681 fromID, 9682 takeTransition, 9683 destOrName, 9684 recipName, 9685 placeInZone=zone, 9686 forceNew=not isinstance(destOrName, base.DecisionID) 9687 ) 9688 else: 9689 # Otherwise, since the destination already existed 9690 # and was hooked up at the right decision, no graph 9691 # edits need to be made, unless we need to rename 9692 # the reciprocal. 9693 # TODO: Do we care about zones here? 9694 if recipName is not None: 9695 oldReciprocal = newGraph.getReciprocal( 9696 fromID, 9697 takeTransition 9698 ) 9699 if ( 9700 oldReciprocal is not None 9701 and oldReciprocal != recipName 9702 ): 9703 newGraph.addTransition( 9704 destID, 9705 recipName, 9706 fromID, 9707 None 9708 ) 9709 newGraph.setReciprocal( 9710 destID, 9711 recipName, 9712 takeTransition, 9713 setBoth=True 9714 ) 9715 newGraph.mergeTransitions( 9716 destID, 9717 oldReciprocal, 9718 recipName 9719 ) 9720 9721 # If we are moving along a transition, check requirements 9722 # and apply transition effects *before* updating our 9723 # position, and check that they don't cancel the normal 9724 # position update 9725 finalDest = None 9726 if takeTransition is not None: 9727 assert fromID is not None # both or neither 9728 if not self.isTraversable(fromID, takeTransition): 9729 req = now.graph.getTransitionRequirement( 9730 fromID, 9731 takeTransition 9732 ) 9733 # TODO: Alter warning message if transition is 9734 # deactivated vs. requirement not satisfied 9735 warnings.warn( 9736 ( 9737 f"The requirements for transition" 9738 f" {takeTransition!r} from decision" 9739 f" {now.graph.identityOf(fromID)} are" 9740 f" not met at step {len(self) - 1} (or that" 9741 f" transition has been deactivated):\n{req}" 9742 ), 9743 TransitionBlockedWarning 9744 ) 9745 9746 # Apply transition consequences to our new state and 9747 # figure out if we need to skip our normal update or not 9748 finalDest = self.applyTransitionConsequence( 9749 fromID, 9750 (takeTransition, outcomes), 9751 moveWhich, 9752 challengePolicy 9753 ) 9754 9755 # Check moveInDomain 9756 destDomain = newGraph.domainFor(destID) 9757 if moveInDomain is not None and moveInDomain != destDomain: 9758 raise ImpossibleActionError( 9759 f"Invalid ExplorationAction: move specified" 9760 f" domain {repr(moveInDomain)} as the domain of" 9761 f" the focal point to move, but the destination" 9762 f" of the move is {now.graph.identityOf(destID)}" 9763 f" which is in domain {repr(destDomain)}, so focal" 9764 f" point {repr(moveWhich)} cannot be moved there." 9765 ) 9766 9767 # Now that we know where we're going, update position 9768 # information (assuming it wasn't already set): 9769 if finalDest is None: 9770 finalDest = destID 9771 base.updatePosition( 9772 updated, 9773 destID, 9774 cSpec, 9775 moveWhich 9776 ) 9777 9778 destIDs.add(finalDest) 9779 9780 elif action[0] == "focus": 9781 # Figure out target context 9782 action = cast( 9783 Tuple[ 9784 Literal['focus'], 9785 base.ContextSpecifier, 9786 Set[base.Domain], 9787 Set[base.Domain] 9788 ], 9789 action 9790 ) 9791 contextSpecifier: base.ContextSpecifier = action[1] 9792 if contextSpecifier == 'common': 9793 targetContext = newState['common'] 9794 else: 9795 targetContext = newState['contexts'][ 9796 newState['activeContext'] 9797 ] 9798 9799 # Just need to swap out active domains 9800 goingOut, comingIn = cast( 9801 Tuple[Set[base.Domain], Set[base.Domain]], 9802 action[2:] 9803 ) 9804 if ( 9805 not isinstance(goingOut, set) 9806 or not isinstance(comingIn, set) 9807 or not all(isinstance(d, base.Domain) for d in goingOut) 9808 or not all(isinstance(d, base.Domain) for d in comingIn) 9809 ): 9810 raise InvalidActionError( 9811 f"Invalid ExplorationAction tuple (must have 4" 9812 f" parts if the first part is 'focus' and" 9813 f" the third and fourth parts must be sets of" 9814 f" domains):" 9815 f"\n{repr(action)}" 9816 ) 9817 activeSet = targetContext['activeDomains'] 9818 for dom in goingOut: 9819 try: 9820 activeSet.remove(dom) 9821 except KeyError: 9822 warnings.warn( 9823 ( 9824 f"Domain {repr(dom)} was deactivated at" 9825 f" step {len(self)} but it was already" 9826 f" inactive at that point." 9827 ), 9828 InactiveDomainWarning 9829 ) 9830 # TODO: Also warn for doubly-activated domains? 9831 activeSet |= comingIn 9832 9833 # destIDs remains empty in this case 9834 9835 elif action[0] == 'swap': # update which `FocalContext` is active 9836 newContext = cast(base.FocalContextName, action[1]) 9837 if newContext not in newState['contexts']: 9838 raise MissingFocalContextError( 9839 f"'swap' action with target {repr(newContext)} is" 9840 f" invalid because no context with that name" 9841 f" exists." 9842 ) 9843 newState['activeContext'] = newContext 9844 9845 # destIDs remains empty in this case 9846 9847 elif action[0] == 'focalize': # create new `FocalContext` 9848 newContext = cast(base.FocalContextName, action[1]) 9849 if newContext in newState['contexts']: 9850 raise FocalContextCollisionError( 9851 f"'focalize' action with target {repr(newContext)}" 9852 f" is invalid because a context with that name" 9853 f" already exists." 9854 ) 9855 newState['contexts'][newContext] = base.emptyFocalContext() 9856 newState['activeContext'] = newContext 9857 9858 # destIDs remains empty in this case 9859 9860 # revertTo is handled above 9861 else: 9862 raise InvalidActionError( 9863 f"Invalid ExplorationAction tuple (first item must be" 9864 f" an ExplorationActionType, and tuple must be length-1" 9865 f" if the action type is 'noAction'):" 9866 f"\n{repr(action)}" 9867 ) 9868 9869 # Apply any active triggers 9870 followTo = self.applyActiveTriggers() 9871 if followTo is not None: 9872 destIDs.add(followTo) 9873 # TODO: Re-work to work with multiple position updates in 9874 # different focal contexts, domains, and/or for different 9875 # focal points in plural-focalized domains. 9876 9877 return (updated, destIDs) 9878 9879 def applyActiveTriggers(self) -> Optional[base.DecisionID]: 9880 """ 9881 Finds all actions with the 'trigger' tag attached to currently 9882 active decisions, and applies their effects if their requirements 9883 are met (ordered by decision-ID with ties broken alphabetically 9884 by action name). 9885 9886 'bounce', 'goto' and 'follow' effects may apply. However, any 9887 new triggers that would be activated because of decisions 9888 reached by such effects will not apply. Note that 'bounce' 9889 effects update position to the decision where the action was 9890 attached, which is usually a no-op. This function returns the 9891 decision ID of the decision reached by the last decision-moving 9892 effect applied, or `None` if no such effects triggered. 9893 9894 TODO: What about situations where positions are updated in 9895 multiple domains or multiple foal points in a plural domain are 9896 independently updated? 9897 9898 TODO: Tests for this! 9899 """ 9900 active = self.getActiveDecisions() 9901 now = self.getSituation() 9902 graph = now.graph 9903 finalFollow = None 9904 for decision in sorted(active): 9905 for action in graph.decisionActions(decision): 9906 if ( 9907 'trigger' in graph.transitionTags(decision, action) 9908 and self.isTraversable(decision, action) 9909 ): 9910 followTo = self.applyTransitionConsequence( 9911 decision, 9912 action 9913 ) 9914 if followTo is not None: 9915 # TODO: How will triggers interact with 9916 # plural-focalized domains? Probably need to fix 9917 # this to detect moveWhich based on which focal 9918 # points are at the decision where the transition 9919 # is, and then apply this to each of them? 9920 base.updatePosition(now, followTo) 9921 finalFollow = followTo 9922 9923 return finalFollow 9924 9925 def explore( 9926 self, 9927 transition: base.AnyTransition, 9928 destination: Union[base.DecisionName, base.DecisionID, None], 9929 reciprocal: Optional[base.Transition] = None, 9930 zone: Optional[base.Zone] = base.DefaultZone, 9931 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 9932 whichFocus: Optional[base.FocalPointSpecifier] = None, 9933 inCommon: Union[bool, Literal["auto"]] = "auto", 9934 decisionType: base.DecisionType = "active", 9935 challengePolicy: base.ChallengePolicy = "specified" 9936 ) -> base.DecisionID: 9937 """ 9938 Adds a new situation to the exploration representing the 9939 traversal of the specified transition (possibly with outcomes 9940 specified for challenges among that transitions consequences). 9941 Uses `deduceTransitionDetailsAtStep` to figure out from the 9942 transition name which specific transition is taken (and which 9943 focal point is updated if necessary). This uses the 9944 `fromDecision`, `whichFocus`, and `inCommon` optional 9945 parameters, and also determines whether to update the common or 9946 the active `FocalContext`. Sets the exploration status of the 9947 decision explored to 'exploring'. Returns the decision ID for 9948 the destination reached, accounting for goto/bounce/follow 9949 effects that might have triggered. 9950 9951 The `destination` will be used to name the newly-explored 9952 decision, except when it's a `DecisionID`, in which case that 9953 decision must be unvisited, and we'll connect the specified 9954 transition to that decision. 9955 9956 The focalization of the destination domain in the context to be 9957 updated determines how active decisions are changed: 9958 9959 - If the destination domain is focalized as 'single', then in 9960 the subsequent `Situation`, the destination decision will 9961 become the single active decision in that domain. 9962 - If it's focalized as 'plural', then one of the 9963 `FocalPointName`s for that domain will be moved to activate 9964 that decision; which one can be specified using `whichFocus` 9965 or if left unspecified, will be deduced: if the starting 9966 decision is in the same domain, then the 9967 alphabetically-earliest focal point which is at the starting 9968 decision will be moved. If the starting position is in a 9969 different domain, then the alphabetically earliest focal 9970 point among all focal points in the destination domain will 9971 be moved. 9972 - If it's focalized as 'spreading', then the destination 9973 decision will be added to the set of active decisions in 9974 that domain, without removing any. 9975 9976 The transition named must have been pointing to an unvisited 9977 decision (see `hasBeenVisited`), and the name of that decision 9978 will be updated if a `destination` value is given (a 9979 `DecisionCollisionWarning` will be issued if the destination 9980 name is a duplicate of another name in the graph, although this 9981 is not an error). Additionally: 9982 9983 - If a `reciprocal` name is specified, the reciprocal transition 9984 will be renamed using that name, or created with that name if 9985 it didn't already exist. If reciprocal is left as `None` (the 9986 default) then no change will be made to the reciprocal 9987 transition, and it will not be created if it doesn't exist. 9988 - If a `zone` is specified, the newly-explored decision will be 9989 added to that zone (and that zone will be created at level 0 9990 if it didn't already exist). If `zone` is set to `None` then 9991 it will not be added to any new zones. If `zone` is left as 9992 the default (the `base.DefaultZone` value) then the explored 9993 decision will be added to each zone that the decision it was 9994 explored from is a part of. If a zone needs to be created, 9995 that zone will be added as a sub-zone of each zone which is a 9996 parent of a zone that directly contains the origin decision. 9997 - An `ExplorationStatusError` will be raised if the specified 9998 transition leads to a decision whose `ExplorationStatus` is 9999 'exploring' or higher (i.e., `hasBeenVisited`). (Use 10000 `returnTo` instead to adjust things when a transition to an 10001 unknown destination turns out to lead to an already-known 10002 destination.) 10003 - A `TransitionBlockedWarning` will be issued if the specified 10004 transition is not traversable given the current game state 10005 (but in that last case the step will still be taken). 10006 - By default, the decision type for the new step will be 10007 'active', but a `decisionType` value can be specified to 10008 override that. 10009 - By default, the 'mostLikely' `ChallengePolicy` will be used to 10010 resolve challenges in the consequence of the transition 10011 taken, but an alternate policy can be supplied using the 10012 `challengePolicy` argument. 10013 """ 10014 now = self.getSituation() 10015 10016 transitionName, outcomes = base.nameAndOutcomes(transition) 10017 10018 # Deduce transition details from the name + optional specifiers 10019 ( 10020 using, 10021 fromID, 10022 destID, 10023 whichFocus 10024 ) = self.deduceTransitionDetailsAtStep( 10025 -1, 10026 transitionName, 10027 fromDecision, 10028 whichFocus, 10029 inCommon 10030 ) 10031 10032 # Issue a warning if the destination name is already in use 10033 if destination is not None: 10034 if isinstance(destination, base.DecisionName): 10035 try: 10036 existingID = now.graph.resolveDecision(destination) 10037 collision = existingID != destID 10038 except MissingDecisionError: 10039 collision = False 10040 except AmbiguousDecisionSpecifierError: 10041 collision = True 10042 10043 if collision and WARN_OF_NAME_COLLISIONS: 10044 warnings.warn( 10045 ( 10046 f"The destination name {repr(destination)} is" 10047 f" already in use when exploring transition" 10048 f" {repr(transition)} from decision" 10049 f" {now.graph.identityOf(fromID)} at step" 10050 f" {len(self) - 1}." 10051 ), 10052 DecisionCollisionWarning 10053 ) 10054 10055 # TODO: Different terminology for "exploration state above 10056 # noticed" vs. "DG thinks it's been visited"... 10057 if ( 10058 self.hasBeenVisited(destID) 10059 ): 10060 raise ExplorationStatusError( 10061 f"Cannot explore to decision" 10062 f" {now.graph.identityOf(destID)} because it has" 10063 f" already been visited. Use returnTo instead of" 10064 f" explore when discovering a connection back to a" 10065 f" previously-explored decision." 10066 ) 10067 10068 if ( 10069 isinstance(destination, base.DecisionID) 10070 and self.hasBeenVisited(destination) 10071 ): 10072 raise ExplorationStatusError( 10073 f"Cannot explore to decision" 10074 f" {now.graph.identityOf(destination)} because it has" 10075 f" already been visited. Use returnTo instead of" 10076 f" explore when discovering a connection back to a" 10077 f" previously-explored decision." 10078 ) 10079 10080 actionTaken: base.ExplorationAction = ( 10081 'explore', 10082 using, 10083 fromID, 10084 (transitionName, outcomes), 10085 destination, 10086 reciprocal, 10087 zone 10088 ) 10089 if whichFocus is not None: 10090 # A move-from-specific-focal-point action 10091 actionTaken = ( 10092 'explore', 10093 whichFocus, 10094 (transitionName, outcomes), 10095 destination, 10096 reciprocal, 10097 zone 10098 ) 10099 10100 # Advance the situation, applying transition effects and 10101 # updating the destination decision. 10102 _, finalDest = self.advanceSituation( 10103 actionTaken, 10104 decisionType, 10105 challengePolicy 10106 ) 10107 10108 # TODO: Is this assertion always valid? 10109 assert len(finalDest) == 1 10110 return next(x for x in finalDest) 10111 10112 def returnTo( 10113 self, 10114 transition: base.AnyTransition, 10115 destination: base.AnyDecisionSpecifier, 10116 reciprocal: Optional[base.Transition] = None, 10117 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 10118 whichFocus: Optional[base.FocalPointSpecifier] = None, 10119 inCommon: Union[bool, Literal["auto"]] = "auto", 10120 decisionType: base.DecisionType = "active", 10121 challengePolicy: base.ChallengePolicy = "specified" 10122 ) -> base.DecisionID: 10123 """ 10124 Adds a new graph to the exploration that replaces the given 10125 transition at the current position (which must lead to an unknown 10126 node, or a `MissingDecisionError` will result). The new 10127 transition will connect back to the specified destination, which 10128 must already exist (or a different `ValueError` will be raised). 10129 Returns the decision ID for the destination reached. 10130 10131 Deduces transition details using the optional `fromDecision`, 10132 `whichFocus`, and `inCommon` arguments in addition to the 10133 `transition` value; see `deduceTransitionDetailsAtStep`. 10134 10135 If a `reciprocal` transition is specified, that transition must 10136 either not already exist in the destination decision or lead to 10137 an unknown region; it will be replaced (or added) as an edge 10138 leading back to the current position. 10139 10140 The `decisionType` and `challengePolicy` optional arguments are 10141 used for `advanceSituation`. 10142 10143 A `TransitionBlockedWarning` will be issued if the requirements 10144 for the transition are not met, but the step will still be taken. 10145 Raises a `MissingDecisionError` if there is no current 10146 transition. 10147 """ 10148 now = self.getSituation() 10149 10150 transitionName, outcomes = base.nameAndOutcomes(transition) 10151 10152 # Deduce transition details from the name + optional specifiers 10153 ( 10154 using, 10155 fromID, 10156 destID, 10157 whichFocus 10158 ) = self.deduceTransitionDetailsAtStep( 10159 -1, 10160 transitionName, 10161 fromDecision, 10162 whichFocus, 10163 inCommon 10164 ) 10165 10166 # Replace with connection to existing destination 10167 destID = now.graph.resolveDecision(destination) 10168 if not self.hasBeenVisited(destID): 10169 raise ExplorationStatusError( 10170 f"Cannot return to decision" 10171 f" {now.graph.identityOf(destID)} because it has NOT" 10172 f" already been at least partially explored. Use" 10173 f" explore instead of returnTo when discovering a" 10174 f" connection to a previously-unexplored decision." 10175 ) 10176 10177 now.graph.replaceUnconfirmed( 10178 fromID, 10179 transitionName, 10180 destID, 10181 reciprocal 10182 ) 10183 10184 # A move-from-decision action 10185 actionTaken: base.ExplorationAction = ( 10186 'take', 10187 using, 10188 fromID, 10189 (transitionName, outcomes) 10190 ) 10191 if whichFocus is not None: 10192 # A move-from-specific-focal-point action 10193 actionTaken = ('take', whichFocus, (transitionName, outcomes)) 10194 10195 # Next, advance the situation, applying transition effects 10196 _, finalDest = self.advanceSituation( 10197 actionTaken, 10198 decisionType, 10199 challengePolicy 10200 ) 10201 10202 assert len(finalDest) == 1 10203 return next(x for x in finalDest) 10204 10205 def takeAction( 10206 self, 10207 action: base.AnyTransition, 10208 requires: Optional[base.Requirement] = None, 10209 consequence: Optional[base.Consequence] = None, 10210 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 10211 whichFocus: Optional[base.FocalPointSpecifier] = None, 10212 inCommon: Union[bool, Literal["auto"]] = "auto", 10213 decisionType: base.DecisionType = "active", 10214 challengePolicy: base.ChallengePolicy = "specified" 10215 ) -> base.DecisionID: 10216 """ 10217 Adds a new graph to the exploration based on taking the given 10218 action, which must be a self-transition in the graph. If the 10219 action does not already exist in the graph, it will be created. 10220 Either way if requirements and/or a consequence are supplied, 10221 the requirements and consequence of the action will be updated 10222 to match them, and those are the requirements/consequence that 10223 will count. 10224 10225 Returns the decision ID for the decision reached, which normally 10226 is the same action you were just at, but which might be altered 10227 by goto, bounce, and/or follow effects. 10228 10229 Issues a `TransitionBlockedWarning` if the current game state 10230 doesn't satisfy the requirements for the action. 10231 10232 The `fromDecision`, `whichFocus`, and `inCommon` arguments are 10233 used for `deduceTransitionDetailsAtStep`, while `decisionType` 10234 and `challengePolicy` are used for `advanceSituation`. 10235 10236 When an action is being created, `fromDecision` (or 10237 `whichFocus`) must be specified, since the source decision won't 10238 be deducible from the transition name. Note that if a transition 10239 with the given name exists from *any* active decision, it will 10240 be used instead of creating a new action (possibly resulting in 10241 an error if it's not a self-loop transition). Also, you may get 10242 an `AmbiguousTransitionError` if several transitions with that 10243 name exist; in that case use `fromDecision` and/or `whichFocus` 10244 to disambiguate. 10245 """ 10246 now = self.getSituation() 10247 graph = now.graph 10248 10249 actionName, outcomes = base.nameAndOutcomes(action) 10250 10251 try: 10252 ( 10253 using, 10254 fromID, 10255 destID, 10256 whichFocus 10257 ) = self.deduceTransitionDetailsAtStep( 10258 -1, 10259 actionName, 10260 fromDecision, 10261 whichFocus, 10262 inCommon 10263 ) 10264 10265 if destID != fromID: 10266 raise ValueError( 10267 f"Cannot take action {repr(action)} because it's a" 10268 f" transition to another decision, not an action" 10269 f" (use explore, returnTo, and/or retrace instead)." 10270 ) 10271 10272 except MissingTransitionError: 10273 using = 'active' 10274 if inCommon is True: 10275 using = 'common' 10276 10277 if fromDecision is not None: 10278 fromID = graph.resolveDecision(fromDecision) 10279 elif whichFocus is not None: 10280 maybeFromID = base.resolvePosition(now, whichFocus) 10281 if maybeFromID is None: 10282 raise MissingDecisionError( 10283 f"Focal point {repr(whichFocus)} was specified" 10284 f" in takeAction but that focal point doesn't" 10285 f" have a position." 10286 ) 10287 else: 10288 fromID = maybeFromID 10289 else: 10290 raise AmbiguousTransitionError( 10291 f"Taking action {repr(action)} is ambiguous because" 10292 f" the source decision has not been specified via" 10293 f" either fromDecision or whichFocus, and we" 10294 f" couldn't find an existing action with that name." 10295 ) 10296 10297 # Since the action doesn't exist, add it: 10298 graph.addAction(fromID, actionName, requires, consequence) 10299 10300 # Update the transition requirement/consequence if requested 10301 # (before the action is taken) 10302 if requires is not None: 10303 graph.setTransitionRequirement(fromID, actionName, requires) 10304 if consequence is not None: 10305 graph.setConsequence(fromID, actionName, consequence) 10306 10307 # A move-from-decision action 10308 actionTaken: base.ExplorationAction = ( 10309 'take', 10310 using, 10311 fromID, 10312 (actionName, outcomes) 10313 ) 10314 if whichFocus is not None: 10315 # A move-from-specific-focal-point action 10316 actionTaken = ('take', whichFocus, (actionName, outcomes)) 10317 10318 _, finalDest = self.advanceSituation( 10319 actionTaken, 10320 decisionType, 10321 challengePolicy 10322 ) 10323 10324 assert len(finalDest) in (0, 1) 10325 if len(finalDest) == 1: 10326 return next(x for x in finalDest) 10327 else: 10328 return fromID 10329 10330 def retrace( 10331 self, 10332 transition: base.AnyTransition, 10333 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 10334 whichFocus: Optional[base.FocalPointSpecifier] = None, 10335 inCommon: Union[bool, Literal["auto"]] = "auto", 10336 decisionType: base.DecisionType = "active", 10337 challengePolicy: base.ChallengePolicy = "specified" 10338 ) -> base.DecisionID: 10339 """ 10340 Adds a new graph to the exploration based on taking the given 10341 transition, which must already exist and which must not lead to 10342 an unknown region. Returns the ID of the destination decision, 10343 accounting for goto, bounce, and/or follow effects. 10344 10345 Issues a `TransitionBlockedWarning` if the current game state 10346 doesn't satisfy the requirements for the transition. 10347 10348 The `fromDecision`, `whichFocus`, and `inCommon` arguments are 10349 used for `deduceTransitionDetailsAtStep`, while `decisionType` 10350 and `challengePolicy` are used for `advanceSituation`. 10351 """ 10352 now = self.getSituation() 10353 10354 transitionName, outcomes = base.nameAndOutcomes(transition) 10355 10356 ( 10357 using, 10358 fromID, 10359 destID, 10360 whichFocus 10361 ) = self.deduceTransitionDetailsAtStep( 10362 -1, 10363 transitionName, 10364 fromDecision, 10365 whichFocus, 10366 inCommon 10367 ) 10368 10369 visited = self.hasBeenVisited(destID) 10370 confirmed = now.graph.isConfirmed(destID) 10371 if not confirmed: 10372 raise ExplorationStatusError( 10373 f"Cannot retrace transition {transition!r} from" 10374 f" decision {now.graph.identityOf(fromID)} because it" 10375 f" leads to an unconfirmed decision.\nUse" 10376 f" `DiscreteExploration.explore` and provide" 10377 f" destination decision details instead." 10378 ) 10379 if not visited: 10380 raise ExplorationStatusError( 10381 f"Cannot retrace transition {transition!r} from" 10382 f" decision {now.graph.identityOf(fromID)} because it" 10383 f" leads to an unvisited decision.\nUse" 10384 f" `DiscreteExploration.explore` and provide" 10385 f" destination decision details instead." 10386 ) 10387 10388 # A move-from-decision action 10389 actionTaken: base.ExplorationAction = ( 10390 'take', 10391 using, 10392 fromID, 10393 (transitionName, outcomes) 10394 ) 10395 if whichFocus is not None: 10396 # A move-from-specific-focal-point action 10397 actionTaken = ('take', whichFocus, (transitionName, outcomes)) 10398 10399 _, finalDest = self.advanceSituation( 10400 actionTaken, 10401 decisionType, 10402 challengePolicy 10403 ) 10404 10405 assert len(finalDest) == 1 10406 return next(x for x in finalDest) 10407 10408 def warp( 10409 self, 10410 destination: base.AnyDecisionSpecifier, 10411 consequence: Optional[base.Consequence] = None, 10412 domain: Optional[base.Domain] = None, 10413 zone: Optional[base.Zone] = base.DefaultZone, 10414 whichFocus: Optional[base.FocalPointSpecifier] = None, 10415 inCommon: Union[bool] = False, 10416 decisionType: base.DecisionType = "active", 10417 challengePolicy: base.ChallengePolicy = "specified" 10418 ) -> base.DecisionID: 10419 """ 10420 Adds a new graph to the exploration that's a copy of the current 10421 graph, with the position updated to be at the destination without 10422 actually creating a transition from the old position to the new 10423 one. Returns the ID of the decision warped to (accounting for 10424 any goto or follow effects triggered). 10425 10426 Any provided consequences are applied, but are not associated 10427 with any transition (so any delays and charges are ignored, and 10428 'bounce' effects don't actually cancel the warp). 'goto' or 10429 'follow' effects might change the warp destination; 'follow' 10430 effects take the original destination as their starting point. 10431 Any mechanisms mentioned in extra consequences will be found 10432 based on the destination. Outcomes in supplied challenges should 10433 be pre-specified, or else they will be resolved with the 10434 `challengePolicy`. 10435 10436 `whichFocus` may be specified when the destination domain's 10437 focalization is 'plural' but for 'singular' or 'spreading' 10438 destination domains it is not allowed. `inCommon` determines 10439 whether the common or the active focal context is updated 10440 (default is to update the active context). The `decisionType` 10441 and `challengePolicy` are used for `advanceSituation`. 10442 10443 - If the destination did not already exist, it will be created. 10444 Initially, it will be disconnected from all other decisions. 10445 In this case, the `domain` value can be used to put it in a 10446 non-default domain. 10447 - The position is set to the specified destination, and if a 10448 `consequence` is specified it is applied. Note that 10449 'deactivate' effects are NOT allowed, and 'edit' effects 10450 must establish their own transition target because there is 10451 no transition that the effects are being applied to. 10452 - If the destination had been unexplored, its exploration status 10453 will be set to 'exploring'. 10454 - If a `zone` is specified, the destination will be added to that 10455 zone (even if the destination already existed) and that zone 10456 will be created (as a level-0 zone) if need be. If `zone` is 10457 set to `None`, then no zone will be applied. If `zone` is 10458 left as the default (`base.DefaultZone`) and the 10459 focalization of the destination domain is 'singular' or 10460 'plural' and the destination is newly created and there is 10461 an origin and the origin is in the same domain as the 10462 destination, then the destination will be added to all zones 10463 that the origin was a part of if the destination is newly 10464 created, but otherwise the destination will not be added to 10465 any zones. If the specified zone has to be created and 10466 there's an origin decision, it will be added as a sub-zone 10467 to all parents of zones directly containing the origin, as 10468 long as the origin is in the same domain as the destination. 10469 """ 10470 now = self.getSituation() 10471 graph = now.graph 10472 10473 fromID: Optional[base.DecisionID] 10474 10475 new = False 10476 try: 10477 destID = graph.resolveDecision(destination) 10478 except MissingDecisionError: 10479 if isinstance(destination, tuple): 10480 # just the name; ignore zone/domain 10481 destination = destination[-1] 10482 10483 if not isinstance(destination, base.DecisionName): 10484 raise TypeError( 10485 f"Warp destination {repr(destination)} does not" 10486 f" exist, and cannot be created as it is not a" 10487 f" decision name." 10488 ) 10489 destID = graph.addDecision(destination, domain) 10490 graph.tagDecision(destID, 'unconfirmed') 10491 self.setExplorationStatus(destID, 'unknown') 10492 new = True 10493 10494 using: base.ContextSpecifier 10495 if inCommon: 10496 targetContext = self.getCommonContext() 10497 using = "common" 10498 else: 10499 targetContext = self.getActiveContext() 10500 using = "active" 10501 10502 destDomain = graph.domainFor(destID) 10503 targetFocalization = base.getDomainFocalization( 10504 targetContext, 10505 destDomain 10506 ) 10507 if targetFocalization == 'singular': 10508 targetActive = targetContext['activeDecisions'] 10509 if destDomain in targetActive: 10510 fromID = cast( 10511 base.DecisionID, 10512 targetContext['activeDecisions'][destDomain] 10513 ) 10514 else: 10515 fromID = None 10516 elif targetFocalization == 'plural': 10517 if whichFocus is None: 10518 raise AmbiguousTransitionError( 10519 f"Warping to {repr(destination)} is ambiguous" 10520 f" becuase domain {repr(destDomain)} has plural" 10521 f" focalization, and no whichFocus value was" 10522 f" specified." 10523 ) 10524 10525 fromID = base.resolvePosition( 10526 self.getSituation(), 10527 whichFocus 10528 ) 10529 else: 10530 fromID = None 10531 10532 # Handle zones 10533 if zone == base.DefaultZone: 10534 if ( 10535 new 10536 and fromID is not None 10537 and graph.domainFor(fromID) == destDomain 10538 ): 10539 for prevZone in graph.zoneParents(fromID): 10540 graph.addDecisionToZone(destination, prevZone) 10541 # Otherwise don't update zones 10542 elif zone is not None: 10543 # Newness is ignored when a zone is specified 10544 zone = cast(base.Zone, zone) 10545 # Create the zone at level 0 if it didn't already exist 10546 if graph.getZoneInfo(zone) is None: 10547 graph.createZone(zone, 0) 10548 # Add the newly created zone to each 2nd-level parent of 10549 # the previous decision if there is one and it's in the 10550 # same domain 10551 if ( 10552 fromID is not None 10553 and graph.domainFor(fromID) == destDomain 10554 ): 10555 for prevZone in graph.zoneParents(fromID): 10556 for prevUpper in graph.zoneParents(prevZone): 10557 graph.addZoneToZone(zone, prevUpper) 10558 # Finally add the destination to the (maybe new) zone 10559 graph.addDecisionToZone(destID, zone) 10560 # else don't touch zones 10561 10562 # Encode the action taken 10563 actionTaken: base.ExplorationAction 10564 if whichFocus is None: 10565 actionTaken = ( 10566 'warp', 10567 using, 10568 destID 10569 ) 10570 else: 10571 actionTaken = ( 10572 'warp', 10573 whichFocus, 10574 destID 10575 ) 10576 10577 # Advance the situation 10578 _, finalDests = self.advanceSituation( 10579 actionTaken, 10580 decisionType, 10581 challengePolicy 10582 ) 10583 now = self.getSituation() # updating just in case 10584 10585 assert len(finalDests) == 1 10586 finalDest = next(x for x in finalDests) 10587 10588 # Apply additional consequences: 10589 if consequence is not None: 10590 altDest = self.applyExtraneousConsequence( 10591 consequence, 10592 where=(destID, None), 10593 # TODO: Mechanism search from both ends? 10594 moveWhich=( 10595 whichFocus[-1] 10596 if whichFocus is not None 10597 else None 10598 ) 10599 ) 10600 if altDest is not None: 10601 finalDest = altDest 10602 now = self.getSituation() # updating just in case 10603 10604 return finalDest 10605 10606 def wait( 10607 self, 10608 consequence: Optional[base.Consequence] = None, 10609 decisionType: base.DecisionType = "active", 10610 challengePolicy: base.ChallengePolicy = "specified" 10611 ) -> Optional[base.DecisionID]: 10612 """ 10613 Adds a wait step. If a consequence is specified, it is applied, 10614 although it will not have any position/transition information 10615 available during resolution/application. 10616 10617 A decision type other than "active" and/or a challenge policy 10618 other than "specified" can be included (see `advanceSituation`). 10619 10620 The "pending" decision type may not be used, a `ValueError` will 10621 result. This allows None as the action for waiting while 10622 preserving the pending/None type/action combination for 10623 unresolved situations. 10624 10625 If a goto or follow effect in the applied consequence implies a 10626 position update, this will return the new destination ID; 10627 otherwise it will return `None`. Triggering a 'bounce' effect 10628 will be an error, because there is no position information for 10629 the effect. 10630 """ 10631 if decisionType == "pending": 10632 raise ValueError( 10633 "The 'pending' decision type may not be used for" 10634 " wait actions." 10635 ) 10636 self.advanceSituation(('noAction',), decisionType, challengePolicy) 10637 now = self.getSituation() 10638 if consequence is not None: 10639 if challengePolicy != "specified": 10640 base.resetChallengeOutcomes(consequence) 10641 observed = base.observeChallengeOutcomes( 10642 base.RequirementContext( 10643 state=now.state, 10644 graph=now.graph, 10645 searchFrom=set() 10646 ), 10647 consequence, 10648 location=None, # No position info 10649 policy=challengePolicy, 10650 knownOutcomes=None # bake outcomes into the consequence 10651 ) 10652 # No location information since we might have multiple 10653 # active decisions and there's no indication of which one 10654 # we're "waiting at." 10655 finalDest = self.applyExtraneousConsequence(observed) 10656 now = self.getSituation() # updating just in case 10657 10658 return finalDest 10659 else: 10660 return None 10661 10662 def revert( 10663 self, 10664 slot: base.SaveSlot = base.DEFAULT_SAVE_SLOT, 10665 aspects: Optional[Set[str]] = None, 10666 decisionType: base.DecisionType = "active" 10667 ) -> None: 10668 """ 10669 Reverts the game state to a previously-saved game state (saved 10670 via a 'save' effect). The save slot name and set of aspects to 10671 revert are required. By default, all aspects except the graph 10672 are reverted. 10673 """ 10674 if aspects is None: 10675 aspects = set() 10676 10677 action: base.ExplorationAction = ("revertTo", slot, aspects) 10678 10679 self.advanceSituation(action, decisionType) 10680 10681 def observeAll( 10682 self, 10683 where: base.AnyDecisionSpecifier, 10684 *transitions: Union[ 10685 base.Transition, 10686 Tuple[base.Transition, base.AnyDecisionSpecifier], 10687 Tuple[ 10688 base.Transition, 10689 base.AnyDecisionSpecifier, 10690 base.Transition 10691 ] 10692 ] 10693 ) -> List[base.DecisionID]: 10694 """ 10695 Observes one or more new transitions, applying changes to the 10696 current graph. The transitions can be specified in one of three 10697 ways: 10698 10699 1. A transition name. The transition will be created and will 10700 point to a new unexplored node. 10701 2. A pair containing a transition name and a destination 10702 specifier. If the destination does not exist it will be 10703 created as an unexplored node, although in that case the 10704 decision specifier may not be an ID. 10705 3. A triple containing a transition name, a destination 10706 specifier, and a reciprocal name. Works the same as the pair 10707 case but also specifies the name for the reciprocal 10708 transition. 10709 10710 The new transitions are outgoing from specified decision. 10711 10712 Yields the ID of each decision connected to, whether those are 10713 new or existing decisions. 10714 """ 10715 now = self.getSituation() 10716 fromID = now.graph.resolveDecision(where) 10717 result = [] 10718 for entry in transitions: 10719 if isinstance(entry, base.Transition): 10720 result.append(self.observe(fromID, entry)) 10721 else: 10722 result.append(self.observe(fromID, *entry)) 10723 return result 10724 10725 def observe( 10726 self, 10727 where: base.AnyDecisionSpecifier, 10728 transition: base.Transition, 10729 destination: Optional[base.AnyDecisionSpecifier] = None, 10730 reciprocal: Optional[base.Transition] = None 10731 ) -> base.DecisionID: 10732 """ 10733 Observes a single new outgoing transition from the specified 10734 decision. If specified the transition connects to a specific 10735 destination and/or has a specific reciprocal. The specified 10736 destination will be created if it doesn't exist, or where no 10737 destination is specified, a new unexplored decision will be 10738 added. The ID of the decision connected to is returned. 10739 10740 Sets the exploration status of the observed destination to 10741 "noticed" if a destination is specified and needs to be created 10742 (but not when no destination is specified). 10743 10744 For example: 10745 10746 >>> e = DiscreteExploration() 10747 >>> e.start('start') 10748 0 10749 >>> e.observe('start', 'up') 10750 1 10751 >>> g = e.getSituation().graph 10752 >>> g.destinationsFrom('start') 10753 {'up': 1} 10754 >>> e.getExplorationStatus(1) # not given a name: assumed unknown 10755 'unknown' 10756 >>> e.observe('start', 'left', 'A') 10757 2 10758 >>> g.destinationsFrom('start') 10759 {'up': 1, 'left': 2} 10760 >>> g.nameFor(2) 10761 'A' 10762 >>> e.getExplorationStatus(2) # given a name: noticed 10763 'noticed' 10764 >>> e.observe('start', 'up2', 1) 10765 1 10766 >>> g.destinationsFrom('start') 10767 {'up': 1, 'left': 2, 'up2': 1} 10768 >>> e.getExplorationStatus(1) # existing decision: status unchanged 10769 'unknown' 10770 >>> e.observe('start', 'right', 'B', 'left') 10771 3 10772 >>> g.destinationsFrom('start') 10773 {'up': 1, 'left': 2, 'up2': 1, 'right': 3} 10774 >>> g.nameFor(3) 10775 'B' 10776 >>> e.getExplorationStatus(3) # new + name -> noticed 10777 'noticed' 10778 >>> e.observe('start', 'right') # repeat transition name 10779 Traceback (most recent call last): 10780 ... 10781 exploration.core.TransitionCollisionError... 10782 >>> e.observe('start', 'right2', 'B', 'left') # repeat reciprocal 10783 Traceback (most recent call last): 10784 ... 10785 exploration.core.TransitionCollisionError... 10786 >>> g = e.getSituation().graph 10787 >>> g.createZone('Z', 0) 10788 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 10789 annotations=[]) 10790 >>> g.addDecisionToZone('start', 'Z') 10791 >>> e.observe('start', 'down', 'C', 'up') 10792 4 10793 >>> g.destinationsFrom('start') 10794 {'up': 1, 'left': 2, 'up2': 1, 'right': 3, 'down': 4} 10795 >>> g.identityOf('C') 10796 '4 (C)' 10797 >>> g.zoneParents(4) # not in any zones, 'cause still unexplored 10798 set() 10799 >>> e.observe( 10800 ... 'C', 10801 ... 'right', 10802 ... base.DecisionSpecifier('main', 'Z2', 'D'), 10803 ... ) # creates zone 10804 5 10805 >>> g.destinationsFrom('C') 10806 {'up': 0, 'right': 5} 10807 >>> g.destinationsFrom('D') # default reciprocal name 10808 {'return': 4} 10809 >>> g.identityOf('D') 10810 '5 (Z2::D)' 10811 >>> g.zoneParents(5) 10812 {'Z2'} 10813 """ 10814 now = self.getSituation() 10815 fromID = now.graph.resolveDecision(where) 10816 10817 kwargs: Dict[ 10818 str, 10819 Union[base.Transition, base.DecisionName, None] 10820 ] = {} 10821 if reciprocal is not None: 10822 kwargs['reciprocal'] = reciprocal 10823 10824 if destination is not None: 10825 try: 10826 destID = now.graph.resolveDecision(destination) 10827 now.graph.addTransition( 10828 fromID, 10829 transition, 10830 destID, 10831 reciprocal 10832 ) 10833 return destID 10834 except MissingDecisionError: 10835 if isinstance(destination, base.DecisionSpecifier): 10836 kwargs['toDomain'] = destination.domain 10837 kwargs['placeInZone'] = destination.zone 10838 kwargs['destinationName'] = destination.name 10839 elif isinstance(destination, base.DecisionName): 10840 kwargs['destinationName'] = destination 10841 else: 10842 assert isinstance(destination, base.DecisionID) 10843 # We got to except by failing to resolve, so it's an 10844 # invalid ID 10845 raise 10846 10847 result = now.graph.addUnexploredEdge( 10848 fromID, 10849 transition, 10850 **kwargs # type: ignore [arg-type] 10851 ) 10852 if 'destinationName' in kwargs: 10853 self.setExplorationStatus(result, 'noticed', upgradeOnly=True) 10854 return result 10855 10856 def observeMechanisms( 10857 self, 10858 where: Optional[base.AnyDecisionSpecifier], 10859 *mechanisms: Union[ 10860 base.MechanismName, 10861 Tuple[base.MechanismName, base.MechanismState] 10862 ] 10863 ) -> List[base.MechanismID]: 10864 """ 10865 Adds one or more mechanisms to the exploration's current graph, 10866 located at the specified decision. Global mechanisms can be 10867 added by using `None` for the location. Mechanisms are named, or 10868 a (name, state) tuple can be used to set them into a specific 10869 state. Mechanisms not set to a state will be in the 10870 `base.DEFAULT_MECHANISM_STATE`. 10871 """ 10872 now = self.getSituation() 10873 result = [] 10874 for mSpec in mechanisms: 10875 setState = None 10876 if isinstance(mSpec, base.MechanismName): 10877 result.append(now.graph.addMechanism(mSpec, where)) 10878 elif ( 10879 isinstance(mSpec, tuple) 10880 and len(mSpec) == 2 10881 and isinstance(mSpec[0], base.MechanismName) 10882 and isinstance(mSpec[1], base.MechanismState) 10883 ): 10884 result.append(now.graph.addMechanism(mSpec[0], where)) 10885 setState = mSpec[1] 10886 else: 10887 raise TypeError( 10888 f"Invalid mechanism: {repr(mSpec)} (must be a" 10889 f" mechanism name or a (name, state) tuple." 10890 ) 10891 10892 if setState: 10893 self.setMechanismStateNow(result[-1], setState) 10894 10895 return result 10896 10897 def reZone( 10898 self, 10899 zone: base.Zone, 10900 where: base.AnyDecisionSpecifier, 10901 replace: Union[base.Zone, int] = 0 10902 ) -> None: 10903 """ 10904 Alters the current graph without adding a new exploration step. 10905 10906 Calls `DecisionGraph.replaceZonesInHierarchy` targeting the 10907 specified decision. Note that per the logic of that method, ALL 10908 zones at the specified hierarchy level are replaced, even if a 10909 specific zone to replace is specified here. 10910 10911 TODO: not that? 10912 10913 The level value is either specified via `replace` (default 0) or 10914 deduced from the zone provided as the `replace` value using 10915 `DecisionGraph.zoneHierarchyLevel`. 10916 """ 10917 now = self.getSituation() 10918 10919 if isinstance(replace, int): 10920 level = replace 10921 else: 10922 level = now.graph.zoneHierarchyLevel(replace) 10923 10924 now.graph.replaceZonesInHierarchy(where, zone, level) 10925 10926 def runCommand( 10927 self, 10928 command: commands.Command, 10929 scope: Optional[commands.Scope] = None, 10930 line: int = -1 10931 ) -> commands.CommandResult: 10932 """ 10933 Runs a single `Command` applying effects to the exploration, its 10934 current graph, and the provided execution context, and returning 10935 a command result, which contains the modified scope plus 10936 optional skip and label values (see `CommandResult`). This 10937 function also directly modifies the scope you give it. Variable 10938 references in the command are resolved via entries in the 10939 provided scope. If no scope is given, an empty one is created. 10940 10941 A line number may be supplied for use in error messages; if left 10942 out line -1 will be used. 10943 10944 Raises an error if the command is invalid. 10945 10946 For commands that establish a value as the 'current value', that 10947 value will be stored in the '_' variable. When this happens, the 10948 old contents of '_' are stored in '__' first, and the old 10949 contents of '__' are discarded. Note that non-automatic 10950 assignment to '_' does not move the old value to '__'. 10951 """ 10952 try: 10953 if scope is None: 10954 scope = {} 10955 10956 skip: Union[int, str, None] = None 10957 label: Optional[str] = None 10958 10959 if command.command == 'val': 10960 command = cast(commands.LiteralValue, command) 10961 result = commands.resolveValue(command.value, scope) 10962 commands.pushCurrentValue(scope, result) 10963 10964 elif command.command == 'empty': 10965 command = cast(commands.EstablishCollection, command) 10966 collection = commands.resolveVarName(command.collection, scope) 10967 commands.pushCurrentValue( 10968 scope, 10969 { 10970 'list': [], 10971 'tuple': (), 10972 'set': set(), 10973 'dict': {}, 10974 }[collection] 10975 ) 10976 10977 elif command.command == 'append': 10978 command = cast(commands.AppendValue, command) 10979 target = scope['_'] 10980 addIt = commands.resolveValue(command.value, scope) 10981 if isinstance(target, list): 10982 target.append(addIt) 10983 elif isinstance(target, tuple): 10984 scope['_'] = target + (addIt,) 10985 elif isinstance(target, set): 10986 target.add(addIt) 10987 elif isinstance(target, dict): 10988 raise TypeError( 10989 "'append' command cannot be used with a" 10990 " dictionary. Use 'set' instead." 10991 ) 10992 else: 10993 raise TypeError( 10994 f"Invalid current value for 'append' command." 10995 f" The current value must be a list, tuple, or" 10996 f" set, but it was a '{type(target).__name__}'." 10997 ) 10998 10999 elif command.command == 'set': 11000 command = cast(commands.SetValue, command) 11001 target = scope['_'] 11002 where = commands.resolveValue(command.location, scope) 11003 what = commands.resolveValue(command.value, scope) 11004 if isinstance(target, list): 11005 if not isinstance(where, int): 11006 raise TypeError( 11007 f"Cannot set item in list: index {where!r}" 11008 f" is not an integer." 11009 ) 11010 target[where] = what 11011 elif isinstance(target, tuple): 11012 if not isinstance(where, int): 11013 raise TypeError( 11014 f"Cannot set item in tuple: index {where!r}" 11015 f" is not an integer." 11016 ) 11017 if not ( 11018 0 <= where < len(target) 11019 or -1 >= where >= -len(target) 11020 ): 11021 raise IndexError( 11022 f"Cannot set item in tuple at index" 11023 f" {where}: Tuple has length {len(target)}." 11024 ) 11025 scope['_'] = target[:where] + (what,) + target[where + 1:] 11026 elif isinstance(target, set): 11027 if what: 11028 target.add(where) 11029 else: 11030 try: 11031 target.remove(where) 11032 except KeyError: 11033 pass 11034 elif isinstance(target, dict): 11035 target[where] = what 11036 11037 elif command.command == 'pop': 11038 command = cast(commands.PopValue, command) 11039 target = scope['_'] 11040 if isinstance(target, list): 11041 result = target.pop() 11042 commands.pushCurrentValue(scope, result) 11043 elif isinstance(target, tuple): 11044 result = target[-1] 11045 updated = target[:-1] 11046 scope['__'] = updated 11047 scope['_'] = result 11048 else: 11049 raise TypeError( 11050 f"Cannot 'pop' from a {type(target).__name__}" 11051 f" (current value must be a list or tuple)." 11052 ) 11053 11054 elif command.command == 'get': 11055 command = cast(commands.GetValue, command) 11056 target = scope['_'] 11057 where = commands.resolveValue(command.location, scope) 11058 if isinstance(target, list): 11059 if not isinstance(where, int): 11060 raise TypeError( 11061 f"Cannot get item from list: index" 11062 f" {where!r} is not an integer." 11063 ) 11064 elif isinstance(target, tuple): 11065 if not isinstance(where, int): 11066 raise TypeError( 11067 f"Cannot get item from tuple: index" 11068 f" {where!r} is not an integer." 11069 ) 11070 elif isinstance(target, set): 11071 result = where in target 11072 commands.pushCurrentValue(scope, result) 11073 elif isinstance(target, dict): 11074 result = target[where] 11075 commands.pushCurrentValue(scope, result) 11076 else: 11077 result = getattr(target, where) 11078 commands.pushCurrentValue(scope, result) 11079 11080 elif command.command == 'remove': 11081 command = cast(commands.RemoveValue, command) 11082 target = scope['_'] 11083 where = commands.resolveValue(command.location, scope) 11084 if isinstance(target, (list, tuple)): 11085 # this cast is not correct but suppresses warnings 11086 # given insufficient narrowing by MyPy 11087 target = cast(Tuple[Any, ...], target) 11088 if not isinstance(where, int): 11089 raise TypeError( 11090 f"Cannot remove item from list or tuple:" 11091 f" index {where!r} is not an integer." 11092 ) 11093 scope['_'] = target[:where] + target[where + 1:] 11094 elif isinstance(target, set): 11095 target.remove(where) 11096 elif isinstance(target, dict): 11097 del target[where] 11098 else: 11099 raise TypeError( 11100 f"Cannot use 'remove' on a/an" 11101 f" {type(target).__name__}." 11102 ) 11103 11104 elif command.command == 'op': 11105 command = cast(commands.ApplyOperator, command) 11106 left = commands.resolveValue(command.left, scope) 11107 right = commands.resolveValue(command.right, scope) 11108 op = command.op 11109 if op == '+': 11110 result = left + right 11111 elif op == '-': 11112 result = left - right 11113 elif op == '*': 11114 result = left * right 11115 elif op == '/': 11116 result = left / right 11117 elif op == '//': 11118 result = left // right 11119 elif op == '**': 11120 result = left ** right 11121 elif op == '%': 11122 result = left % right 11123 elif op == '^': 11124 result = left ^ right 11125 elif op == '|': 11126 result = left | right 11127 elif op == '&': 11128 result = left & right 11129 elif op == 'and': 11130 result = left and right 11131 elif op == 'or': 11132 result = left or right 11133 elif op == '<': 11134 result = left < right 11135 elif op == '>': 11136 result = left > right 11137 elif op == '<=': 11138 result = left <= right 11139 elif op == '>=': 11140 result = left >= right 11141 elif op == '==': 11142 result = left == right 11143 elif op == 'is': 11144 result = left is right 11145 else: 11146 raise RuntimeError("Invalid operator '{op}'.") 11147 11148 commands.pushCurrentValue(scope, result) 11149 11150 elif command.command == 'unary': 11151 command = cast(commands.ApplyUnary, command) 11152 value = commands.resolveValue(command.value, scope) 11153 op = command.op 11154 if op == '-': 11155 result = -value 11156 elif op == '~': 11157 result = ~value 11158 elif op == 'not': 11159 result = not value 11160 11161 commands.pushCurrentValue(scope, result) 11162 11163 elif command.command == 'assign': 11164 command = cast(commands.VariableAssignment, command) 11165 varname = commands.resolveVarName(command.varname, scope) 11166 value = commands.resolveValue(command.value, scope) 11167 scope[varname] = value 11168 11169 elif command.command == 'delete': 11170 command = cast(commands.VariableDeletion, command) 11171 varname = commands.resolveVarName(command.varname, scope) 11172 del scope[varname] 11173 11174 elif command.command == 'load': 11175 command = cast(commands.LoadVariable, command) 11176 varname = commands.resolveVarName(command.varname, scope) 11177 commands.pushCurrentValue(scope, scope[varname]) 11178 11179 elif command.command == 'call': 11180 command = cast(commands.FunctionCall, command) 11181 function = command.function 11182 if function.startswith('$'): 11183 function = commands.resolveValue(function, scope) 11184 11185 toCall: Callable 11186 args: Tuple[str, ...] 11187 kwargs: Dict[str, Any] 11188 11189 if command.target == 'builtin': 11190 toCall = commands.COMMAND_BUILTINS[function] 11191 args = (scope['_'],) 11192 kwargs = {} 11193 if toCall == round: 11194 if 'ndigits' in scope: 11195 kwargs['ndigits'] = scope['ndigits'] 11196 elif toCall == range and args[0] is None: 11197 start = scope.get('start', 0) 11198 stop = scope['stop'] 11199 step = scope.get('step', 1) 11200 args = (start, stop, step) 11201 11202 else: 11203 if command.target == 'stored': 11204 toCall = function 11205 elif command.target == 'graph': 11206 toCall = getattr(self.getSituation().graph, function) 11207 elif command.target == 'exploration': 11208 toCall = getattr(self, function) 11209 else: 11210 raise TypeError( 11211 f"Invalid call target '{command.target}'" 11212 f" (must be one of 'builtin', 'stored'," 11213 f" 'graph', or 'exploration'." 11214 ) 11215 11216 # Fill in arguments via kwargs defined in scope 11217 args = () 11218 kwargs = {} 11219 signature = inspect.signature(toCall) 11220 # TODO: Maybe try some type-checking here? 11221 for argName, param in signature.parameters.items(): 11222 if param.kind == inspect.Parameter.VAR_POSITIONAL: 11223 if argName in scope: 11224 args = args + tuple(scope[argName]) 11225 # Else leave args as-is 11226 elif param.kind == inspect.Parameter.KEYWORD_ONLY: 11227 # These must have a default 11228 if argName in scope: 11229 kwargs[argName] = scope[argName] 11230 elif param.kind == inspect.Parameter.VAR_KEYWORD: 11231 # treat as a dictionary 11232 if argName in scope: 11233 argsToUse = scope[argName] 11234 if not isinstance(argsToUse, dict): 11235 raise TypeError( 11236 f"Variable '{argName}' must" 11237 f" hold a dictionary when" 11238 f" calling function" 11239 f" '{toCall.__name__} which" 11240 f" uses that argument as a" 11241 f" keyword catchall." 11242 ) 11243 kwargs.update(scope[argName]) 11244 else: # a normal parameter 11245 if argName in scope: 11246 args = args + (scope[argName],) 11247 elif param.default == inspect.Parameter.empty: 11248 raise TypeError( 11249 f"No variable named '{argName}' has" 11250 f" been defined to supply the" 11251 f" required parameter with that" 11252 f" name for function" 11253 f" '{toCall.__name__}'." 11254 ) 11255 11256 result = toCall(*args, **kwargs) 11257 commands.pushCurrentValue(scope, result) 11258 11259 elif command.command == 'skip': 11260 command = cast(commands.SkipCommands, command) 11261 doIt = commands.resolveValue(command.condition, scope) 11262 if doIt: 11263 skip = commands.resolveValue(command.amount, scope) 11264 if not isinstance(skip, (int, str)): 11265 raise TypeError( 11266 f"Skip amount must be an integer or a label" 11267 f" name (got {skip!r})." 11268 ) 11269 11270 elif command.command == 'label': 11271 command = cast(commands.Label, command) 11272 label = commands.resolveValue(command.name, scope) 11273 if not isinstance(label, str): 11274 raise TypeError( 11275 f"Label name must be a string (got {label!r})." 11276 ) 11277 11278 else: 11279 raise ValueError( 11280 f"Invalid command type: {command.command!r}" 11281 ) 11282 except ValueError as e: 11283 raise commands.CommandValueError(command, line, e) 11284 except TypeError as e: 11285 raise commands.CommandTypeError(command, line, e) 11286 except IndexError as e: 11287 raise commands.CommandIndexError(command, line, e) 11288 except KeyError as e: 11289 raise commands.CommandKeyError(command, line, e) 11290 except Exception as e: 11291 raise commands.CommandOtherError(command, line, e) 11292 11293 return (scope, skip, label) 11294 11295 def runCommandBlock( 11296 self, 11297 block: List[commands.Command], 11298 scope: Optional[commands.Scope] = None 11299 ) -> commands.Scope: 11300 """ 11301 Runs a list of commands, using the given scope (or creating a new 11302 empty scope if none was provided). Returns the scope after 11303 running all of the commands, which may also edit the exploration 11304 and/or the current graph of course. 11305 11306 Note that if a skip command would skip past the end of the 11307 block, execution will end. If a skip command would skip before 11308 the beginning of the block, execution will start from the first 11309 command. 11310 11311 Example: 11312 11313 >>> e = DiscreteExploration() 11314 >>> scope = e.runCommandBlock([ 11315 ... commands.command('assign', 'decision', "'START'"), 11316 ... commands.command('call', 'exploration', 'start'), 11317 ... commands.command('assign', 'where', '$decision'), 11318 ... commands.command('assign', 'transition', "'left'"), 11319 ... commands.command('call', 'exploration', 'observe'), 11320 ... commands.command('assign', 'transition', "'right'"), 11321 ... commands.command('call', 'exploration', 'observe'), 11322 ... commands.command('call', 'graph', 'destinationsFrom'), 11323 ... commands.command('call', 'builtin', 'print'), 11324 ... commands.command('assign', 'transition', "'right'"), 11325 ... commands.command('assign', 'destination', "'EastRoom'"), 11326 ... commands.command('call', 'exploration', 'explore'), 11327 ... ]) 11328 {'left': 1, 'right': 2} 11329 >>> scope['decision'] 11330 'START' 11331 >>> scope['where'] 11332 'START' 11333 >>> scope['_'] # result of 'explore' call is dest ID 11334 2 11335 >>> scope['transition'] 11336 'right' 11337 >>> scope['destination'] 11338 'EastRoom' 11339 >>> g = e.getSituation().graph 11340 >>> len(e) 11341 3 11342 >>> len(g) 11343 3 11344 >>> g.namesListing(g) 11345 ' 0 (START)\\n 1 (_u.0)\\n 2 (EastRoom)\\n' 11346 """ 11347 if scope is None: 11348 scope = {} 11349 11350 labelPositions: Dict[str, List[int]] = {} 11351 11352 # Keep going until we've exhausted the commands list 11353 index = 0 11354 while index < len(block): 11355 11356 # Execute the next command 11357 scope, skip, label = self.runCommand( 11358 block[index], 11359 scope, 11360 index + 1 11361 ) 11362 11363 # Increment our index, or apply a skip 11364 if skip is None: 11365 index = index + 1 11366 11367 elif isinstance(skip, int): # Integer skip value 11368 if skip < 0: 11369 index += skip 11370 if index < 0: # can't skip before the start 11371 index = 0 11372 else: 11373 index += skip + 1 # may end loop if we skip too far 11374 11375 else: # must be a label name 11376 if skip in labelPositions: # an established label 11377 # We jump to the last previous index, or if there 11378 # are none, to the first future index. 11379 prevIndices = [ 11380 x 11381 for x in labelPositions[skip] 11382 if x < index 11383 ] 11384 futureIndices = [ 11385 x 11386 for x in labelPositions[skip] 11387 if x >= index 11388 ] 11389 if len(prevIndices) > 0: 11390 index = max(prevIndices) 11391 else: 11392 index = min(futureIndices) 11393 else: # must be a forward-reference 11394 for future in range(index + 1, len(block)): 11395 inspect = block[future] 11396 if inspect.command == 'label': 11397 inspect = cast(commands.Label, inspect) 11398 if inspect.name == skip: 11399 index = future 11400 break 11401 else: 11402 raise KeyError( 11403 f"Skip command indicated a jump to label" 11404 f" {skip!r} but that label had not already" 11405 f" been defined and there is no future" 11406 f" label with that name either (future" 11407 f" labels based on variables cannot be" 11408 f" skipped to from above as their names" 11409 f" are not known yet)." 11410 ) 11411 11412 # If there's a label, record it 11413 if label is not None: 11414 labelPositions.setdefault(label, []).append(index) 11415 11416 # And now the while loop continues, or ends if we're at the 11417 # end of the commands list. 11418 11419 # Return the scope object. 11420 return scope 11421 11422 @staticmethod 11423 def example() -> 'DiscreteExploration': 11424 """ 11425 Returns a little example exploration. Has a few decisions 11426 including one that's unexplored, and uses a few steps to explore 11427 them. 11428 11429 >>> e = DiscreteExploration.example() 11430 >>> len(e) 11431 7 11432 >>> def pg(n): 11433 ... print(e[n].graph.namesListing(e[n].graph)) 11434 >>> pg(0) 11435 0 (House) 11436 <BLANKLINE> 11437 >>> pg(1) 11438 0 (House) 11439 1 (_u.0) 11440 2 (_u.1) 11441 3 (_u.2) 11442 <BLANKLINE> 11443 >>> pg(2) 11444 0 (House) 11445 1 (_u.0) 11446 2 (_u.1) 11447 3 (Yard) 11448 4 (_u.3) 11449 5 (_u.4) 11450 <BLANKLINE> 11451 >>> pg(3) 11452 0 (House) 11453 1 (_u.0) 11454 2 (_u.1) 11455 3 (Yard) 11456 4 (_u.3) 11457 5 (_u.4) 11458 <BLANKLINE> 11459 >>> pg(4) 11460 0 (House) 11461 1 (_u.0) 11462 2 (Cellar) 11463 3 (Yard) 11464 5 (_u.4) 11465 <BLANKLINE> 11466 >>> pg(5) 11467 0 (House) 11468 1 (_u.0) 11469 2 (Cellar) 11470 3 (Yard) 11471 5 (_u.4) 11472 <BLANKLINE> 11473 >>> pg(6) 11474 0 (House) 11475 1 (_u.0) 11476 2 (Cellar) 11477 3 (Yard) 11478 5 (Lane) 11479 <BLANKLINE> 11480 """ 11481 result = DiscreteExploration() 11482 result.start("House") 11483 result.observeAll("House", "ladder", "stairsDown", "frontDoor") 11484 result.explore("frontDoor", "Yard", "frontDoor") 11485 result.observe("Yard", "cellarDoors") 11486 result.observe("Yard", "frontGate") 11487 result.retrace("frontDoor") 11488 result.explore("stairsDown", "Cellar", "stairsUp") 11489 result.observe("Cellar", "stairsOut") 11490 result.returnTo("stairsOut", "Yard", "cellarDoors") 11491 result.explore("frontGate", "Lane", "redGate") 11492 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.Layout
s 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
6617 @staticmethod 6618 def fromGraph( 6619 graph: DecisionGraph, 6620 state: Optional[base.State] = None 6621 ) -> 'DiscreteExploration': 6622 """ 6623 Creates an exploration which has just a single step whose graph 6624 is the entire specified graph, with the specified decision as 6625 the primary decision (if any). The graph is copied, so that 6626 changes to the exploration will not modify it. A starting state 6627 may also be specified if desired, although if not an empty state 6628 will be used (a provided starting state is NOT copied, but used 6629 directly). 6630 6631 Example: 6632 6633 >>> g = DecisionGraph() 6634 >>> g.addDecision('Room1') 6635 0 6636 >>> g.addDecision('Room2') 6637 1 6638 >>> g.addTransition('Room1', 'door', 'Room2', 'door') 6639 >>> e = DiscreteExploration.fromGraph(g) 6640 >>> len(e) 6641 1 6642 >>> e.getSituation().graph == g 6643 True 6644 >>> e.getActiveDecisions() 6645 set() 6646 >>> e.primaryDecision() is None 6647 True 6648 >>> e.observe('Room1', 'hatch') 6649 2 6650 >>> e.getSituation().graph == g 6651 False 6652 >>> e.getSituation().graph.destinationsFrom('Room1') 6653 {'door': 1, 'hatch': 2} 6654 >>> g.destinationsFrom('Room1') 6655 {'door': 1} 6656 """ 6657 result = DiscreteExploration() 6658 result.situations[0] = base.Situation( 6659 graph=copy.deepcopy(graph), 6660 state=base.emptyState() if state is None else state, 6661 type='pending', 6662 action=None, 6663 saves={}, 6664 tags={}, 6665 annotations=[] 6666 ) 6667 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}
6688 def getSituation(self, step: int = -1) -> base.Situation: 6689 """ 6690 Returns a `base.Situation` named tuple detailing the state of 6691 the exploration at a given step (or at the current step if no 6692 argument is given). Note that this method works the same 6693 way as indexing the exploration: see `__getitem__`. 6694 6695 Raises an `IndexError` if asked for a step that's out-of-range. 6696 """ 6697 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.
6699 def primaryDecision(self, step: int = -1) -> Optional[base.DecisionID]: 6700 """ 6701 Returns the current primary `base.DecisionID`, or the primary 6702 decision from a specific step if one is specified. This may be 6703 `None` for some steps, but mostly it's the destination of the 6704 transition taken in the previous step. 6705 """ 6706 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.
6708 def effectiveCapabilities( 6709 self, 6710 step: int = -1 6711 ) -> base.CapabilitySet: 6712 """ 6713 Returns the effective capability set for the specified step 6714 (default is the last/current step). See 6715 `base.effectiveCapabilities`. 6716 """ 6717 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
.
6719 def getCommonContext( 6720 self, 6721 step: Optional[int] = None 6722 ) -> base.FocalContext: 6723 """ 6724 Returns the common `FocalContext` at the specified step, or at 6725 the current step if no argument is given. Raises an `IndexError` 6726 if an invalid step is specified. 6727 """ 6728 if step is None: 6729 step = -1 6730 state = self.getSituation(step).state 6731 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.
6733 def getActiveContext( 6734 self, 6735 step: Optional[int] = None 6736 ) -> base.FocalContext: 6737 """ 6738 Returns the active `FocalContext` at the specified step, or at 6739 the current step if no argument is provided. Raises an 6740 `IndexError` if an invalid step is specified. 6741 """ 6742 if step is None: 6743 step = -1 6744 state = self.getSituation(step).state 6745 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.
6747 def addFocalContext(self, name: base.FocalContextName) -> None: 6748 """ 6749 Adds a new empty focal context to our set of focal contexts (see 6750 `emptyFocalContext`). Use `setActiveContext` to swap to it. 6751 Raises a `FocalContextCollisionError` if the name is already in 6752 use. 6753 """ 6754 contextMap = self.getSituation().state['contexts'] 6755 if name in contextMap: 6756 raise FocalContextCollisionError( 6757 f"Cannot add focal context {name!r}: a focal context" 6758 f" with that name already exists." 6759 ) 6760 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.
6762 def setActiveContext(self, which: base.FocalContextName) -> None: 6763 """ 6764 Sets the active context to the named focal context, creating it 6765 if it did not already exist (makes changes to the current 6766 situation only). Does not add an exploration step (use 6767 `advanceSituation` with a 'swap' action for that). 6768 """ 6769 state = self.getSituation().state 6770 contextMap = state['contexts'] 6771 if which not in contextMap: 6772 self.addFocalContext(which) 6773 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).
6775 def createDomain( 6776 self, 6777 name: base.Domain, 6778 focalization: base.DomainFocalization = 'singular', 6779 makeActive: bool = False, 6780 inCommon: Union[bool, Literal["both"]] = "both" 6781 ) -> None: 6782 """ 6783 Creates a new domain with the given focalization type, in either 6784 the common context (`inCommon` = `True`) the active context 6785 (`inCommon` = `False`) or both (the default; `inCommon` = 'both'). 6786 The domain's focalization will be set to the given 6787 `focalization` value (default 'singular') and it will have no 6788 active decisions. Raises a `DomainCollisionError` if a domain 6789 with the specified name already exists. 6790 6791 Creates the domain in the current situation. 6792 6793 If `makeActive` is set to `True` (default is `False`) then the 6794 domain will be made active in whichever context(s) it's created 6795 in. 6796 """ 6797 now = self.getSituation() 6798 state = now.state 6799 modify = [] 6800 if inCommon in (True, "both"): 6801 modify.append(('common', state['common'])) 6802 if inCommon in (False, "both"): 6803 acName = state['activeContext'] 6804 modify.append( 6805 ('current ({repr(acName)})', state['contexts'][acName]) 6806 ) 6807 6808 for (fcType, fc) in modify: 6809 if name in fc['focalization']: 6810 raise DomainCollisionError( 6811 f"Cannot create domain {repr(name)} because a" 6812 f" domain with that name already exists in the" 6813 f" {fcType} focal context." 6814 ) 6815 fc['focalization'][name] = focalization 6816 if makeActive: 6817 fc['activeDomains'].add(name) 6818 if focalization == "spreading": 6819 fc['activeDecisions'][name] = set() 6820 elif focalization == "plural": 6821 fc['activeDecisions'][name] = {} 6822 else: 6823 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.
6825 def activateDomain( 6826 self, 6827 domain: base.Domain, 6828 activate: bool = True, 6829 inContext: base.ContextSpecifier = "active" 6830 ) -> None: 6831 """ 6832 Sets the given domain as active (or inactive if 'activate' is 6833 given as `False`) in the specified context (default "active"). 6834 6835 Modifies the current situation. 6836 """ 6837 fc: base.FocalContext 6838 if inContext == "active": 6839 fc = self.getActiveContext() 6840 elif inContext == "common": 6841 fc = self.getCommonContext() 6842 6843 if activate: 6844 fc['activeDomains'].add(domain) 6845 else: 6846 try: 6847 fc['activeDomains'].remove(domain) 6848 except KeyError: 6849 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.
6851 def createTriggerGroup( 6852 self, 6853 name: base.DecisionName 6854 ) -> base.DecisionID: 6855 """ 6856 Creates a new trigger group with the given name, returning the 6857 decision ID for that trigger group. If this is the first trigger 6858 group being created, also creates the `TRIGGERS_DOMAIN` domain 6859 as a spreading-focalized domain that's active in the common 6860 context (but does NOT set the created trigger group as an active 6861 decision in that domain). 6862 6863 You can use 'goto' effects to activate trigger domains via 6864 consequences, and 'retreat' effects to deactivate them. 6865 6866 Creating a second trigger group with the same name as another 6867 results in a `ValueError`. 6868 6869 TODO: Retreat effects 6870 """ 6871 ctx = self.getCommonContext() 6872 if TRIGGERS_DOMAIN not in ctx['focalization']: 6873 self.createDomain( 6874 TRIGGERS_DOMAIN, 6875 focalization='spreading', 6876 makeActive=True, 6877 inCommon=True 6878 ) 6879 6880 graph = self.getSituation().graph 6881 if graph.getDecision( 6882 base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name) 6883 ) is not None: 6884 raise ValueError( 6885 f"Cannot create trigger group {name!r}: a trigger group" 6886 f" with that name already exists." 6887 ) 6888 6889 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
6891 def toggleTriggerGroup( 6892 self, 6893 name: base.DecisionName, 6894 setActive: Union[bool, None] = None 6895 ): 6896 """ 6897 Toggles whether the specified trigger group (a decision in the 6898 `TRIGGERS_DOMAIN`) is active or not. Pass `True` or `False` as 6899 the `setActive` argument (instead of the default `None`) to set 6900 the state directly instead of toggling it. 6901 6902 Note that trigger groups are decisions in a spreading-focalized 6903 domain, so they can be activated or deactivated by the 'goto' 6904 and 'retreat' effects as well. 6905 6906 This does not affect whether the `TRIGGERS_DOMAIN` itself is 6907 active (normally it would always be active). 6908 6909 Raises a `MissingDecisionError` if the specified trigger group 6910 does not exist yet, including when the entire `TRIGGERS_DOMAIN` 6911 does not exist. Raises a `KeyError` if the target group exists 6912 but the `TRIGGERS_DOMAIN` has not been set up properly. 6913 """ 6914 ctx = self.getCommonContext() 6915 tID = self.getSituation().graph.resolveDecision( 6916 base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name) 6917 ) 6918 activeGroups = ctx['activeDecisions'][TRIGGERS_DOMAIN] 6919 assert isinstance(activeGroups, set) 6920 if tID in activeGroups: 6921 if setActive is not True: 6922 activeGroups.remove(tID) 6923 else: 6924 if setActive is not False: 6925 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.
6927 def getActiveDecisions( 6928 self, 6929 step: Optional[int] = None, 6930 inCommon: Union[bool, Literal["both"]] = "both" 6931 ) -> Set[base.DecisionID]: 6932 """ 6933 Returns the set of active decisions at the given step index, or 6934 at the current step if no step is specified. Raises an 6935 `IndexError` if the step index is out of bounds (see `__len__`). 6936 May return an empty set if no decisions are active. 6937 6938 If `inCommon` is set to "both" (the default) then decisions 6939 active in either the common or active context are returned. Set 6940 it to `True` or `False` to return only decisions active in the 6941 common (when `True`) or active (when `False`) context. 6942 """ 6943 if step is None: 6944 step = -1 6945 state = self.getSituation(step).state 6946 if inCommon == "both": 6947 return base.combinedDecisionSet(state) 6948 elif inCommon is True: 6949 return base.activeDecisionSet(state['common']) 6950 elif inCommon is False: 6951 return base.activeDecisionSet( 6952 state['contexts'][state['activeContext']] 6953 ) 6954 else: 6955 raise ValueError( 6956 f"Invalid inCommon value {repr(inCommon)} (must be" 6957 f" 'both', True, or False)." 6958 )
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.
6960 def setActiveDecisionsAtStep( 6961 self, 6962 step: int, 6963 domain: base.Domain, 6964 activate: Union[ 6965 base.DecisionID, 6966 Dict[base.FocalPointName, Optional[base.DecisionID]], 6967 Set[base.DecisionID] 6968 ], 6969 inCommon: bool = False 6970 ) -> None: 6971 """ 6972 Changes the activation status of decisions in the active 6973 `FocalContext` at the specified step, for the specified domain 6974 (see `currentActiveContext`). Does this without adding an 6975 exploration step, which is unusual: normally you should use 6976 another method like `warp` to update active decisions. 6977 6978 Note that this does not change which domains are active, and 6979 setting active decisions in inactive domains does not make those 6980 decisions active overall. 6981 6982 Which decisions to activate or deactivate are specified as 6983 either a single `DecisionID`, a list of them, or a set of them, 6984 depending on the `DomainFocalization` setting in the selected 6985 `FocalContext` for the specified domain. A `TypeError` will be 6986 raised if the wrong kind of decision information is provided. If 6987 the focalization context does not have any focalization value for 6988 the domain in question, it will be set based on the kind of 6989 active decision information specified. 6990 6991 A `MissingDecisionError` will be raised if a decision is 6992 included which is not part of the current `DecisionGraph`. 6993 The provided information will overwrite the previous active 6994 decision information. 6995 6996 If `inCommon` is set to `True`, then decisions are activated or 6997 deactivated in the common context, instead of in the active 6998 context. 6999 7000 Example: 7001 7002 >>> e = DiscreteExploration() 7003 >>> e.getActiveDecisions() 7004 set() 7005 >>> graph = e.getSituation().graph 7006 >>> graph.addDecision('A') 7007 0 7008 >>> graph.addDecision('B') 7009 1 7010 >>> graph.addDecision('C') 7011 2 7012 >>> e.setActiveDecisionsAtStep(0, 'main', 0) 7013 >>> e.getActiveDecisions() 7014 {0} 7015 >>> e.setActiveDecisionsAtStep(0, 'main', 1) 7016 >>> e.getActiveDecisions() 7017 {1} 7018 >>> graph = e.getSituation().graph 7019 >>> graph.addDecision('One', domain='numbers') 7020 3 7021 >>> graph.addDecision('Two', domain='numbers') 7022 4 7023 >>> graph.addDecision('Three', domain='numbers') 7024 5 7025 >>> graph.addDecision('Bear', domain='animals') 7026 6 7027 >>> graph.addDecision('Spider', domain='animals') 7028 7 7029 >>> graph.addDecision('Eel', domain='animals') 7030 8 7031 >>> ac = e.getActiveContext() 7032 >>> ac['focalization']['numbers'] = 'plural' 7033 >>> ac['focalization']['animals'] = 'spreading' 7034 >>> ac['activeDecisions']['numbers'] = {'a': None, 'b': None} 7035 >>> ac['activeDecisions']['animals'] = set() 7036 >>> cc = e.getCommonContext() 7037 >>> cc['focalization']['numbers'] = 'plural' 7038 >>> cc['focalization']['animals'] = 'spreading' 7039 >>> cc['activeDecisions']['numbers'] = {'z': None} 7040 >>> cc['activeDecisions']['animals'] = set() 7041 >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 3, 'b': 3}) 7042 >>> e.getActiveDecisions() 7043 {1} 7044 >>> e.activateDomain('numbers') 7045 >>> e.getActiveDecisions() 7046 {1, 3} 7047 >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 4, 'b': None}) 7048 >>> e.getActiveDecisions() 7049 {1, 4} 7050 >>> # Wrong domain for the decision ID: 7051 >>> e.setActiveDecisionsAtStep(0, 'main', 3) 7052 Traceback (most recent call last): 7053 ... 7054 ValueError... 7055 >>> # Wrong domain for one of the decision IDs: 7056 >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 2, 'b': None}) 7057 Traceback (most recent call last): 7058 ... 7059 ValueError... 7060 >>> # Wrong kind of decision information provided. 7061 >>> e.setActiveDecisionsAtStep(0, 'numbers', 3) 7062 Traceback (most recent call last): 7063 ... 7064 TypeError... 7065 >>> e.getActiveDecisions() 7066 {1, 4} 7067 >>> e.setActiveDecisionsAtStep(0, 'animals', {6, 7}) 7068 >>> e.getActiveDecisions() 7069 {1, 4} 7070 >>> e.activateDomain('animals') 7071 >>> e.getActiveDecisions() 7072 {1, 4, 6, 7} 7073 >>> e.setActiveDecisionsAtStep(0, 'animals', {8}) 7074 >>> e.getActiveDecisions() 7075 {8, 1, 4} 7076 >>> e.setActiveDecisionsAtStep(1, 'main', 2) # invalid step 7077 Traceback (most recent call last): 7078 ... 7079 IndexError... 7080 >>> e.setActiveDecisionsAtStep(0, 'novel', 0) # domain mismatch 7081 Traceback (most recent call last): 7082 ... 7083 ValueError... 7084 7085 Example of active/common contexts: 7086 7087 >>> e = DiscreteExploration() 7088 >>> graph = e.getSituation().graph 7089 >>> graph.addDecision('A') 7090 0 7091 >>> graph.addDecision('B') 7092 1 7093 >>> e.activateDomain('main', inContext="common") 7094 >>> e.setActiveDecisionsAtStep(0, 'main', 0, inCommon=True) 7095 >>> e.getActiveDecisions() 7096 {0} 7097 >>> e.setActiveDecisionsAtStep(0, 'main', None) 7098 >>> e.getActiveDecisions() 7099 {0} 7100 >>> # (Still active since it's active in the common context) 7101 >>> e.setActiveDecisionsAtStep(0, 'main', 1) 7102 >>> e.getActiveDecisions() 7103 {0, 1} 7104 >>> e.setActiveDecisionsAtStep(0, 'main', 1, inCommon=True) 7105 >>> e.getActiveDecisions() 7106 {1} 7107 >>> e.setActiveDecisionsAtStep(0, 'main', None, inCommon=True) 7108 >>> e.getActiveDecisions() 7109 {1} 7110 >>> # (Still active since it's active in the active context) 7111 >>> e.setActiveDecisionsAtStep(0, 'main', None) 7112 >>> e.getActiveDecisions() 7113 set() 7114 """ 7115 now = self.getSituation(step) 7116 graph = now.graph 7117 if inCommon: 7118 context = self.getCommonContext(step) 7119 else: 7120 context = self.getActiveContext(step) 7121 7122 defaultFocalization: base.DomainFocalization = 'singular' 7123 if isinstance(activate, base.DecisionID): 7124 defaultFocalization = 'singular' 7125 elif isinstance(activate, dict): 7126 defaultFocalization = 'plural' 7127 elif isinstance(activate, set): 7128 defaultFocalization = 'spreading' 7129 elif domain not in context['focalization']: 7130 raise TypeError( 7131 f"Domain {domain!r} has no focalization in the" 7132 f" {'common' if inCommon else 'active'} context," 7133 f" and the specified position doesn't imply one." 7134 ) 7135 7136 focalization = base.getDomainFocalization( 7137 context, 7138 domain, 7139 defaultFocalization 7140 ) 7141 7142 # Check domain & existence of decision(s) in question 7143 if activate is None: 7144 pass 7145 elif isinstance(activate, base.DecisionID): 7146 if activate not in graph: 7147 raise MissingDecisionError( 7148 f"There is no decision {activate} at step {step}." 7149 ) 7150 if graph.domainFor(activate) != domain: 7151 raise ValueError( 7152 f"Can't set active decisions in domain {domain!r}" 7153 f" to decision {graph.identityOf(activate)} because" 7154 f" that decision is in actually in domain" 7155 f" {graph.domainFor(activate)!r}." 7156 ) 7157 elif isinstance(activate, dict): 7158 for fpName, pos in activate.items(): 7159 if pos is None: 7160 continue 7161 if pos not in graph: 7162 raise MissingDecisionError( 7163 f"There is no decision {pos} at step {step}." 7164 ) 7165 if graph.domainFor(pos) != domain: 7166 raise ValueError( 7167 f"Can't set active decision for focal point" 7168 f" {fpName!r} in domain {domain!r}" 7169 f" to decision {graph.identityOf(pos)} because" 7170 f" that decision is in actually in domain" 7171 f" {graph.domainFor(pos)!r}." 7172 ) 7173 elif isinstance(activate, set): 7174 for pos in activate: 7175 if pos not in graph: 7176 raise MissingDecisionError( 7177 f"There is no decision {pos} at step {step}." 7178 ) 7179 if graph.domainFor(pos) != domain: 7180 raise ValueError( 7181 f"Can't set {graph.identityOf(pos)} as an" 7182 f" active decision in domain {domain!r} to" 7183 f" decision because that decision is in" 7184 f" actually in domain {graph.domainFor(pos)!r}." 7185 ) 7186 else: 7187 raise TypeError( 7188 f"Domain {domain!r} has no focalization in the" 7189 f" {'common' if inCommon else 'active'} context," 7190 f" and the specified position doesn't imply one:" 7191 f"\n{activate!r}" 7192 ) 7193 7194 if focalization == 'singular': 7195 if activate is None or isinstance(activate, base.DecisionID): 7196 if activate is not None: 7197 targetDomain = graph.domainFor(activate) 7198 if activate not in graph: 7199 raise MissingDecisionError( 7200 f"There is no decision {activate} in the" 7201 f" graph at step {step}." 7202 ) 7203 elif targetDomain != domain: 7204 raise ValueError( 7205 f"At step {step}, decision {activate} cannot" 7206 f" be the active decision for domain" 7207 f" {repr(domain)} because it is in a" 7208 f" different domain ({repr(targetDomain)})." 7209 ) 7210 context['activeDecisions'][domain] = activate 7211 else: 7212 raise TypeError( 7213 f"{'Common' if inCommon else 'Active'} focal" 7214 f" context at step {step} has {repr(focalization)}" 7215 f" focalization for domain {repr(domain)}, so the" 7216 f" active decision must be a single decision or" 7217 f" None.\n(You provided: {repr(activate)})" 7218 ) 7219 elif focalization == 'plural': 7220 if ( 7221 isinstance(activate, dict) 7222 and all( 7223 isinstance(k, base.FocalPointName) 7224 for k in activate.keys() 7225 ) 7226 and all( 7227 v is None or isinstance(v, base.DecisionID) 7228 for v in activate.values() 7229 ) 7230 ): 7231 for v in activate.values(): 7232 if v is not None: 7233 targetDomain = graph.domainFor(v) 7234 if v not in graph: 7235 raise MissingDecisionError( 7236 f"There is no decision {v} in the graph" 7237 f" at step {step}." 7238 ) 7239 elif targetDomain != domain: 7240 raise ValueError( 7241 f"At step {step}, decision {activate}" 7242 f" cannot be an active decision for" 7243 f" domain {repr(domain)} because it is" 7244 f" in a different domain" 7245 f" ({repr(targetDomain)})." 7246 ) 7247 context['activeDecisions'][domain] = activate 7248 else: 7249 raise TypeError( 7250 f"{'Common' if inCommon else 'Active'} focal" 7251 f" context at step {step} has {repr(focalization)}" 7252 f" focalization for domain {repr(domain)}, so the" 7253 f" active decision must be a dictionary mapping" 7254 f" focal point names to decision IDs (or Nones)." 7255 f"\n(You provided: {repr(activate)})" 7256 ) 7257 elif focalization == 'spreading': 7258 if ( 7259 isinstance(activate, set) 7260 and all(isinstance(x, base.DecisionID) for x in activate) 7261 ): 7262 for x in activate: 7263 targetDomain = graph.domainFor(x) 7264 if x not in graph: 7265 raise MissingDecisionError( 7266 f"There is no decision {x} in the graph" 7267 f" at step {step}." 7268 ) 7269 elif targetDomain != domain: 7270 raise ValueError( 7271 f"At step {step}, decision {activate}" 7272 f" cannot be an active decision for" 7273 f" domain {repr(domain)} because it is" 7274 f" in a different domain" 7275 f" ({repr(targetDomain)})." 7276 ) 7277 context['activeDecisions'][domain] = activate 7278 else: 7279 raise TypeError( 7280 f"{'Common' if inCommon else 'Active'} focal" 7281 f" context at step {step} has {repr(focalization)}" 7282 f" focalization for domain {repr(domain)}, so the" 7283 f" active decision must be a set of decision IDs" 7284 f"\n(You provided: {repr(activate)})" 7285 ) 7286 else: 7287 raise RuntimeError( 7288 f"Invalid focalization value {repr(focalization)} for" 7289 f" domain {repr(domain)} at step {step}." 7290 )
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()
7292 def movementAtStep(self, step: int = -1) -> Tuple[ 7293 Union[base.DecisionID, Set[base.DecisionID], None], 7294 Optional[base.Transition], 7295 Union[base.DecisionID, Set[base.DecisionID], None] 7296 ]: 7297 """ 7298 Given a step number, returns information about the starting 7299 decision, transition taken, and destination decision for that 7300 step. Not all steps have all of those, so some items may be 7301 `None`. 7302 7303 For steps where there is no action, where a decision is still 7304 pending, or where the action type is 'focus', 'swap', 'focalize', 7305 or 'revertTo', the result will be `(None, None, None)`, unless a 7306 primary decision is available in which case the first item in the 7307 tuple will be that decision. For 'start' actions, the starting 7308 position and transition will be `None` (again unless the step had 7309 a primary decision) but the destination will be the ID of the 7310 node started at. For 'revertTo' actions, the destination will be 7311 the primary decision of the state reverted to, if available. 7312 7313 Also, if the action taken has multiple potential or actual start 7314 or end points, these may be sets of decision IDs instead of 7315 single IDs. 7316 7317 Note that the primary decision of the starting state is usually 7318 used as the from-decision, but in some cases an action dictates 7319 taking a transition from a different decision, and this function 7320 will return that decision as the from-decision. 7321 7322 TODO: Examples! 7323 7324 TODO: Account for bounce/follow/goto effects!!! 7325 """ 7326 now = self.getSituation(step) 7327 action = now.action 7328 graph = now.graph 7329 primary = now.state['primaryDecision'] 7330 7331 if action is None: 7332 return (primary, None, None) 7333 7334 aType = action[0] 7335 fromID: Optional[base.DecisionID] 7336 destID: Optional[base.DecisionID] 7337 transition: base.Transition 7338 outcomes: List[bool] 7339 7340 if aType in ('noAction', 'focus', 'swap', 'focalize'): 7341 return (primary, None, None) 7342 elif aType == 'start': 7343 assert len(action) == 7 7344 where = cast( 7345 Union[ 7346 base.DecisionID, 7347 Dict[base.FocalPointName, base.DecisionID], 7348 Set[base.DecisionID] 7349 ], 7350 action[1] 7351 ) 7352 if isinstance(where, dict): 7353 where = set(where.values()) 7354 return (primary, None, where) 7355 elif aType in ('take', 'explore'): 7356 if ( 7357 (len(action) == 4 or len(action) == 7) 7358 and isinstance(action[2], base.DecisionID) 7359 ): 7360 fromID = action[2] 7361 assert isinstance(action[3], tuple) 7362 transition, outcomes = action[3] 7363 if ( 7364 action[0] == "explore" 7365 and isinstance(action[4], base.DecisionID) 7366 ): 7367 destID = action[4] 7368 else: 7369 destID = graph.getDestination(fromID, transition) 7370 return (fromID, transition, destID) 7371 elif ( 7372 (len(action) == 3 or len(action) == 6) 7373 and isinstance(action[1], tuple) 7374 and isinstance(action[2], base.Transition) 7375 and len(action[1]) == 3 7376 and action[1][0] in get_args(base.ContextSpecifier) 7377 and isinstance(action[1][1], base.Domain) 7378 and isinstance(action[1][2], base.FocalPointName) 7379 ): 7380 fromID = base.resolvePosition(now, action[1]) 7381 if fromID is None: 7382 raise InvalidActionError( 7383 f"{aType!r} action at step {step} has position" 7384 f" {action[1]!r} which cannot be resolved to a" 7385 f" decision." 7386 ) 7387 transition, outcomes = action[2] 7388 if ( 7389 action[0] == "explore" 7390 and isinstance(action[3], base.DecisionID) 7391 ): 7392 destID = action[3] 7393 else: 7394 destID = graph.getDestination(fromID, transition) 7395 return (fromID, transition, destID) 7396 else: 7397 raise InvalidActionError( 7398 f"Malformed {aType!r} action:\n{repr(action)}" 7399 ) 7400 elif aType == 'warp': 7401 if len(action) != 3: 7402 raise InvalidActionError( 7403 f"Malformed 'warp' action:\n{repr(action)}" 7404 ) 7405 dest = action[2] 7406 assert isinstance(dest, base.DecisionID) 7407 if action[1] in get_args(base.ContextSpecifier): 7408 # Unspecified starting point; find active decisions in 7409 # same domain if primary is None 7410 if primary is not None: 7411 return (primary, None, dest) 7412 else: 7413 toDomain = now.graph.domainFor(dest) 7414 # TODO: Could check destination focalization here... 7415 active = self.getActiveDecisions(step) 7416 sameDomain = set( 7417 dID 7418 for dID in active 7419 if now.graph.domainFor(dID) == toDomain 7420 ) 7421 if len(sameDomain) == 1: 7422 return ( 7423 list(sameDomain)[0], 7424 None, 7425 dest 7426 ) 7427 else: 7428 return ( 7429 sameDomain, 7430 None, 7431 dest 7432 ) 7433 else: 7434 if ( 7435 not isinstance(action[1], tuple) 7436 or not len(action[1]) == 3 7437 or not action[1][0] in get_args(base.ContextSpecifier) 7438 or not isinstance(action[1][1], base.Domain) 7439 or not isinstance(action[1][2], base.FocalPointName) 7440 ): 7441 raise InvalidActionError( 7442 f"Malformed 'warp' action:\n{repr(action)}" 7443 ) 7444 return ( 7445 base.resolvePosition(now, action[1]), 7446 None, 7447 dest 7448 ) 7449 elif aType == 'revertTo': 7450 assert len(action) == 3 # type, save slot, & aspects 7451 if primary is not None: 7452 cameFrom = primary 7453 nextSituation = self.getSituation(step + 1) 7454 wentTo = nextSituation.state['primaryDecision'] 7455 return (primary, None, wentTo) 7456 else: 7457 raise InvalidActionError( 7458 f"Action taken had invalid action type {repr(aType)}:" 7459 f"\n{repr(action)}" 7460 )
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!!!
7462 def latestStepWithDecision( 7463 self, 7464 dID: base.DecisionID, 7465 startFrom: int = -1 7466 ) -> int: 7467 """ 7468 Scans backwards through exploration steps until it finds a graph 7469 that contains a decision with the specified ID, and returns the 7470 step number of that step. Instead of starting from the last step, 7471 you can tell it to start from a different step (either positive 7472 or negative index) via `startFrom`. Raises a 7473 `MissingDecisionError` if there is no such step. 7474 """ 7475 if startFrom < 0: 7476 startFrom = len(self) + startFrom 7477 for step in range(startFrom, -1, -1): 7478 graph = self.getSituation(step).graph 7479 try: 7480 return step 7481 except MissingDecisionError: 7482 continue 7483 raise MissingDecisionError( 7484 f"Decision {dID!r} does not exist at any step of the" 7485 f" exploration." 7486 )
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.
7488 def latestDecisionInfo(self, dID: base.DecisionID) -> DecisionInfo: 7489 """ 7490 Looks up decision info for the given decision in the latest step 7491 in which that decision exists (which will usually be the final 7492 exploration step, unless the decision was merged or otherwise 7493 removed along the way). This will raise a `MissingDecisionError` 7494 only if there is no step at which the specified decision exists. 7495 """ 7496 for step in range(len(self) - 1, -1, -1): 7497 graph = self.getSituation(step).graph 7498 try: 7499 return graph.decisionInfo(dID) 7500 except MissingDecisionError: 7501 continue 7502 raise MissingDecisionError( 7503 f"Decision {dID!r} does not exist at any step of the" 7504 f" exploration." 7505 )
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.
7507 def latestTransitionProperties( 7508 self, 7509 dID: base.DecisionID, 7510 transition: base.Transition 7511 ) -> TransitionProperties: 7512 """ 7513 Looks up transition properties for the transition with the given 7514 name outgoing from the decision with the given ID, in the latest 7515 step in which a transiiton with that name from that decision 7516 exists (which will usually be the final exploration step, unless 7517 transitions get removed/renamed along the way). Note that because 7518 a transition can be deleted and later added back (unlike 7519 decisions where an ID will not be re-used), it's possible there 7520 are two or more different transitions that meet the 7521 specifications at different points in time, and this will always 7522 return the properties of the last of them. This will raise a 7523 `MissingDecisionError` if there is no step at which the specified 7524 decision exists, and a `MissingTransitionError` if the target 7525 decision exists at some step but never has a transition with the 7526 specified name. 7527 """ 7528 sawDecision: Optional[int] = None 7529 for step in range(len(self) - 1, -1, -1): 7530 graph = self.getSituation(step).graph 7531 try: 7532 return graph.getTransitionProperties(dID, transition) 7533 except (MissingDecisionError, MissingTransitionError) as e: 7534 if ( 7535 sawDecision is None 7536 and isinstance(e, MissingTransitionError) 7537 ): 7538 sawDecision = step 7539 continue 7540 if sawDecision is None: 7541 raise MissingDecisionError( 7542 f"Decision {dID!r} does not exist at any step of the" 7543 f" exploration." 7544 ) 7545 else: 7546 raise MissingTransitionError( 7547 f"Decision {dID!r} does exist (last seen at step" 7548 f" {sawDecision}) but it never has an outgoing" 7549 f" transition named {transition!r}." 7550 )
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.
7552 def tagStep( 7553 self, 7554 tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]], 7555 tagValue: Union[ 7556 base.TagValue, 7557 type[base.NoTagValue] 7558 ] = base.NoTagValue, 7559 step: int = -1 7560 ) -> None: 7561 """ 7562 Adds a tag (or multiple tags) to the current step, or to a 7563 specific step if `n` is given as an integer rather than the 7564 default `None`. A tag value should be supplied when a tag is 7565 given (unless you want to use the default of `1`), but it's a 7566 `ValueError` to supply a tag value when a dictionary of tags to 7567 update is provided. 7568 """ 7569 if isinstance(tagOrTags, base.Tag): 7570 if tagValue is base.NoTagValue: 7571 tagValue = 1 7572 7573 # Not sure why this is necessary... 7574 tagValue = cast(base.TagValue, tagValue) 7575 7576 self.getSituation(step).tags.update({tagOrTags: tagValue}) 7577 else: 7578 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.
7580 def annotateStep( 7581 self, 7582 annotationOrAnnotations: Union[ 7583 base.Annotation, 7584 Sequence[base.Annotation] 7585 ], 7586 step: Optional[int] = None 7587 ) -> None: 7588 """ 7589 Adds an annotation to the current exploration step, or to a 7590 specific step if `n` is given as an integer rather than the 7591 default `None`. 7592 """ 7593 if step is None: 7594 step = -1 7595 if isinstance(annotationOrAnnotations, base.Annotation): 7596 self.getSituation(step).annotations.append( 7597 annotationOrAnnotations 7598 ) 7599 else: 7600 self.getSituation(step).annotations.extend( 7601 annotationOrAnnotations 7602 )
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
.
7604 def hasCapability( 7605 self, 7606 capability: base.Capability, 7607 step: Optional[int] = None, 7608 inCommon: Union[bool, Literal['both']] = "both" 7609 ) -> bool: 7610 """ 7611 Returns True if the player currently had the specified 7612 capability, at the specified exploration step, and False 7613 otherwise. Checks the current state if no step is given. Does 7614 NOT return true if the game state means that the player has an 7615 equivalent for that capability (see 7616 `hasCapabilityOrEquivalent`). 7617 7618 Normally, `inCommon` is set to 'both' by default and so if 7619 either the common `FocalContext` or the active one has the 7620 capability, this will return `True`. `inCommon` may instead be 7621 set to `True` or `False` to ask about just the common (or 7622 active) focal context. 7623 """ 7624 state = self.getSituation().state 7625 commonCapabilities = state['common']['capabilities']\ 7626 ['capabilities'] # noqa 7627 activeCapabilities = state['contexts'][state['activeContext']]\ 7628 ['capabilities']['capabilities'] # noqa 7629 7630 if inCommon == 'both': 7631 return ( 7632 capability in commonCapabilities 7633 or capability in activeCapabilities 7634 ) 7635 elif inCommon is True: 7636 return capability in commonCapabilities 7637 elif inCommon is False: 7638 return capability in activeCapabilities 7639 else: 7640 raise ValueError( 7641 f"Invalid inCommon value (must be False, True, or" 7642 f" 'both'; got {repr(inCommon)})." 7643 )
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.
7645 def hasCapabilityOrEquivalent( 7646 self, 7647 capability: base.Capability, 7648 step: Optional[int] = None, 7649 location: Optional[Set[base.DecisionID]] = None 7650 ) -> bool: 7651 """ 7652 Works like `hasCapability`, but also returns `True` if the 7653 player counts as having the specified capability via an equivalence 7654 that's part of the current graph. As with `hasCapability`, the 7655 optional `step` argument is used to specify which step to check, 7656 with the current step being used as the default. 7657 7658 The `location` set can specify where to start looking for 7659 mechanisms; if left unspecified active decisions for that step 7660 will be used. 7661 """ 7662 if step is None: 7663 step = -1 7664 if location is None: 7665 location = self.getActiveDecisions(step) 7666 situation = self.getSituation(step) 7667 return base.hasCapabilityOrEquivalent( 7668 capability, 7669 base.RequirementContext( 7670 state=situation.state, 7671 graph=situation.graph, 7672 searchFrom=location 7673 ) 7674 )
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.
7676 def gainCapabilityNow( 7677 self, 7678 capability: base.Capability, 7679 inCommon: bool = False 7680 ) -> None: 7681 """ 7682 Modifies the current game state to add the specified `Capability` 7683 to the player's capabilities. No changes are made to the current 7684 graph. 7685 7686 If `inCommon` is set to `True` (default is `False`) then the 7687 capability will be added to the common `FocalContext` and will 7688 therefore persist even when a focal context switch happens. 7689 Normally, it will be added to the currently-active focal 7690 context. 7691 """ 7692 state = self.getSituation().state 7693 if inCommon: 7694 context = state['common'] 7695 else: 7696 context = state['contexts'][state['activeContext']] 7697 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.
7699 def loseCapabilityNow( 7700 self, 7701 capability: base.Capability, 7702 inCommon: Union[bool, Literal['both']] = "both" 7703 ) -> None: 7704 """ 7705 Modifies the current game state to remove the specified `Capability` 7706 from the player's capabilities. Does nothing if the player 7707 doesn't already have that capability. 7708 7709 By default, this removes the capability from both the common 7710 capabilities set and the active `FocalContext`'s capabilities 7711 set, so that afterwards the player will definitely not have that 7712 capability. However, if you set `inCommon` to either `True` or 7713 `False`, it will remove the capability from just the common 7714 capabilities set (if `True`) or just the active capabilities set 7715 (if `False`). In these cases, removing the capability from just 7716 one capability set will not actually remove it in terms of the 7717 `hasCapability` result if it had been present in the other set. 7718 Set `inCommon` to "both" to use the default behavior explicitly. 7719 """ 7720 now = self.getSituation() 7721 if inCommon in ("both", True): 7722 context = now.state['common'] 7723 try: 7724 context['capabilities']['capabilities'].remove(capability) 7725 except KeyError: 7726 pass 7727 elif inCommon in ("both", False): 7728 context = now.state['contexts'][now.state['activeContext']] 7729 try: 7730 context['capabilities']['capabilities'].remove(capability) 7731 except KeyError: 7732 pass 7733 else: 7734 raise ValueError( 7735 f"Invalid inCommon value (must be False, True, or" 7736 f" 'both'; got {repr(inCommon)})." 7737 )
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.
7739 def tokenCountNow(self, tokenType: base.Token) -> Optional[int]: 7740 """ 7741 Returns the number of tokens the player currently has of a given 7742 type. Returns `None` if the player has never acquired or lost 7743 tokens of that type. 7744 7745 This method adds together tokens from the common and active 7746 focal contexts. 7747 """ 7748 state = self.getSituation().state 7749 commonContext = state['common'] 7750 activeContext = state['contexts'][state['activeContext']] 7751 base = commonContext['capabilities']['tokens'].get(tokenType) 7752 if base is None: 7753 return activeContext['capabilities']['tokens'].get(tokenType) 7754 else: 7755 return base + activeContext['capabilities']['tokens'].get( 7756 tokenType, 7757 0 7758 )
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.
7760 def adjustTokensNow( 7761 self, 7762 tokenType: base.Token, 7763 amount: int, 7764 inCommon: bool = False 7765 ) -> None: 7766 """ 7767 Modifies the current game state to add the specified number of 7768 `Token`s of the given type to the player's tokens. No changes are 7769 made to the current graph. Reduce the number of tokens by 7770 supplying a negative amount; note that negative token amounts 7771 are possible. 7772 7773 By default, the number of tokens for the current active 7774 `FocalContext` will be adjusted. However, if `inCommon` is set 7775 to `True`, then the number of tokens for the common context will 7776 be adjusted instead. 7777 """ 7778 # TODO: Custom token caps! 7779 state = self.getSituation().state 7780 if inCommon: 7781 context = state['common'] 7782 else: 7783 context = state['contexts'][state['activeContext']] 7784 tokens = context['capabilities']['tokens'] 7785 tokens[tokenType] = tokens.get(tokenType, 0) + amount
Modifies the current game state to add the specified number of
Token
s 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.
7787 def setTokensNow( 7788 self, 7789 tokenType: base.Token, 7790 amount: int, 7791 inCommon: bool = False 7792 ) -> None: 7793 """ 7794 Modifies the current game state to set number of `Token`s of the 7795 given type to a specific amount, regardless of the old value. No 7796 changes are made to the current graph. 7797 7798 By default this sets the number of tokens for the active 7799 `FocalContext`. But if you set `inCommon` to `True`, it will 7800 set the number of tokens in the common context instead. 7801 """ 7802 # TODO: Custom token caps! 7803 state = self.getSituation().state 7804 if inCommon: 7805 context = state['common'] 7806 else: 7807 context = state['contexts'][state['activeContext']] 7808 context['capabilities']['tokens'][tokenType] = amount
Modifies the current game state to set number of Token
s 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.
7810 def lookupMechanism( 7811 self, 7812 mechanism: base.MechanismName, 7813 step: Optional[int] = None, 7814 where: Union[ 7815 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]], 7816 Collection[base.AnyDecisionSpecifier], 7817 None 7818 ] = None 7819 ) -> base.MechanismID: 7820 """ 7821 Looks up a mechanism ID by name, in the graph for the specified 7822 step. The `where` argument specifies where to start looking, 7823 which helps disambiguate. It can be a tuple with a decision 7824 specifier and `None` to start from a single decision, or with a 7825 decision specifier and a transition name to start from either 7826 end of that transition. It can also be `None` to look at global 7827 mechanisms and then all decisions directly, although this 7828 increases the chance of a `MechanismCollisionError`. Finally, it 7829 can be some other non-tuple collection of decision specifiers to 7830 start from that set. 7831 7832 If no step is specified, uses the current step. 7833 """ 7834 if step is None: 7835 step = -1 7836 situation = self.getSituation(step) 7837 graph = situation.graph 7838 searchFrom: Collection[base.AnyDecisionSpecifier] 7839 if where is None: 7840 searchFrom = set() 7841 elif isinstance(where, tuple): 7842 if len(where) != 2: 7843 raise ValueError( 7844 f"Mechanism lookup location was a tuple with an" 7845 f" invalid length (must be length-2 if it's a" 7846 f" tuple):\n {repr(where)}" 7847 ) 7848 where = cast( 7849 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]], 7850 where 7851 ) 7852 if where[1] is None: 7853 searchFrom = {graph.resolveDecision(where[0])} 7854 else: 7855 searchFrom = graph.bothEnds(where[0], where[1]) 7856 else: # must be a collection of specifiers 7857 searchFrom = cast(Collection[base.AnyDecisionSpecifier], where) 7858 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 MechanismCollisionError
. 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.
7860 def mechanismState( 7861 self, 7862 mechanism: base.AnyMechanismSpecifier, 7863 where: Optional[Set[base.DecisionID]] = None, 7864 step: int = -1 7865 ) -> Optional[base.MechanismState]: 7866 """ 7867 Returns the current state for the specified mechanism (or the 7868 state at the specified step if a step index is given). `where` 7869 may be provided as a set of decision IDs to indicate where to 7870 search for the named mechanism, or a mechanism ID may be provided 7871 in the first place. Mechanism states are properties of a `State` 7872 but are not associated with focal contexts. 7873 """ 7874 situation = self.getSituation(step) 7875 mID = situation.graph.resolveMechanism(mechanism, startFrom=where) 7876 return situation.state['mechanisms'].get( 7877 mID, 7878 base.DEFAULT_MECHANISM_STATE 7879 )
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.
7881 def setMechanismStateNow( 7882 self, 7883 mechanism: base.AnyMechanismSpecifier, 7884 toState: base.MechanismState, 7885 where: Optional[Set[base.DecisionID]] = None 7886 ) -> None: 7887 """ 7888 Sets the state of the specified mechanism to the specified 7889 state. Mechanisms can only be in one state at once, so this 7890 removes any previous states for that mechanism (note that via 7891 equivalences multiple mechanism states can count as active). 7892 7893 The mechanism can be any kind of mechanism specifier (see 7894 `base.AnyMechanismSpecifier`). If it's not a mechanism ID and 7895 doesn't have its own position information, the 'where' argument 7896 can be used to hint where to search for the mechanism. 7897 """ 7898 now = self.getSituation() 7899 mID = now.graph.resolveMechanism(mechanism, startFrom=where) 7900 if mID is None: 7901 raise MissingMechanismError( 7902 f"Couldn't find mechanism for {repr(mechanism)}." 7903 ) 7904 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.
7906 def skillLevel( 7907 self, 7908 skill: base.Skill, 7909 step: Optional[int] = None 7910 ) -> Optional[base.Level]: 7911 """ 7912 Returns the skill level the player had in a given skill at a 7913 given step, or for the current step if no step is specified. 7914 Returns `None` if the player had never acquired or lost levels 7915 in that skill before the specified step (skill level would count 7916 as 0 in that case). 7917 7918 This method adds together levels from the common and active 7919 focal contexts. 7920 """ 7921 if step is None: 7922 step = -1 7923 state = self.getSituation(step).state 7924 commonContext = state['common'] 7925 activeContext = state['contexts'][state['activeContext']] 7926 base = commonContext['capabilities']['skills'].get(skill) 7927 if base is None: 7928 return activeContext['capabilities']['skills'].get(skill) 7929 else: 7930 return base + activeContext['capabilities']['skills'].get( 7931 skill, 7932 0 7933 )
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.
7935 def adjustSkillLevelNow( 7936 self, 7937 skill: base.Skill, 7938 levels: base.Level, 7939 inCommon: bool = False 7940 ) -> None: 7941 """ 7942 Modifies the current game state to add the specified number of 7943 `Level`s of the given skill. No changes are made to the current 7944 graph. Reduce the skill level by supplying negative levels; note 7945 that negative skill levels are possible. 7946 7947 By default, the skill level for the current active 7948 `FocalContext` will be adjusted. However, if `inCommon` is set 7949 to `True`, then the skill level for the common context will be 7950 adjusted instead. 7951 """ 7952 # TODO: Custom level caps? 7953 state = self.getSituation().state 7954 if inCommon: 7955 context = state['common'] 7956 else: 7957 context = state['contexts'][state['activeContext']] 7958 skills = context['capabilities']['skills'] 7959 skills[skill] = skills.get(skill, 0) + levels
Modifies the current game state to add the specified number of
Level
s 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.
7961 def setSkillLevelNow( 7962 self, 7963 skill: base.Skill, 7964 level: base.Level, 7965 inCommon: bool = False 7966 ) -> None: 7967 """ 7968 Modifies the current game state to set `Skill` `Level` for the 7969 given skill, regardless of the old value. No changes are made to 7970 the current graph. 7971 7972 By default this sets the skill level for the active 7973 `FocalContext`. But if you set `inCommon` to `True`, it will set 7974 the skill level in the common context instead. 7975 """ 7976 # TODO: Custom level caps? 7977 state = self.getSituation().state 7978 if inCommon: 7979 context = state['common'] 7980 else: 7981 context = state['contexts'][state['activeContext']] 7982 skills = context['capabilities']['skills'] 7983 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.
7985 def updateRequirementNow( 7986 self, 7987 decision: base.AnyDecisionSpecifier, 7988 transition: base.Transition, 7989 requirement: Optional[base.Requirement] 7990 ) -> None: 7991 """ 7992 Updates the requirement for a specific transition in a specific 7993 decision. Use `None` to remove the requirement for that edge. 7994 """ 7995 if requirement is None: 7996 requirement = base.ReqNothing() 7997 self.getSituation().graph.setTransitionRequirement( 7998 decision, 7999 transition, 8000 requirement 8001 )
Updates the requirement for a specific transition in a specific
decision. Use None
to remove the requirement for that edge.
8003 def isTraversable( 8004 self, 8005 decision: base.AnyDecisionSpecifier, 8006 transition: base.Transition, 8007 step: int = -1 8008 ) -> bool: 8009 """ 8010 Returns True if the specified transition from the specified 8011 decision had its requirement satisfied by the game state at the 8012 specified step (or at the current step if no step is specified). 8013 Raises an `IndexError` if the specified step doesn't exist, and 8014 a `KeyError` if the decision or transition specified does not 8015 exist in the `DecisionGraph` at that step. 8016 """ 8017 situation = self.getSituation(step) 8018 req = situation.graph.getTransitionRequirement(decision, transition) 8019 ctx = base.contextForTransition(situation, decision, transition) 8020 fromID = situation.graph.resolveDecision(decision) 8021 return ( 8022 req.satisfied(ctx) 8023 and (fromID, transition) not in situation.state['deactivated'] 8024 )
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.
8026 def applyTransitionEffect( 8027 self, 8028 whichEffect: base.EffectSpecifier, 8029 moveWhich: Optional[base.FocalPointName] = None 8030 ) -> Optional[base.DecisionID]: 8031 """ 8032 Applies an effect attached to a transition, taking charges and 8033 delay into account based on the current `Situation`. 8034 Modifies the effect's trigger count (but may not actually 8035 trigger the effect if the charges and/or delay values indicate 8036 not to; see `base.doTriggerEffect`). 8037 8038 If a specific focal point in a plural-focalized domain is 8039 triggering the effect, the focal point name should be specified 8040 via `moveWhich` so that goto `Effect`s can know which focal 8041 point to move when it's not explicitly specified in the effect. 8042 TODO: Test this! 8043 8044 Returns None most of the time, but if a 'goto', 'bounce', or 8045 'follow' effect was applied, it returns the decision ID for that 8046 effect's destination, which would override a transition's normal 8047 destination. If it returns a destination ID, then the exploration 8048 state will already have been updated to set the position there, 8049 and further position updates are not needed. 8050 8051 Note that transition effects which update active decisions will 8052 also update the exploration status of those decisions to 8053 'exploring' if they had been in an unvisited status (see 8054 `updatePosition` and `hasBeenVisited`). 8055 8056 Note: callers should immediately update situation-based variables 8057 that might have been changes by a 'revert' effect. 8058 """ 8059 now = self.getSituation() 8060 effect, triggerCount = base.doTriggerEffect(now, whichEffect) 8061 if triggerCount is not None: 8062 return self.applyExtraneousEffect( 8063 effect, 8064 where=whichEffect[:2], 8065 moveWhich=moveWhich 8066 ) 8067 else: 8068 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 Effect
s 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.
8070 def applyExtraneousEffect( 8071 self, 8072 effect: base.Effect, 8073 where: Optional[ 8074 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]] 8075 ] = None, 8076 moveWhich: Optional[base.FocalPointName] = None, 8077 challengePolicy: base.ChallengePolicy = "specified" 8078 ) -> Optional[base.DecisionID]: 8079 """ 8080 Applies a single extraneous effect to the state & graph, 8081 *without* accounting for charges or delay values, since the 8082 effect is not part of the graph (use `applyTransitionEffect` to 8083 apply effects that are attached to transitions, which is almost 8084 always the function you should be using). An associated 8085 transition for the extraneous effect can be supplied using the 8086 `where` argument, and effects like 'deactivate' and 'edit' will 8087 affect it (but the effect's charges and delay values will still 8088 be ignored). 8089 8090 If the effect would change the destination of a transition, the 8091 altered destination ID is returned: 'bounce' effects return the 8092 provided decision part of `where`, 'goto' effects return their 8093 target, and 'follow' effects return the destination followed to 8094 (possibly via chained follows in the extreme case). In all other 8095 cases, `None` is returned indicating no change to a normal 8096 destination. 8097 8098 If a specific focal point in a plural-focalized domain is 8099 triggering the effect, the focal point name should be specified 8100 via `moveWhich` so that goto `Effect`s can know which focal 8101 point to move when it's not explicitly specified in the effect. 8102 TODO: Test this! 8103 8104 Note that transition effects which update active decisions will 8105 also update the exploration status of those decisions to 8106 'exploring' if they had been in an unvisited status and will 8107 remove any 'unconfirmed' tag they might still have (see 8108 `updatePosition` and `hasBeenVisited`). 8109 8110 The given `challengePolicy` is applied when traversing further 8111 transitions due to 'follow' effects. 8112 8113 Note: Anyone calling `applyExtraneousEffect` should update any 8114 situation-based variables immediately after the call, as a 8115 'revert' effect may have changed the current graph and/or state. 8116 """ 8117 typ = effect['type'] 8118 value = effect['value'] 8119 applyTo = effect['applyTo'] 8120 inCommon = applyTo == 'common' 8121 8122 now = self.getSituation() 8123 8124 if where is not None: 8125 if where[1] is not None: 8126 searchFrom = now.graph.bothEnds(where[0], where[1]) 8127 else: 8128 searchFrom = {now.graph.resolveDecision(where[0])} 8129 else: 8130 searchFrom = None 8131 8132 # Note: Delay and charges are ignored! 8133 8134 if typ in ("gain", "lose"): 8135 value = cast( 8136 Union[ 8137 base.Capability, 8138 Tuple[base.Token, base.TokenCount], 8139 Tuple[Literal['skill'], base.Skill, base.Level], 8140 ], 8141 value 8142 ) 8143 if isinstance(value, base.Capability): 8144 if typ == "gain": 8145 self.gainCapabilityNow(value, inCommon) 8146 else: 8147 self.loseCapabilityNow(value, inCommon) 8148 elif len(value) == 2: # must be a token, amount pair 8149 token, amount = cast( 8150 Tuple[base.Token, base.TokenCount], 8151 value 8152 ) 8153 if typ == "lose": 8154 amount *= -1 8155 self.adjustTokensNow(token, amount, inCommon) 8156 else: # must be a 'skill', skill, level triple 8157 _, skill, levels = cast( 8158 Tuple[Literal['skill'], base.Skill, base.Level], 8159 value 8160 ) 8161 if typ == "lose": 8162 levels *= -1 8163 self.adjustSkillLevelNow(skill, levels, inCommon) 8164 8165 elif typ == "set": 8166 value = cast( 8167 Union[ 8168 Tuple[base.Token, base.TokenCount], 8169 Tuple[base.AnyMechanismSpecifier, base.MechanismState], 8170 Tuple[Literal['skill'], base.Skill, base.Level], 8171 ], 8172 value 8173 ) 8174 if len(value) == 2: # must be a token or mechanism pair 8175 if isinstance(value[1], base.TokenCount): # token 8176 token, amount = cast( 8177 Tuple[base.Token, base.TokenCount], 8178 value 8179 ) 8180 self.setTokensNow(token, amount, inCommon) 8181 else: # mechanism 8182 mechanism, state = cast( 8183 Tuple[ 8184 base.AnyMechanismSpecifier, 8185 base.MechanismState 8186 ], 8187 value 8188 ) 8189 self.setMechanismStateNow(mechanism, state, searchFrom) 8190 else: # must be a 'skill', skill, level triple 8191 _, skill, level = cast( 8192 Tuple[Literal['skill'], base.Skill, base.Level], 8193 value 8194 ) 8195 self.setSkillLevelNow(skill, level, inCommon) 8196 8197 elif typ == "toggle": 8198 # Length-1 list just toggles a capability on/off based on current 8199 # state (not attending to equivalents): 8200 if isinstance(value, List): # capabilities list 8201 value = cast(List[base.Capability], value) 8202 if len(value) == 0: 8203 raise ValueError( 8204 "Toggle effect has empty capabilities list." 8205 ) 8206 if len(value) == 1: 8207 capability = value[0] 8208 if self.hasCapability(capability, inCommon=False): 8209 self.loseCapabilityNow(capability, inCommon=False) 8210 else: 8211 self.gainCapabilityNow(capability) 8212 else: 8213 # Otherwise toggle all powers off, then one on, 8214 # based on the first capability that's currently on. 8215 # Note we do NOT count equivalences. 8216 8217 # Find first capability that's on: 8218 firstIndex: Optional[int] = None 8219 for i, capability in enumerate(value): 8220 if self.hasCapability(capability): 8221 firstIndex = i 8222 break 8223 8224 # Turn them all off: 8225 for capability in value: 8226 self.loseCapabilityNow(capability, inCommon=False) 8227 # TODO: inCommon for the check? 8228 8229 if firstIndex is None: 8230 self.gainCapabilityNow(value[0]) 8231 else: 8232 self.gainCapabilityNow( 8233 value[(firstIndex + 1) % len(value)] 8234 ) 8235 else: # must be a mechanism w/ states list 8236 mechanism, states = cast( 8237 Tuple[ 8238 base.AnyMechanismSpecifier, 8239 List[base.MechanismState] 8240 ], 8241 value 8242 ) 8243 currentState = self.mechanismState(mechanism, where=searchFrom) 8244 if len(states) == 1: 8245 if currentState == states[0]: 8246 # default alternate state 8247 self.setMechanismStateNow( 8248 mechanism, 8249 base.DEFAULT_MECHANISM_STATE, 8250 searchFrom 8251 ) 8252 else: 8253 self.setMechanismStateNow( 8254 mechanism, 8255 states[0], 8256 searchFrom 8257 ) 8258 else: 8259 # Find our position in the list, if any 8260 try: 8261 currentIndex = states.index(cast(str, currentState)) 8262 # Cast here just because we know that None will 8263 # raise a ValueError but we'll catch it, and we 8264 # want to suppress the mypy warning about the 8265 # option 8266 except ValueError: 8267 currentIndex = len(states) - 1 8268 # Set next state in list as current state 8269 nextIndex = (currentIndex + 1) % len(states) 8270 self.setMechanismStateNow( 8271 mechanism, 8272 states[nextIndex], 8273 searchFrom 8274 ) 8275 8276 elif typ == "deactivate": 8277 if where is None or where[1] is None: 8278 raise ValueError( 8279 "Can't apply a deactivate effect without specifying" 8280 " which transition it applies to." 8281 ) 8282 8283 decision, transition = cast( 8284 Tuple[base.AnyDecisionSpecifier, base.Transition], 8285 where 8286 ) 8287 8288 dID = now.graph.resolveDecision(decision) 8289 now.state['deactivated'].add((dID, transition)) 8290 8291 elif typ == "edit": 8292 value = cast(List[List[commands.Command]], value) 8293 # If there are no blocks, do nothing 8294 if len(value) > 0: 8295 # Apply the first block of commands and then rotate the list 8296 scope: commands.Scope = {} 8297 if where is not None: 8298 here: base.DecisionID = now.graph.resolveDecision( 8299 where[0] 8300 ) 8301 outwards: Optional[base.Transition] = where[1] 8302 scope['@'] = here 8303 scope['@t'] = outwards 8304 if outwards is not None: 8305 reciprocal = now.graph.getReciprocal(here, outwards) 8306 destination = now.graph.getDestination(here, outwards) 8307 else: 8308 reciprocal = None 8309 destination = None 8310 scope['@r'] = reciprocal 8311 scope['@d'] = destination 8312 self.runCommandBlock(value[0], scope) 8313 value.append(value.pop(0)) 8314 8315 elif typ == "goto": 8316 if isinstance(value, base.DecisionSpecifier): 8317 target: base.AnyDecisionSpecifier = value 8318 # use moveWhich provided as argument 8319 elif isinstance(value, tuple): 8320 target, moveWhich = cast( 8321 Tuple[base.AnyDecisionSpecifier, base.FocalPointName], 8322 value 8323 ) 8324 else: 8325 target = cast(base.AnyDecisionSpecifier, value) 8326 # use moveWhich provided as argument 8327 8328 destID = now.graph.resolveDecision(target) 8329 base.updatePosition(now, destID, applyTo, moveWhich) 8330 return destID 8331 8332 elif typ == "bounce": 8333 # Just need to let the caller know they should cancel 8334 if where is None: 8335 raise ValueError( 8336 "Can't apply a 'bounce' effect without a position" 8337 " to apply it from." 8338 ) 8339 return now.graph.resolveDecision(where[0]) 8340 8341 elif typ == "follow": 8342 if where is None: 8343 raise ValueError( 8344 f"Can't follow transition {value!r} because there" 8345 f" is no position information when applying the" 8346 f" effect." 8347 ) 8348 if where[1] is not None: 8349 followFrom = now.graph.getDestination(where[0], where[1]) 8350 if followFrom is None: 8351 raise ValueError( 8352 f"Can't follow transition {value!r} because the" 8353 f" position information specifies transition" 8354 f" {where[1]!r} from decision" 8355 f" {now.graph.identityOf(where[0])} but that" 8356 f" transition does not exist." 8357 ) 8358 else: 8359 followFrom = now.graph.resolveDecision(where[0]) 8360 8361 following = cast(base.Transition, value) 8362 8363 followTo = now.graph.getDestination(followFrom, following) 8364 8365 if followTo is None: 8366 raise ValueError( 8367 f"Can't follow transition {following!r} because" 8368 f" that transition doesn't exist at the specified" 8369 f" destination {now.graph.identityOf(followFrom)}." 8370 ) 8371 8372 if self.isTraversable(followFrom, following): # skip if not 8373 # Perform initial position update before following new 8374 # transition: 8375 base.updatePosition( 8376 now, 8377 followFrom, 8378 applyTo, 8379 moveWhich 8380 ) 8381 8382 # Apply consequences of followed transition 8383 fullFollowTo = self.applyTransitionConsequence( 8384 followFrom, 8385 following, 8386 moveWhich, 8387 challengePolicy 8388 ) 8389 8390 # Now update to end of followed transition 8391 if fullFollowTo is None: 8392 base.updatePosition( 8393 now, 8394 followTo, 8395 applyTo, 8396 moveWhich 8397 ) 8398 fullFollowTo = followTo 8399 8400 # Skip the normal update: we've taken care of that plus more 8401 return fullFollowTo 8402 else: 8403 # Normal position updates still applies since follow 8404 # transition wasn't possible 8405 return None 8406 8407 elif typ == "save": 8408 assert isinstance(value, base.SaveSlot) 8409 now.saves[value] = copy.deepcopy((now.graph, now.state)) 8410 8411 else: 8412 raise ValueError(f"Invalid effect type {typ!r}.") 8413 8414 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 Effect
s 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.
8416 def applyExtraneousConsequence( 8417 self, 8418 consequence: base.Consequence, 8419 where: Optional[ 8420 Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]] 8421 ] = None, 8422 moveWhich: Optional[base.FocalPointName] = None 8423 ) -> Optional[base.DecisionID]: 8424 """ 8425 Applies an extraneous consequence not associated with a 8426 transition. Unlike `applyTransitionConsequence`, the provided 8427 `base.Consequence` must already have observed outcomes (see 8428 `base.observeChallengeOutcomes`). Returns the decision ID for a 8429 decision implied by a goto, follow, or bounce effect, or `None` 8430 if no effect implies a destination. 8431 8432 The `where` and `moveWhich` optional arguments specify which 8433 decision and/or transition to use as the application position, 8434 and/or which focal point to move. This affects mechanism lookup 8435 as well as the end position when 'follow' effects are used. 8436 Specifically: 8437 8438 - A 'follow' trigger will search for transitions to follow from 8439 the destination of the specified transition, or if only a 8440 decision was supplied, from that decision. 8441 - Mechanism lookups will start with both ends of the specified 8442 transition as their search field (or with just the specified 8443 decision if no transition is included). 8444 8445 'bounce' effects will cause an error unless position information 8446 is provided, and will set the position to the base decision 8447 provided in `where`. 8448 8449 Note: callers should update any situation-based variables 8450 immediately after calling this as a 'revert' effect could change 8451 the current graph and/or state and other changes could get lost 8452 if they get applied to a stale graph/state. 8453 8454 # TODO: Examples for goto and follow effects. 8455 """ 8456 now = self.getSituation() 8457 searchFrom = set() 8458 if where is not None: 8459 if where[1] is not None: 8460 searchFrom = now.graph.bothEnds(where[0], where[1]) 8461 else: 8462 searchFrom = {now.graph.resolveDecision(where[0])} 8463 8464 context = base.RequirementContext( 8465 state=now.state, 8466 graph=now.graph, 8467 searchFrom=searchFrom 8468 ) 8469 8470 effectIndices = base.observedEffects(context, consequence) 8471 destID = None 8472 for index in effectIndices: 8473 effect = base.consequencePart(consequence, index) 8474 if not isinstance(effect, dict) or 'value' not in effect: 8475 raise RuntimeError( 8476 f"Invalid effect index {index}: Consequence part at" 8477 f" that index is not an Effect. Got:\n{effect}" 8478 ) 8479 effect = cast(base.Effect, effect) 8480 destID = self.applyExtraneousEffect( 8481 effect, 8482 where, 8483 moveWhich 8484 ) 8485 # technically this variable is not used later in this 8486 # function, but the `applyExtraneousEffect` call means it 8487 # needs an update, so we're doing that in case someone later 8488 # adds code to this function that uses 'now' after this 8489 # point. 8490 now = self.getSituation() 8491 8492 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.
8494 def applyTransitionConsequence( 8495 self, 8496 decision: base.AnyDecisionSpecifier, 8497 transition: base.AnyTransition, 8498 moveWhich: Optional[base.FocalPointName] = None, 8499 policy: base.ChallengePolicy = "specified", 8500 fromIndex: Optional[int] = None, 8501 toIndex: Optional[int] = None 8502 ) -> Optional[base.DecisionID]: 8503 """ 8504 Applies the effects of the specified transition to the current 8505 graph and state, possibly overriding observed outcomes using 8506 outcomes specified as part of a `base.TransitionWithOutcomes`. 8507 8508 The `where` and `moveWhich` function serve the same purpose as 8509 for `applyExtraneousEffect`. If `where` is `None`, then the 8510 effects will be applied as extraneous effects, meaning that 8511 their delay and charges values will be ignored and their trigger 8512 count will not be tracked. If `where` is supplied 8513 8514 Returns either None to indicate that the position update for the 8515 transition should apply as usual, or a decision ID indicating 8516 another destination which has already been applied by a 8517 transition effect. 8518 8519 If `fromIndex` and/or `toIndex` are specified, then only effects 8520 which have indices between those two (inclusive) will be 8521 applied, and other effects will neither apply nor be updated in 8522 any way. Note that `onlyPart` does not override the challenge 8523 policy: if the effects in the specified part are not applied due 8524 to a challenge outcome, they still won't happen, including 8525 challenge outcomes outside of that part. Also, outcomes for 8526 challenges of the entire consequence are re-observed if the 8527 challenge policy implies it. 8528 8529 Note: Anyone calling this should update any situation-based 8530 variables immediately after the call, as a 'revert' effect may 8531 have changed the current graph and/or state. 8532 """ 8533 now = self.getSituation() 8534 dID = now.graph.resolveDecision(decision) 8535 8536 transitionName, outcomes = base.nameAndOutcomes(transition) 8537 8538 searchFrom = set() 8539 searchFrom = now.graph.bothEnds(dID, transitionName) 8540 8541 context = base.RequirementContext( 8542 state=now.state, 8543 graph=now.graph, 8544 searchFrom=searchFrom 8545 ) 8546 8547 consequence = now.graph.getConsequence(dID, transitionName) 8548 8549 # Make sure that challenge outcomes are known 8550 if policy != "specified": 8551 base.resetChallengeOutcomes(consequence) 8552 useUp = outcomes[:] 8553 base.observeChallengeOutcomes( 8554 context, 8555 consequence, 8556 location=searchFrom, 8557 policy=policy, 8558 knownOutcomes=useUp 8559 ) 8560 if len(useUp) > 0: 8561 raise ValueError( 8562 f"More outcomes specified than challenges observed in" 8563 f" consequence:\n{consequence}" 8564 f"\nRemaining outcomes:\n{useUp}" 8565 ) 8566 8567 # Figure out which effects apply, and apply each of them 8568 effectIndices = base.observedEffects(context, consequence) 8569 if fromIndex is None: 8570 fromIndex = 0 8571 8572 altDest = None 8573 for index in effectIndices: 8574 if ( 8575 index >= fromIndex 8576 and (toIndex is None or index <= toIndex) 8577 ): 8578 thisDest = self.applyTransitionEffect( 8579 (dID, transitionName, index), 8580 moveWhich 8581 ) 8582 if thisDest is not None: 8583 altDest = thisDest 8584 # TODO: What if this updates state with 'revert' to a 8585 # graph that doesn't contain the same effects? 8586 # TODO: Update 'now' and 'context'?! 8587 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.
8589 def allDecisions(self) -> List[base.DecisionID]: 8590 """ 8591 Returns the list of all decisions which existed at any point 8592 within the exploration. Example: 8593 8594 >>> ex = DiscreteExploration() 8595 >>> ex.start('A') 8596 0 8597 >>> ex.observe('A', 'right') 8598 1 8599 >>> ex.explore('right', 'B', 'left') 8600 1 8601 >>> ex.observe('B', 'right') 8602 2 8603 >>> ex.allDecisions() # 'A', 'B', and the unnamed 'right of B' 8604 [0, 1, 2] 8605 """ 8606 seen = set() 8607 result = [] 8608 for situation in self: 8609 for decision in situation.graph: 8610 if decision not in seen: 8611 result.append(decision) 8612 seen.add(decision) 8613 8614 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]
8616 def allExploredDecisions(self) -> List[base.DecisionID]: 8617 """ 8618 Returns the list of all decisions which existed at any point 8619 within the exploration, excluding decisions whose highest 8620 exploration status was `noticed` or lower. May still include 8621 decisions which don't exist in the final situation's graph due to 8622 things like decision merging. Example: 8623 8624 >>> ex = DiscreteExploration() 8625 >>> ex.start('A') 8626 0 8627 >>> ex.observe('A', 'right') 8628 1 8629 >>> ex.explore('right', 'B', 'left') 8630 1 8631 >>> ex.observe('B', 'right') 8632 2 8633 >>> graph = ex.getSituation().graph 8634 >>> graph.addDecision('C') # add isolated decision; doesn't set status 8635 3 8636 >>> ex.hasBeenVisited('C') 8637 False 8638 >>> ex.allExploredDecisions() 8639 [0, 1] 8640 >>> ex.setExplorationStatus('C', 'exploring') 8641 >>> ex.allExploredDecisions() # 2 is the decision right from 'B' 8642 [0, 1, 3] 8643 >>> ex.setExplorationStatus('A', 'explored') 8644 >>> ex.allExploredDecisions() 8645 [0, 1, 3] 8646 >>> ex.setExplorationStatus('A', 'unknown') 8647 >>> # remains visisted in an earlier step 8648 >>> ex.allExploredDecisions() 8649 [0, 1, 3] 8650 >>> ex.setExplorationStatus('C', 'unknown') # not explored earlier 8651 >>> ex.allExploredDecisions() # 2 is the decision right from 'B' 8652 [0, 1] 8653 """ 8654 seen = set() 8655 result = [] 8656 for situation in self: 8657 graph = situation.graph 8658 for decision in graph: 8659 if ( 8660 decision not in seen 8661 and base.hasBeenVisited(situation, decision) 8662 ): 8663 result.append(decision) 8664 seen.add(decision) 8665 8666 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()
>>> ex.start('A')
0
>>> ex.observe('A', 'right')
1
>>> ex.explore('right', 'B', 'left')
1
>>> ex.observe('B', 'right')
2
>>> graph = ex.getSituation().graph
>>> graph.addDecision('C') # add isolated decision; doesn't set status
3
>>> ex.hasBeenVisited('C')
False
>>> ex.allExploredDecisions()
[0, 1]
>>> ex.setExplorationStatus('C', 'exploring')
>>> ex.allExploredDecisions() # 2 is the decision right from 'B'
[0, 1, 3]
>>> ex.setExplorationStatus('A', 'explored')
>>> ex.allExploredDecisions()
[0, 1, 3]
>>> ex.setExplorationStatus('A', 'unknown')
>>> # remains visisted in an earlier step
>>> ex.allExploredDecisions()
[0, 1, 3]
>>> ex.setExplorationStatus('C', 'unknown') # not explored earlier
>>> ex.allExploredDecisions() # 2 is the decision right from 'B'
[0, 1]
8668 def allVisitedDecisions(self) -> List[base.DecisionID]: 8669 """ 8670 Returns the list of all decisions which existed at any point 8671 within the exploration and which were visited at least once. 8672 Orders them in the same order they were visited in. 8673 8674 Usually all of these decisions will be present in the final 8675 situation's graph, but sometimes merging or other factors means 8676 there might be some that won't be. Being present on the game 8677 state's 'active' list in a step for its domain is what counts as 8678 "being visited," which means that nodes which were passed through 8679 directly via a 'follow' effect won't be counted, for example. 8680 8681 This should usually correspond with the absence of the 8682 'unconfirmed' tag. 8683 8684 Example: 8685 8686 >>> ex = DiscreteExploration() 8687 >>> ex.start('A') 8688 0 8689 >>> ex.observe('A', 'right') 8690 1 8691 >>> ex.explore('right', 'B', 'left') 8692 1 8693 >>> ex.observe('B', 'right') 8694 2 8695 >>> ex.getSituation().graph.addDecision('C') # add isolated decision 8696 3 8697 >>> av = ex.allVisitedDecisions() 8698 >>> av 8699 [0, 1] 8700 >>> all( # no decisions in the 'visited' list are tagged 8701 ... 'unconfirmed' not in ex.getSituation().graph.decisionTags(d) 8702 ... for d in av 8703 ... ) 8704 True 8705 >>> graph = ex.getSituation().graph 8706 >>> 'unconfirmed' in graph.decisionTags(0) 8707 False 8708 >>> 'unconfirmed' in graph.decisionTags(1) 8709 False 8710 >>> 'unconfirmed' in graph.decisionTags(2) 8711 True 8712 >>> 'unconfirmed' in graph.decisionTags(3) # not tagged; not explored 8713 False 8714 """ 8715 seen = set() 8716 result = [] 8717 for step in range(len(self)): 8718 active = self.getActiveDecisions(step) 8719 for dID in active: 8720 if dID not in seen: 8721 result.append(dID) 8722 seen.add(dID) 8723 8724 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
8726 def allTransitions(self) -> List[ 8727 Tuple[base.DecisionID, base.Transition, base.DecisionID] 8728 ]: 8729 """ 8730 Returns the list of all transitions which existed at any point 8731 within the exploration, as 3-tuples with source decision ID, 8732 transition name, and destination decision ID. Note that since 8733 transitions can be deleted or re-targeted, and a transition name 8734 can be re-used after being deleted, things can get messy in the 8735 edges cases. When the same transition name is used in different 8736 steps with different decision targets, we end up including each 8737 possible source-transition-destination triple. Example: 8738 8739 >>> ex = DiscreteExploration() 8740 >>> ex.start('A') 8741 0 8742 >>> ex.observe('A', 'right') 8743 1 8744 >>> ex.explore('right', 'B', 'left') 8745 1 8746 >>> ex.observe('B', 'right') 8747 2 8748 >>> ex.wait() # leave behind a step where 'B' has a 'right' 8749 >>> ex.primaryDecision(0) 8750 >>> ex.primaryDecision(1) 8751 0 8752 >>> ex.primaryDecision(2) 8753 1 8754 >>> ex.primaryDecision(3) 8755 1 8756 >>> len(ex) 8757 4 8758 >>> ex[3].graph.removeDecision(2) # delete 'right of B' 8759 >>> ex.observe('B', 'down') 8760 3 8761 >>> # Decisions are: 'A', 'B', and the unnamed 'right of B' 8762 >>> # (now-deleted), and the unnamed 'down from B' 8763 >>> ex.allDecisions() 8764 [0, 1, 2, 3] 8765 >>> for tr in ex.allTransitions(): 8766 ... print(tr) 8767 ... 8768 (0, 'right', 1) 8769 (1, 'return', 0) 8770 (1, 'left', 0) 8771 (1, 'right', 2) 8772 (2, 'return', 1) 8773 (1, 'down', 3) 8774 (3, 'return', 1) 8775 >>> # Note transitions from now-deleted nodes, and 'return' 8776 >>> # transitions for unexplored nodes before they get explored 8777 """ 8778 seen = set() 8779 result = [] 8780 for situation in self: 8781 graph = situation.graph 8782 for (src, dst, transition) in graph.allEdges(): # type:ignore 8783 trans = (src, transition, dst) 8784 if trans not in seen: 8785 result.append(trans) 8786 seen.add(trans) 8787 8788 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. 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')
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)
(2, 'return', 1)
(1, 'down', 3)
(3, 'return', 1)
>>> # Note transitions from now-deleted nodes, and 'return'
>>> # transitions for unexplored nodes before they get explored
8790 def start( 8791 self, 8792 decision: base.AnyDecisionSpecifier, 8793 startCapabilities: Optional[base.CapabilitySet] = None, 8794 setMechanismStates: Optional[ 8795 Dict[base.MechanismID, base.MechanismState] 8796 ] = None, 8797 setCustomState: Optional[dict] = None, 8798 decisionType: base.DecisionType = "imposed" 8799 ) -> base.DecisionID: 8800 """ 8801 Sets the initial position information for a newly-relevant 8802 domain for the current focal context. Creates a new decision 8803 if the decision is specified by name or `DecisionSpecifier` and 8804 that decision doesn't already exist. Returns the decision ID for 8805 the newly-placed decision (or for the specified decision if it 8806 already existed). 8807 8808 Raises a `BadStart` error if the current focal context already 8809 has position information for the specified domain. 8810 8811 - The given `startCapabilities` replaces any existing 8812 capabilities for the current focal context, although you can 8813 leave it as the default `None` to avoid that and retain any 8814 capabilities that have been set up already. 8815 - The given `setMechanismStates` and `setCustomState` 8816 dictionaries override all previous mechanism states & custom 8817 states in the new situation. Leave these as the default 8818 `None` to maintain those states. 8819 - If created, the decision will be placed in the DEFAULT_DOMAIN 8820 domain unless it's specified as a `base.DecisionSpecifier` 8821 with a domain part, in which case that domain is used. 8822 - If specified as a `base.DecisionSpecifier` with a zone part 8823 and a new decision needs to be created, the decision will be 8824 added to that zone, creating it at level 0 if necessary, 8825 although otherwise no zone information will be changed. 8826 - Resets the decision type to "pending" and the action taken to 8827 `None`. Sets the decision type of the previous situation to 8828 'imposed' (or the specified `decisionType`) and sets an 8829 appropriate 'start' action for that situation. 8830 - Tags the step with 'start'. 8831 - Even in a plural- or spreading-focalized domain, you still need 8832 to pick one decision to start at. 8833 """ 8834 now = self.getSituation() 8835 8836 startID = now.graph.getDecision(decision) 8837 zone = None 8838 domain = base.DEFAULT_DOMAIN 8839 if startID is None: 8840 if isinstance(decision, base.DecisionID): 8841 raise MissingDecisionError( 8842 f"Cannot start at decision {decision} because no" 8843 f" decision with that ID exists. Supply a name or" 8844 f" DecisionSpecifier if you need the start decision" 8845 f" to be created automatically." 8846 ) 8847 elif isinstance(decision, base.DecisionName): 8848 decision = base.DecisionSpecifier( 8849 domain=None, 8850 zone=None, 8851 name=decision 8852 ) 8853 startID = now.graph.addDecision( 8854 decision.name, 8855 domain=decision.domain 8856 ) 8857 zone = decision.zone 8858 if decision.domain is not None: 8859 domain = decision.domain 8860 8861 if zone is not None: 8862 if now.graph.getZoneInfo(zone) is None: 8863 now.graph.createZone(zone, 0) 8864 now.graph.addDecisionToZone(startID, zone) 8865 8866 action: base.ExplorationAction = ( 8867 'start', 8868 startID, 8869 startID, 8870 domain, 8871 startCapabilities, 8872 setMechanismStates, 8873 setCustomState 8874 ) 8875 8876 self.advanceSituation(action, decisionType) 8877 8878 return startID
Sets the initial position information for a newly-relevant
domain for the current focal context. Creates a new decision
if the decision is specified by name or DecisionSpecifier
and
that decision doesn't already exist. Returns the decision ID for
the newly-placed decision (or for the specified decision if it
already existed).
Raises a BadStart
error if the current focal context already
has position information for the specified domain.
- The given
startCapabilities
replaces any existing capabilities for the current focal context, although you can leave it as the defaultNone
to avoid that and retain any capabilities that have been set up already. - The given
setMechanismStates
andsetCustomState
dictionaries override all previous mechanism states & custom states in the new situation. Leave these as the defaultNone
to maintain those states. - If created, the decision will be placed in the DEFAULT_DOMAIN
domain unless it's specified as a
base.DecisionSpecifier
with a domain part, in which case that domain is used. - If specified as a
base.DecisionSpecifier
with a zone part and a new decision needs to be created, the decision will be added to that zone, creating it at level 0 if necessary, although otherwise no zone information will be changed. - Resets the decision type to "pending" and the action taken to
None
. Sets the decision type of the previous situation to 'imposed' (or the 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.
8880 def hasBeenVisited( 8881 self, 8882 decision: base.AnyDecisionSpecifier, 8883 step: int = -1 8884 ): 8885 """ 8886 Returns whether or not the specified decision has been visited in 8887 the specified step (default current step). 8888 """ 8889 return base.hasBeenVisited(self.getSituation(step), decision)
Returns whether or not the specified decision has been visited in the specified step (default current step).
8891 def setExplorationStatus( 8892 self, 8893 decision: base.AnyDecisionSpecifier, 8894 status: base.ExplorationStatus, 8895 upgradeOnly: bool = False 8896 ): 8897 """ 8898 Updates the current exploration status of a specific decision in 8899 the current situation. If `upgradeOnly` is true (default is 8900 `False` then the update will only apply if the new exploration 8901 status counts as 'more-explored' than the old one (see 8902 `base.moreExplored`). 8903 """ 8904 base.setExplorationStatus( 8905 self.getSituation(), 8906 decision, 8907 status, 8908 upgradeOnly 8909 )
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
).
8911 def getExplorationStatus( 8912 self, 8913 decision: base.AnyDecisionSpecifier, 8914 step: int = -1 8915 ): 8916 """ 8917 Returns the exploration status of the specified decision at the 8918 specified step (default is last step). Decisions whose 8919 exploration status has never been set will have a default status 8920 of 'unknown'. 8921 """ 8922 situation = self.getSituation(step) 8923 dID = situation.graph.resolveDecision(decision) 8924 return situation.state['exploration'].get(dID, 'unknown')
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'.
8926 def deduceTransitionDetailsAtStep( 8927 self, 8928 step: int, 8929 transition: base.Transition, 8930 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 8931 whichFocus: Optional[base.FocalPointSpecifier] = None, 8932 inCommon: Union[bool, Literal["auto"]] = "auto" 8933 ) -> Tuple[ 8934 base.ContextSpecifier, 8935 base.DecisionID, 8936 base.DecisionID, 8937 Optional[base.FocalPointSpecifier] 8938 ]: 8939 """ 8940 Given just a transition name which the player intends to take in 8941 a specific step, deduces the `ContextSpecifier` for which 8942 context should be updated, the source and destination 8943 `DecisionID`s for the transition, and if the destination 8944 decision's domain is plural-focalized, the `FocalPointName` 8945 specifying which focal point should be moved. 8946 8947 Because many of those things are ambiguous, you may get an 8948 `AmbiguousTransitionError` when things are underspecified, and 8949 there are options for specifying some of the extra information 8950 directly: 8951 8952 - `fromDecision` may be used to specify the source decision. 8953 - `whichFocus` may be used to specify the focal point (within a 8954 particular context/domain) being updated. When focal point 8955 ambiguity remains and this is unspecified, the 8956 alphabetically-earliest relevant focal point will be used 8957 (either among all focal points which activate the source 8958 decision, if there are any, or among all focal points for 8959 the entire domain of the destination decision). 8960 - `inCommon` (a `ContextSpecifier`) may be used to specify which 8961 context to update. The default of "auto" will cause the 8962 active context to be selected unless it does not activate 8963 the source decision, in which case the common context will 8964 be selected. 8965 8966 A `MissingDecisionError` will be raised if there are no current 8967 active decisions (e.g., before `start` has been called), and a 8968 `MissingTransitionError` will be raised if the listed transition 8969 does not exist from any active decision (or from the specified 8970 decision if `fromDecision` is used). 8971 """ 8972 now = self.getSituation(step) 8973 active = self.getActiveDecisions(step) 8974 if len(active) == 0: 8975 raise MissingDecisionError( 8976 f"There are no active decisions from which transition" 8977 f" {repr(transition)} could be taken at step {step}." 8978 ) 8979 8980 # All source/destination decision pairs for transitions with the 8981 # given transition name. 8982 allDecisionPairs: Dict[base.DecisionID, base.DecisionID] = {} 8983 8984 # TODO: When should we be trimming the active decisions to match 8985 # any alterations to the graph? 8986 for dID in active: 8987 outgoing = now.graph.destinationsFrom(dID) 8988 if transition in outgoing: 8989 allDecisionPairs[dID] = outgoing[transition] 8990 8991 if len(allDecisionPairs) == 0: 8992 raise MissingTransitionError( 8993 f"No transitions named {repr(transition)} are outgoing" 8994 f" from active decisions at step {step}." 8995 f"\nActive decisions are:" 8996 f"\n{now.graph.namesListing(active)}" 8997 ) 8998 8999 if ( 9000 fromDecision is not None 9001 and fromDecision not in allDecisionPairs 9002 ): 9003 raise MissingTransitionError( 9004 f"{fromDecision} was specified as the source decision" 9005 f" for traversing transition {repr(transition)} but" 9006 f" there is no transition of that name from that" 9007 f" decision at step {step}." 9008 f"\nValid source decisions are:" 9009 f"\n{now.graph.namesListing(allDecisionPairs)}" 9010 ) 9011 elif fromDecision is not None: 9012 fromID = now.graph.resolveDecision(fromDecision) 9013 destID = allDecisionPairs[fromID] 9014 fromDomain = now.graph.domainFor(fromID) 9015 elif len(allDecisionPairs) == 1: 9016 fromID, destID = list(allDecisionPairs.items())[0] 9017 fromDomain = now.graph.domainFor(fromID) 9018 else: 9019 fromID = None 9020 destID = None 9021 fromDomain = None 9022 # Still ambiguous; resolve this below 9023 9024 # Use whichFocus if provided 9025 if whichFocus is not None: 9026 # Type/value check for whichFocus 9027 if ( 9028 not isinstance(whichFocus, tuple) 9029 or len(whichFocus) != 3 9030 or whichFocus[0] not in ("active", "common") 9031 or not isinstance(whichFocus[1], base.Domain) 9032 or not isinstance(whichFocus[2], base.FocalPointName) 9033 ): 9034 raise ValueError( 9035 f"Invalid whichFocus value {repr(whichFocus)}." 9036 f"\nMust be a length-3 tuple with 'active' or 'common'" 9037 f" as the first element, a Domain as the second" 9038 f" element, and a FocalPointName as the third" 9039 f" element." 9040 ) 9041 9042 # Resolve focal point specified 9043 fromID = base.resolvePosition( 9044 now, 9045 whichFocus 9046 ) 9047 if fromID is None: 9048 raise MissingTransitionError( 9049 f"Focal point {repr(whichFocus)} was specified as" 9050 f" the transition source, but that focal point does" 9051 f" not have a position." 9052 ) 9053 else: 9054 destID = now.graph.destination(fromID, transition) 9055 fromDomain = now.graph.domainFor(fromID) 9056 9057 elif fromID is None: # whichFocus is None, so it can't disambiguate 9058 raise AmbiguousTransitionError( 9059 f"Transition {repr(transition)} was selected for" 9060 f" disambiguation, but there are multiple transitions" 9061 f" with that name from currently-active decisions, and" 9062 f" neither fromDecision nor whichFocus adequately" 9063 f" disambiguates the specific transition taken." 9064 f"\nValid source decisions at step {step} are:" 9065 f"\n{now.graph.namesListing(allDecisionPairs)}" 9066 ) 9067 9068 # At this point, fromID, destID, and fromDomain have 9069 # been resolved. 9070 if fromID is None or destID is None or fromDomain is None: 9071 raise RuntimeError( 9072 f"One of fromID, destID, or fromDomain was None after" 9073 f" disambiguation was finished:" 9074 f"\nfromID: {fromID}, destID: {destID}, fromDomain:" 9075 f" {repr(fromDomain)}" 9076 ) 9077 9078 # Now figure out which context activated the source so we know 9079 # which focal point we're moving: 9080 context = self.getActiveContext() 9081 active = base.activeDecisionSet(context) 9082 using: base.ContextSpecifier = "active" 9083 if fromID not in active: 9084 context = self.getCommonContext(step) 9085 using = "common" 9086 9087 destDomain = now.graph.domainFor(destID) 9088 if ( 9089 whichFocus is None 9090 and base.getDomainFocalization(context, destDomain) == 'plural' 9091 ): 9092 # Need to figure out which focal point is moving; use the 9093 # alphabetically earliest one that's positioned at the 9094 # fromID, or just the earliest one overall if none of them 9095 # are there. 9096 contextFocalPoints: Dict[ 9097 base.FocalPointName, 9098 Optional[base.DecisionID] 9099 ] = cast( 9100 Dict[base.FocalPointName, Optional[base.DecisionID]], 9101 context['activeDecisions'][destDomain] 9102 ) 9103 if not isinstance(contextFocalPoints, dict): 9104 raise RuntimeError( 9105 f"Active decisions specifier for domain" 9106 f" {repr(destDomain)} with plural focalization has" 9107 f" a non-dictionary value." 9108 ) 9109 9110 if fromDomain == destDomain: 9111 focalCandidates = [ 9112 fp 9113 for fp, pos in contextFocalPoints.items() 9114 if pos == fromID 9115 ] 9116 else: 9117 focalCandidates = list(contextFocalPoints) 9118 9119 whichFocus = (using, destDomain, min(focalCandidates)) 9120 9121 # Now whichFocus has been set if it wasn't already specified; 9122 # might still be None if it's not relevant. 9123 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
DecisionID
s for the transition, and if the destination
decision's domain is plural-focalized, the FocalPointName
specifying which focal point should be moved.
Because many of those things are ambiguous, you may get an
AmbiguousTransitionError
when things are underspecified, and
there are options for specifying some of the extra information
directly:
fromDecision
may be used to specify the source decision.whichFocus
may be used to specify the focal point (within a particular context/domain) being updated. When focal point ambiguity remains and this is unspecified, the alphabetically-earliest relevant focal point will be used (either among all focal points which activate the source decision, if there are any, or among all focal points for the entire domain of the destination decision).inCommon
(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).
9125 def advanceSituation( 9126 self, 9127 action: base.ExplorationAction, 9128 decisionType: base.DecisionType = "active", 9129 challengePolicy: base.ChallengePolicy = "specified" 9130 ) -> Tuple[base.Situation, Set[base.DecisionID]]: 9131 """ 9132 Given an `ExplorationAction`, sets that as the action taken in 9133 the current situation, and adds a new situation with the results 9134 of that action. A `DoubleActionError` will be raised if the 9135 current situation already has an action specified, and/or has a 9136 decision type other than 'pending'. By default the type of the 9137 decision will be 'active' but another `DecisionType` can be 9138 specified via the `decisionType` parameter. 9139 9140 If the action specified is `('noAction',)`, then the new 9141 situation will be a copy of the old one; this represents waiting 9142 or being at an ending (a decision type other than 'pending' 9143 should be used). 9144 9145 Although `None` can appear as the action entry in situations 9146 with pending decisions, you cannot call `advanceSituation` with 9147 `None` as the action. 9148 9149 If the action includes taking a transition whose requirements 9150 are not satisfied, the transition will still be taken (and any 9151 consequences applied) but a `TransitionBlockedWarning` will be 9152 issued. 9153 9154 A `ChallengePolicy` may be specified, the default is 'specified' 9155 which requires that outcomes are pre-specified. If any other 9156 policy is set, the challenge outcomes will be reset before 9157 re-resolving them according to the provided policy. 9158 9159 The new situation will have decision type 'pending' and `None` 9160 as the action. 9161 9162 The new situation created as a result of the action is returned, 9163 along with the set of destination decision IDs, including 9164 possibly a modified destination via 'bounce', 'goto', and/or 9165 'follow' effects. For actions that don't have a destination, the 9166 second part of the returned tuple will be an empty set. Multiple 9167 IDs may be in the set when using a start action in a plural- or 9168 spreading-focalized domain, for example. 9169 9170 If the action updates active decisions (including via transition 9171 effects) this will also update the exploration status of those 9172 decisions to 'exploring' if they had been in an unvisited 9173 status (see `updatePosition` and `hasBeenVisited`). This 9174 includes decisions traveled through but not ultimately arrived 9175 at via 'follow' effects. 9176 9177 If any decisions are active in the `ENDINGS_DOMAIN`, attempting 9178 to 'warp', 'explore', 'take', or 'start' will raise an 9179 `InvalidActionError`. 9180 """ 9181 now = self.getSituation() 9182 if now.type != 'pending' or now.action is not None: 9183 raise DoubleActionError( 9184 f"Attempted to take action {repr(action)} at step" 9185 f" {len(self) - 1}, but an action and/or decision type" 9186 f" had already been specified:" 9187 f"\nAction: {repr(now.action)}" 9188 f"\nType: {repr(now.type)}" 9189 ) 9190 9191 # Update the now situation to add in the decision type and 9192 # action taken: 9193 revised = base.Situation( 9194 now.graph, 9195 now.state, 9196 decisionType, 9197 action, 9198 now.saves, 9199 now.tags, 9200 now.annotations 9201 ) 9202 self.situations[-1] = revised 9203 9204 # Separate update process when reverting (this branch returns) 9205 if ( 9206 action is not None 9207 and isinstance(action, tuple) 9208 and len(action) == 3 9209 and action[0] == 'revertTo' 9210 and isinstance(action[1], base.SaveSlot) 9211 and isinstance(action[2], set) 9212 and all(isinstance(x, str) for x in action[2]) 9213 ): 9214 _, slot, aspects = action 9215 if slot not in now.saves: 9216 raise KeyError( 9217 f"Cannot load save slot {slot!r} because no save" 9218 f" data has been established for that slot." 9219 ) 9220 load = now.saves[slot] 9221 rGraph, rState = base.revertedState( 9222 (now.graph, now.state), 9223 load, 9224 aspects 9225 ) 9226 reverted = base.Situation( 9227 graph=rGraph, 9228 state=rState, 9229 type='pending', 9230 action=None, 9231 saves=copy.deepcopy(now.saves), 9232 tags={}, 9233 annotations=[] 9234 ) 9235 self.situations.append(reverted) 9236 # Apply any active triggers (edits reverted) 9237 self.applyActiveTriggers() 9238 # Figure out destinations set to return 9239 newDestinations = set() 9240 newPr = rState['primaryDecision'] 9241 if newPr is not None: 9242 newDestinations.add(newPr) 9243 return (reverted, newDestinations) 9244 9245 # TODO: These deep copies are expensive time-wise. Can we avoid 9246 # them? Probably not. 9247 newGraph = copy.deepcopy(now.graph) 9248 newState = copy.deepcopy(now.state) 9249 newSaves = copy.copy(now.saves) # a shallow copy 9250 newTags: Dict[base.Tag, base.TagValue] = {} 9251 newAnnotations: List[base.Annotation] = [] 9252 updated = base.Situation( 9253 graph=newGraph, 9254 state=newState, 9255 type='pending', 9256 action=None, 9257 saves=newSaves, 9258 tags=newTags, 9259 annotations=newAnnotations 9260 ) 9261 9262 targetContext: base.FocalContext 9263 9264 # Now that action effects have been imprinted into the updated 9265 # situation, append it to our situations list 9266 self.situations.append(updated) 9267 9268 # Figure out effects of the action: 9269 if action is None: 9270 raise InvalidActionError( 9271 "None cannot be used as an action when advancing the" 9272 " situation." 9273 ) 9274 9275 aLen = len(action) 9276 9277 destIDs = set() 9278 9279 if ( 9280 action[0] in ('start', 'take', 'explore', 'warp') 9281 and any( 9282 newGraph.domainFor(d) == ENDINGS_DOMAIN 9283 for d in self.getActiveDecisions() 9284 ) 9285 ): 9286 activeEndings = [ 9287 d 9288 for d in self.getActiveDecisions() 9289 if newGraph.domainFor(d) == ENDINGS_DOMAIN 9290 ] 9291 raise InvalidActionError( 9292 f"Attempted to {action[0]!r} while an ending was" 9293 f" active. Active endings are:" 9294 f"\n{newGraph.namesListing(activeEndings)}" 9295 ) 9296 9297 if action == ('noAction',): 9298 # No updates needed 9299 pass 9300 9301 elif ( 9302 not isinstance(action, tuple) 9303 or (action[0] not in get_args(base.ExplorationActionType)) 9304 or not (2 <= aLen <= 7) 9305 ): 9306 raise InvalidActionError( 9307 f"Invalid ExplorationAction tuple (must be a tuple that" 9308 f" starts with an ExplorationActionType and has 2-6" 9309 f" entries if it's not ('noAction',)):" 9310 f"\n{repr(action)}" 9311 ) 9312 9313 elif action[0] == 'start': 9314 ( 9315 _, 9316 positionSpecifier, 9317 primary, 9318 domain, 9319 capabilities, 9320 mechanismStates, 9321 customState 9322 ) = cast( 9323 Tuple[ 9324 Literal['start'], 9325 Union[ 9326 base.DecisionID, 9327 Dict[base.FocalPointName, base.DecisionID], 9328 Set[base.DecisionID] 9329 ], 9330 Optional[base.DecisionID], 9331 base.Domain, 9332 Optional[base.CapabilitySet], 9333 Optional[Dict[base.MechanismID, base.MechanismState]], 9334 Optional[dict] 9335 ], 9336 action 9337 ) 9338 targetContext = newState['contexts'][ 9339 newState['activeContext'] 9340 ] 9341 9342 targetFocalization = base.getDomainFocalization( 9343 targetContext, 9344 domain 9345 ) # sets up 'singular' as default if 9346 9347 # Check if there are any already-active decisions. 9348 if targetContext['activeDecisions'][domain] is not None: 9349 raise BadStart( 9350 f"Cannot start in domain {repr(domain)} because" 9351 f" that domain already has a position. 'start' may" 9352 f" only be used with domains that don't yet have" 9353 f" any position information." 9354 ) 9355 9356 # Make the domain active 9357 if domain not in targetContext['activeDomains']: 9358 targetContext['activeDomains'].add(domain) 9359 9360 # Check position info matches focalization type and update 9361 # exploration statuses 9362 if isinstance(positionSpecifier, base.DecisionID): 9363 if targetFocalization != 'singular': 9364 raise BadStart( 9365 f"Invalid position specifier" 9366 f" {repr(positionSpecifier)} (type" 9367 f" {type(positionSpecifier)}). Domain" 9368 f" {repr(domain)} has {targetFocalization}" 9369 f" focalization." 9370 ) 9371 base.setExplorationStatus( 9372 updated, 9373 positionSpecifier, 9374 'exploring', 9375 upgradeOnly=True 9376 ) 9377 destIDs.add(positionSpecifier) 9378 elif isinstance(positionSpecifier, dict): 9379 if targetFocalization != 'plural': 9380 raise BadStart( 9381 f"Invalid position specifier" 9382 f" {repr(positionSpecifier)} (type" 9383 f" {type(positionSpecifier)}). Domain" 9384 f" {repr(domain)} has {targetFocalization}" 9385 f" focalization." 9386 ) 9387 destIDs |= set(positionSpecifier.values()) 9388 elif isinstance(positionSpecifier, set): 9389 if targetFocalization != 'spreading': 9390 raise BadStart( 9391 f"Invalid position specifier" 9392 f" {repr(positionSpecifier)} (type" 9393 f" {type(positionSpecifier)}). Domain" 9394 f" {repr(domain)} has {targetFocalization}" 9395 f" focalization." 9396 ) 9397 destIDs |= positionSpecifier 9398 else: 9399 raise TypeError( 9400 f"Invalid position specifier" 9401 f" {repr(positionSpecifier)} (type" 9402 f" {type(positionSpecifier)}). It must be a" 9403 f" DecisionID, a dictionary from FocalPointNames to" 9404 f" DecisionIDs, or a set of DecisionIDs, according" 9405 f" to the focalization of the relevant domain." 9406 ) 9407 9408 # Put specified position(s) in place 9409 # TODO: This cast is really silly... 9410 targetContext['activeDecisions'][domain] = cast( 9411 Union[ 9412 None, 9413 base.DecisionID, 9414 Dict[base.FocalPointName, Optional[base.DecisionID]], 9415 Set[base.DecisionID] 9416 ], 9417 positionSpecifier 9418 ) 9419 9420 # Set primary decision 9421 newState['primaryDecision'] = primary 9422 9423 # Set capabilities 9424 if capabilities is not None: 9425 targetContext['capabilities'] = capabilities 9426 9427 # Set mechanism states 9428 if mechanismStates is not None: 9429 newState['mechanisms'] = mechanismStates 9430 9431 # Set custom state 9432 if customState is not None: 9433 newState['custom'] = customState 9434 9435 elif action[0] in ('explore', 'take', 'warp'): # similar handling 9436 assert ( 9437 len(action) == 3 9438 or len(action) == 4 9439 or len(action) == 6 9440 or len(action) == 7 9441 ) 9442 # Set up necessary variables 9443 cSpec: base.ContextSpecifier = "active" 9444 fromID: Optional[base.DecisionID] = None 9445 takeTransition: Optional[base.Transition] = None 9446 outcomes: List[bool] = [] 9447 destID: base.DecisionID # No starting value as it's not optional 9448 moveInDomain: Optional[base.Domain] = None 9449 moveWhich: Optional[base.FocalPointName] = None 9450 9451 # Figure out target context 9452 if isinstance(action[1], str): 9453 if action[1] not in get_args(base.ContextSpecifier): 9454 raise InvalidActionError( 9455 f"Action specifies {repr(action[1])} context," 9456 f" but that's not a valid context specifier." 9457 f" The valid options are:" 9458 f"\n{repr(get_args(base.ContextSpecifier))}" 9459 ) 9460 else: 9461 cSpec = cast(base.ContextSpecifier, action[1]) 9462 else: # Must be a `FocalPointSpecifier` 9463 cSpec, moveInDomain, moveWhich = cast( 9464 base.FocalPointSpecifier, 9465 action[1] 9466 ) 9467 assert moveInDomain is not None 9468 9469 # Grab target context to work in 9470 if cSpec == 'common': 9471 targetContext = newState['common'] 9472 else: 9473 targetContext = newState['contexts'][ 9474 newState['activeContext'] 9475 ] 9476 9477 # Check focalization of the target domain 9478 if moveInDomain is not None: 9479 fType = base.getDomainFocalization( 9480 targetContext, 9481 moveInDomain 9482 ) 9483 if ( 9484 ( 9485 isinstance(action[1], str) 9486 and fType == 'plural' 9487 ) or ( 9488 not isinstance(action[1], str) 9489 and fType != 'plural' 9490 ) 9491 ): 9492 raise ImpossibleActionError( 9493 f"Invalid ExplorationAction (moves in" 9494 f" plural-focalized domains must include a" 9495 f" FocalPointSpecifier, while moves in" 9496 f" non-plural-focalized domains must not." 9497 f" Domain {repr(moveInDomain)} is" 9498 f" {fType}-focalized):" 9499 f"\n{repr(action)}" 9500 ) 9501 9502 if action[0] == "warp": 9503 # It's a warp, so destination is specified directly 9504 if not isinstance(action[2], base.DecisionID): 9505 raise TypeError( 9506 f"Invalid ExplorationAction tuple (third part" 9507 f" must be a decision ID for 'warp' actions):" 9508 f"\n{repr(action)}" 9509 ) 9510 else: 9511 destID = cast(base.DecisionID, action[2]) 9512 9513 elif aLen == 4 or aLen == 7: 9514 # direct 'take' or 'explore' 9515 fromID = cast(base.DecisionID, action[2]) 9516 takeTransition, outcomes = cast( 9517 base.TransitionWithOutcomes, 9518 action[3] # type: ignore [misc] 9519 ) 9520 if ( 9521 not isinstance(fromID, base.DecisionID) 9522 or not isinstance(takeTransition, base.Transition) 9523 ): 9524 raise InvalidActionError( 9525 f"Invalid ExplorationAction tuple (for 'take' or" 9526 f" 'explore', if the length is 4/7, parts 2-4" 9527 f" must be a context specifier, a decision ID, and a" 9528 f" transition name. Got:" 9529 f"\n{repr(action)}" 9530 ) 9531 9532 try: 9533 destID = newGraph.destination(fromID, takeTransition) 9534 except MissingDecisionError: 9535 raise ImpossibleActionError( 9536 f"Invalid ExplorationAction: move from decision" 9537 f" {fromID} is invalid because there is no" 9538 f" decision with that ID in the current" 9539 f" graph." 9540 f"\nValid decisions are:" 9541 f"\n{newGraph.namesListing(newGraph)}" 9542 ) 9543 except MissingTransitionError: 9544 valid = newGraph.destinationsFrom(fromID) 9545 listing = newGraph.destinationsListing(valid) 9546 raise ImpossibleActionError( 9547 f"Invalid ExplorationAction: move from decision" 9548 f" {newGraph.identityOf(fromID)}" 9549 f" along transition {repr(takeTransition)} is" 9550 f" invalid because there is no such transition" 9551 f" at that decision." 9552 f"\nValid transitions there are:" 9553 f"\n{listing}" 9554 ) 9555 targetActive = targetContext['activeDecisions'] 9556 if moveInDomain is not None: 9557 activeInDomain = targetActive[moveInDomain] 9558 if ( 9559 ( 9560 isinstance(activeInDomain, base.DecisionID) 9561 and fromID != activeInDomain 9562 ) 9563 or ( 9564 isinstance(activeInDomain, set) 9565 and fromID not in activeInDomain 9566 ) 9567 or ( 9568 isinstance(activeInDomain, dict) 9569 and fromID not in activeInDomain.values() 9570 ) 9571 ): 9572 raise ImpossibleActionError( 9573 f"Invalid ExplorationAction: move from" 9574 f" decision {fromID} is invalid because" 9575 f" that decision is not active in domain" 9576 f" {repr(moveInDomain)} in the current" 9577 f" graph." 9578 f"\nValid decisions are:" 9579 f"\n{newGraph.namesListing(newGraph)}" 9580 ) 9581 9582 elif aLen == 3 or aLen == 6: 9583 # 'take' or 'explore' focal point 9584 # We know that moveInDomain is not None here. 9585 assert moveInDomain is not None 9586 if not isinstance(action[2], base.Transition): 9587 raise InvalidActionError( 9588 f"Invalid ExplorationAction tuple (for 'take'" 9589 f" actions if the second part is a" 9590 f" FocalPointSpecifier the third part must be a" 9591 f" transition name):" 9592 f"\n{repr(action)}" 9593 ) 9594 9595 takeTransition, outcomes = cast( 9596 base.TransitionWithOutcomes, 9597 action[2] 9598 ) 9599 targetActive = targetContext['activeDecisions'] 9600 activeInDomain = cast( 9601 Dict[base.FocalPointName, Optional[base.DecisionID]], 9602 targetActive[moveInDomain] 9603 ) 9604 if ( 9605 moveInDomain is not None 9606 and ( 9607 not isinstance(activeInDomain, dict) 9608 or moveWhich not in activeInDomain 9609 ) 9610 ): 9611 raise ImpossibleActionError( 9612 f"Invalid ExplorationAction: move of focal" 9613 f" point {repr(moveWhich)} in domain" 9614 f" {repr(moveInDomain)} is invalid because" 9615 f" that domain does not have a focal point" 9616 f" with that name." 9617 ) 9618 fromID = activeInDomain[moveWhich] 9619 if fromID is None: 9620 raise ImpossibleActionError( 9621 f"Invalid ExplorationAction: move of focal" 9622 f" point {repr(moveWhich)} in domain" 9623 f" {repr(moveInDomain)} is invalid because" 9624 f" that focal point does not have a position" 9625 f" at this step." 9626 ) 9627 try: 9628 destID = newGraph.destination(fromID, takeTransition) 9629 except MissingDecisionError: 9630 raise ImpossibleActionError( 9631 f"Invalid exploration state: focal point" 9632 f" {repr(moveWhich)} in domain" 9633 f" {repr(moveInDomain)} specifies decision" 9634 f" {fromID} as the current position, but" 9635 f" that decision does not exist!" 9636 ) 9637 except MissingTransitionError: 9638 valid = newGraph.destinationsFrom(fromID) 9639 listing = newGraph.destinationsListing(valid) 9640 raise ImpossibleActionError( 9641 f"Invalid ExplorationAction: move of focal" 9642 f" point {repr(moveWhich)} in domain" 9643 f" {repr(moveInDomain)} along transition" 9644 f" {repr(takeTransition)} is invalid because" 9645 f" that focal point is at decision" 9646 f" {newGraph.identityOf(fromID)} and that" 9647 f" decision does not have an outgoing" 9648 f" transition with that name.\nValid" 9649 f" transitions from that decision are:" 9650 f"\n{listing}" 9651 ) 9652 9653 else: 9654 raise InvalidActionError( 9655 f"Invalid ExplorationAction: unrecognized" 9656 f" 'explore', 'take' or 'warp' format:" 9657 f"\n{action}" 9658 ) 9659 9660 # If we're exploring, update information for the destination 9661 if action[0] == 'explore': 9662 zone = cast(Optional[base.Zone], action[-1]) 9663 recipName = cast(Optional[base.Transition], action[-2]) 9664 destOrName = cast( 9665 Union[base.DecisionName, base.DecisionID, None], 9666 action[-3] 9667 ) 9668 if isinstance(destOrName, base.DecisionID): 9669 destID = destOrName 9670 9671 if fromID is None or takeTransition is None: 9672 raise ImpossibleActionError( 9673 f"Invalid ExplorationAction: exploration" 9674 f" has unclear origin decision or transition." 9675 f" Got:\n{action}" 9676 ) 9677 9678 currentDest = newGraph.destination(fromID, takeTransition) 9679 if not newGraph.isConfirmed(currentDest): 9680 newGraph.replaceUnconfirmed( 9681 fromID, 9682 takeTransition, 9683 destOrName, 9684 recipName, 9685 placeInZone=zone, 9686 forceNew=not isinstance(destOrName, base.DecisionID) 9687 ) 9688 else: 9689 # Otherwise, since the destination already existed 9690 # and was hooked up at the right decision, no graph 9691 # edits need to be made, unless we need to rename 9692 # the reciprocal. 9693 # TODO: Do we care about zones here? 9694 if recipName is not None: 9695 oldReciprocal = newGraph.getReciprocal( 9696 fromID, 9697 takeTransition 9698 ) 9699 if ( 9700 oldReciprocal is not None 9701 and oldReciprocal != recipName 9702 ): 9703 newGraph.addTransition( 9704 destID, 9705 recipName, 9706 fromID, 9707 None 9708 ) 9709 newGraph.setReciprocal( 9710 destID, 9711 recipName, 9712 takeTransition, 9713 setBoth=True 9714 ) 9715 newGraph.mergeTransitions( 9716 destID, 9717 oldReciprocal, 9718 recipName 9719 ) 9720 9721 # If we are moving along a transition, check requirements 9722 # and apply transition effects *before* updating our 9723 # position, and check that they don't cancel the normal 9724 # position update 9725 finalDest = None 9726 if takeTransition is not None: 9727 assert fromID is not None # both or neither 9728 if not self.isTraversable(fromID, takeTransition): 9729 req = now.graph.getTransitionRequirement( 9730 fromID, 9731 takeTransition 9732 ) 9733 # TODO: Alter warning message if transition is 9734 # deactivated vs. requirement not satisfied 9735 warnings.warn( 9736 ( 9737 f"The requirements for transition" 9738 f" {takeTransition!r} from decision" 9739 f" {now.graph.identityOf(fromID)} are" 9740 f" not met at step {len(self) - 1} (or that" 9741 f" transition has been deactivated):\n{req}" 9742 ), 9743 TransitionBlockedWarning 9744 ) 9745 9746 # Apply transition consequences to our new state and 9747 # figure out if we need to skip our normal update or not 9748 finalDest = self.applyTransitionConsequence( 9749 fromID, 9750 (takeTransition, outcomes), 9751 moveWhich, 9752 challengePolicy 9753 ) 9754 9755 # Check moveInDomain 9756 destDomain = newGraph.domainFor(destID) 9757 if moveInDomain is not None and moveInDomain != destDomain: 9758 raise ImpossibleActionError( 9759 f"Invalid ExplorationAction: move specified" 9760 f" domain {repr(moveInDomain)} as the domain of" 9761 f" the focal point to move, but the destination" 9762 f" of the move is {now.graph.identityOf(destID)}" 9763 f" which is in domain {repr(destDomain)}, so focal" 9764 f" point {repr(moveWhich)} cannot be moved there." 9765 ) 9766 9767 # Now that we know where we're going, update position 9768 # information (assuming it wasn't already set): 9769 if finalDest is None: 9770 finalDest = destID 9771 base.updatePosition( 9772 updated, 9773 destID, 9774 cSpec, 9775 moveWhich 9776 ) 9777 9778 destIDs.add(finalDest) 9779 9780 elif action[0] == "focus": 9781 # Figure out target context 9782 action = cast( 9783 Tuple[ 9784 Literal['focus'], 9785 base.ContextSpecifier, 9786 Set[base.Domain], 9787 Set[base.Domain] 9788 ], 9789 action 9790 ) 9791 contextSpecifier: base.ContextSpecifier = action[1] 9792 if contextSpecifier == 'common': 9793 targetContext = newState['common'] 9794 else: 9795 targetContext = newState['contexts'][ 9796 newState['activeContext'] 9797 ] 9798 9799 # Just need to swap out active domains 9800 goingOut, comingIn = cast( 9801 Tuple[Set[base.Domain], Set[base.Domain]], 9802 action[2:] 9803 ) 9804 if ( 9805 not isinstance(goingOut, set) 9806 or not isinstance(comingIn, set) 9807 or not all(isinstance(d, base.Domain) for d in goingOut) 9808 or not all(isinstance(d, base.Domain) for d in comingIn) 9809 ): 9810 raise InvalidActionError( 9811 f"Invalid ExplorationAction tuple (must have 4" 9812 f" parts if the first part is 'focus' and" 9813 f" the third and fourth parts must be sets of" 9814 f" domains):" 9815 f"\n{repr(action)}" 9816 ) 9817 activeSet = targetContext['activeDomains'] 9818 for dom in goingOut: 9819 try: 9820 activeSet.remove(dom) 9821 except KeyError: 9822 warnings.warn( 9823 ( 9824 f"Domain {repr(dom)} was deactivated at" 9825 f" step {len(self)} but it was already" 9826 f" inactive at that point." 9827 ), 9828 InactiveDomainWarning 9829 ) 9830 # TODO: Also warn for doubly-activated domains? 9831 activeSet |= comingIn 9832 9833 # destIDs remains empty in this case 9834 9835 elif action[0] == 'swap': # update which `FocalContext` is active 9836 newContext = cast(base.FocalContextName, action[1]) 9837 if newContext not in newState['contexts']: 9838 raise MissingFocalContextError( 9839 f"'swap' action with target {repr(newContext)} is" 9840 f" invalid because no context with that name" 9841 f" exists." 9842 ) 9843 newState['activeContext'] = newContext 9844 9845 # destIDs remains empty in this case 9846 9847 elif action[0] == 'focalize': # create new `FocalContext` 9848 newContext = cast(base.FocalContextName, action[1]) 9849 if newContext in newState['contexts']: 9850 raise FocalContextCollisionError( 9851 f"'focalize' action with target {repr(newContext)}" 9852 f" is invalid because a context with that name" 9853 f" already exists." 9854 ) 9855 newState['contexts'][newContext] = base.emptyFocalContext() 9856 newState['activeContext'] = newContext 9857 9858 # destIDs remains empty in this case 9859 9860 # revertTo is handled above 9861 else: 9862 raise InvalidActionError( 9863 f"Invalid ExplorationAction tuple (first item must be" 9864 f" an ExplorationActionType, and tuple must be length-1" 9865 f" if the action type is 'noAction'):" 9866 f"\n{repr(action)}" 9867 ) 9868 9869 # Apply any active triggers 9870 followTo = self.applyActiveTriggers() 9871 if followTo is not None: 9872 destIDs.add(followTo) 9873 # TODO: Re-work to work with multiple position updates in 9874 # different focal contexts, domains, and/or for different 9875 # focal points in plural-focalized domains. 9876 9877 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.
If any decisions are active in the ENDINGS_DOMAIN
, attempting
to 'warp', 'explore', 'take', or 'start' will raise an
InvalidActionError
.
9879 def applyActiveTriggers(self) -> Optional[base.DecisionID]: 9880 """ 9881 Finds all actions with the 'trigger' tag attached to currently 9882 active decisions, and applies their effects if their requirements 9883 are met (ordered by decision-ID with ties broken alphabetically 9884 by action name). 9885 9886 'bounce', 'goto' and 'follow' effects may apply. However, any 9887 new triggers that would be activated because of decisions 9888 reached by such effects will not apply. Note that 'bounce' 9889 effects update position to the decision where the action was 9890 attached, which is usually a no-op. This function returns the 9891 decision ID of the decision reached by the last decision-moving 9892 effect applied, or `None` if no such effects triggered. 9893 9894 TODO: What about situations where positions are updated in 9895 multiple domains or multiple foal points in a plural domain are 9896 independently updated? 9897 9898 TODO: Tests for this! 9899 """ 9900 active = self.getActiveDecisions() 9901 now = self.getSituation() 9902 graph = now.graph 9903 finalFollow = None 9904 for decision in sorted(active): 9905 for action in graph.decisionActions(decision): 9906 if ( 9907 'trigger' in graph.transitionTags(decision, action) 9908 and self.isTraversable(decision, action) 9909 ): 9910 followTo = self.applyTransitionConsequence( 9911 decision, 9912 action 9913 ) 9914 if followTo is not None: 9915 # TODO: How will triggers interact with 9916 # plural-focalized domains? Probably need to fix 9917 # this to detect moveWhich based on which focal 9918 # points are at the decision where the transition 9919 # is, and then apply this to each of them? 9920 base.updatePosition(now, followTo) 9921 finalFollow = followTo 9922 9923 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!
9925 def explore( 9926 self, 9927 transition: base.AnyTransition, 9928 destination: Union[base.DecisionName, base.DecisionID, None], 9929 reciprocal: Optional[base.Transition] = None, 9930 zone: Optional[base.Zone] = base.DefaultZone, 9931 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 9932 whichFocus: Optional[base.FocalPointSpecifier] = None, 9933 inCommon: Union[bool, Literal["auto"]] = "auto", 9934 decisionType: base.DecisionType = "active", 9935 challengePolicy: base.ChallengePolicy = "specified" 9936 ) -> base.DecisionID: 9937 """ 9938 Adds a new situation to the exploration representing the 9939 traversal of the specified transition (possibly with outcomes 9940 specified for challenges among that transitions consequences). 9941 Uses `deduceTransitionDetailsAtStep` to figure out from the 9942 transition name which specific transition is taken (and which 9943 focal point is updated if necessary). This uses the 9944 `fromDecision`, `whichFocus`, and `inCommon` optional 9945 parameters, and also determines whether to update the common or 9946 the active `FocalContext`. Sets the exploration status of the 9947 decision explored to 'exploring'. Returns the decision ID for 9948 the destination reached, accounting for goto/bounce/follow 9949 effects that might have triggered. 9950 9951 The `destination` will be used to name the newly-explored 9952 decision, except when it's a `DecisionID`, in which case that 9953 decision must be unvisited, and we'll connect the specified 9954 transition to that decision. 9955 9956 The focalization of the destination domain in the context to be 9957 updated determines how active decisions are changed: 9958 9959 - If the destination domain is focalized as 'single', then in 9960 the subsequent `Situation`, the destination decision will 9961 become the single active decision in that domain. 9962 - If it's focalized as 'plural', then one of the 9963 `FocalPointName`s for that domain will be moved to activate 9964 that decision; which one can be specified using `whichFocus` 9965 or if left unspecified, will be deduced: if the starting 9966 decision is in the same domain, then the 9967 alphabetically-earliest focal point which is at the starting 9968 decision will be moved. If the starting position is in a 9969 different domain, then the alphabetically earliest focal 9970 point among all focal points in the destination domain will 9971 be moved. 9972 - If it's focalized as 'spreading', then the destination 9973 decision will be added to the set of active decisions in 9974 that domain, without removing any. 9975 9976 The transition named must have been pointing to an unvisited 9977 decision (see `hasBeenVisited`), and the name of that decision 9978 will be updated if a `destination` value is given (a 9979 `DecisionCollisionWarning` will be issued if the destination 9980 name is a duplicate of another name in the graph, although this 9981 is not an error). Additionally: 9982 9983 - If a `reciprocal` name is specified, the reciprocal transition 9984 will be renamed using that name, or created with that name if 9985 it didn't already exist. If reciprocal is left as `None` (the 9986 default) then no change will be made to the reciprocal 9987 transition, and it will not be created if it doesn't exist. 9988 - If a `zone` is specified, the newly-explored decision will be 9989 added to that zone (and that zone will be created at level 0 9990 if it didn't already exist). If `zone` is set to `None` then 9991 it will not be added to any new zones. If `zone` is left as 9992 the default (the `base.DefaultZone` value) then the explored 9993 decision will be added to each zone that the decision it was 9994 explored from is a part of. If a zone needs to be created, 9995 that zone will be added as a sub-zone of each zone which is a 9996 parent of a zone that directly contains the origin decision. 9997 - An `ExplorationStatusError` will be raised if the specified 9998 transition leads to a decision whose `ExplorationStatus` is 9999 'exploring' or higher (i.e., `hasBeenVisited`). (Use 10000 `returnTo` instead to adjust things when a transition to an 10001 unknown destination turns out to lead to an already-known 10002 destination.) 10003 - A `TransitionBlockedWarning` will be issued if the specified 10004 transition is not traversable given the current game state 10005 (but in that last case the step will still be taken). 10006 - By default, the decision type for the new step will be 10007 'active', but a `decisionType` value can be specified to 10008 override that. 10009 - By default, the 'mostLikely' `ChallengePolicy` will be used to 10010 resolve challenges in the consequence of the transition 10011 taken, but an alternate policy can be supplied using the 10012 `challengePolicy` argument. 10013 """ 10014 now = self.getSituation() 10015 10016 transitionName, outcomes = base.nameAndOutcomes(transition) 10017 10018 # Deduce transition details from the name + optional specifiers 10019 ( 10020 using, 10021 fromID, 10022 destID, 10023 whichFocus 10024 ) = self.deduceTransitionDetailsAtStep( 10025 -1, 10026 transitionName, 10027 fromDecision, 10028 whichFocus, 10029 inCommon 10030 ) 10031 10032 # Issue a warning if the destination name is already in use 10033 if destination is not None: 10034 if isinstance(destination, base.DecisionName): 10035 try: 10036 existingID = now.graph.resolveDecision(destination) 10037 collision = existingID != destID 10038 except MissingDecisionError: 10039 collision = False 10040 except AmbiguousDecisionSpecifierError: 10041 collision = True 10042 10043 if collision and WARN_OF_NAME_COLLISIONS: 10044 warnings.warn( 10045 ( 10046 f"The destination name {repr(destination)} is" 10047 f" already in use when exploring transition" 10048 f" {repr(transition)} from decision" 10049 f" {now.graph.identityOf(fromID)} at step" 10050 f" {len(self) - 1}." 10051 ), 10052 DecisionCollisionWarning 10053 ) 10054 10055 # TODO: Different terminology for "exploration state above 10056 # noticed" vs. "DG thinks it's been visited"... 10057 if ( 10058 self.hasBeenVisited(destID) 10059 ): 10060 raise ExplorationStatusError( 10061 f"Cannot explore to decision" 10062 f" {now.graph.identityOf(destID)} because it has" 10063 f" already been visited. Use returnTo instead of" 10064 f" explore when discovering a connection back to a" 10065 f" previously-explored decision." 10066 ) 10067 10068 if ( 10069 isinstance(destination, base.DecisionID) 10070 and self.hasBeenVisited(destination) 10071 ): 10072 raise ExplorationStatusError( 10073 f"Cannot explore to decision" 10074 f" {now.graph.identityOf(destination)} because it has" 10075 f" already been visited. Use returnTo instead of" 10076 f" explore when discovering a connection back to a" 10077 f" previously-explored decision." 10078 ) 10079 10080 actionTaken: base.ExplorationAction = ( 10081 'explore', 10082 using, 10083 fromID, 10084 (transitionName, outcomes), 10085 destination, 10086 reciprocal, 10087 zone 10088 ) 10089 if whichFocus is not None: 10090 # A move-from-specific-focal-point action 10091 actionTaken = ( 10092 'explore', 10093 whichFocus, 10094 (transitionName, outcomes), 10095 destination, 10096 reciprocal, 10097 zone 10098 ) 10099 10100 # Advance the situation, applying transition effects and 10101 # updating the destination decision. 10102 _, finalDest = self.advanceSituation( 10103 actionTaken, 10104 decisionType, 10105 challengePolicy 10106 ) 10107 10108 # TODO: Is this assertion always valid? 10109 assert len(finalDest) == 1 10110 return next(x for x in finalDest)
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.
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
FocalPointName
s for that domain will be moved to activate that decision; which one can be specified usingwhichFocus
or if left unspecified, will be deduced: if the starting decision is in the same domain, then the alphabetically-earliest focal point which is at the starting decision will be moved. If the starting position is in a different domain, then the alphabetically earliest focal point among all focal points in the destination domain will be moved. - If it's focalized as 'spreading', then the destination decision will be added to the set of active decisions in that domain, without removing any.
The transition named must have been pointing to an unvisited
decision (see hasBeenVisited
), and the name of that decision
will be updated if a destination
value is given (a
DecisionCollisionWarning
will be issued if the destination
name is a duplicate of another name in the graph, although this
is not an error). Additionally:
- If a
reciprocal
name is specified, the reciprocal transition will be renamed using that name, or created with that name if it didn't already exist. If reciprocal is left 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
zone
is specified, the newly-explored decision will be added to that zone (and that zone will be created at level 0 if it didn't already exist). Ifzone
is set toNone
then it will not be added to any new zones. Ifzone
is left as the default (thebase.DefaultZone
value) then the explored decision will be added to each zone that the decision it was explored from is a part of. If a zone needs to be created, that zone will be added as a sub-zone of each zone which is a parent of a zone that directly contains the origin decision. - An
ExplorationStatusError
will be raised if the specified transition leads to a decision whoseExplorationStatus
is 'exploring' or higher (i.e.,hasBeenVisited
). (UsereturnTo
instead to adjust things when a transition to an unknown destination turns out to lead to an already-known destination.) - A
TransitionBlockedWarning
will be issued if the specified transition is not traversable given the current game state (but in that last case the step will still be taken). - By default, the decision type for the new step will be
'active', but a
decisionType
value can be specified to override that. - By default, the 'mostLikely'
ChallengePolicy
will be used to resolve challenges in the consequence of the transition taken, but an alternate policy can be supplied using thechallengePolicy
argument.
10112 def returnTo( 10113 self, 10114 transition: base.AnyTransition, 10115 destination: base.AnyDecisionSpecifier, 10116 reciprocal: Optional[base.Transition] = None, 10117 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 10118 whichFocus: Optional[base.FocalPointSpecifier] = None, 10119 inCommon: Union[bool, Literal["auto"]] = "auto", 10120 decisionType: base.DecisionType = "active", 10121 challengePolicy: base.ChallengePolicy = "specified" 10122 ) -> base.DecisionID: 10123 """ 10124 Adds a new graph to the exploration that replaces the given 10125 transition at the current position (which must lead to an unknown 10126 node, or a `MissingDecisionError` will result). The new 10127 transition will connect back to the specified destination, which 10128 must already exist (or a different `ValueError` will be raised). 10129 Returns the decision ID for the destination reached. 10130 10131 Deduces transition details using the optional `fromDecision`, 10132 `whichFocus`, and `inCommon` arguments in addition to the 10133 `transition` value; see `deduceTransitionDetailsAtStep`. 10134 10135 If a `reciprocal` transition is specified, that transition must 10136 either not already exist in the destination decision or lead to 10137 an unknown region; it will be replaced (or added) as an edge 10138 leading back to the current position. 10139 10140 The `decisionType` and `challengePolicy` optional arguments are 10141 used for `advanceSituation`. 10142 10143 A `TransitionBlockedWarning` will be issued if the requirements 10144 for the transition are not met, but the step will still be taken. 10145 Raises a `MissingDecisionError` if there is no current 10146 transition. 10147 """ 10148 now = self.getSituation() 10149 10150 transitionName, outcomes = base.nameAndOutcomes(transition) 10151 10152 # Deduce transition details from the name + optional specifiers 10153 ( 10154 using, 10155 fromID, 10156 destID, 10157 whichFocus 10158 ) = self.deduceTransitionDetailsAtStep( 10159 -1, 10160 transitionName, 10161 fromDecision, 10162 whichFocus, 10163 inCommon 10164 ) 10165 10166 # Replace with connection to existing destination 10167 destID = now.graph.resolveDecision(destination) 10168 if not self.hasBeenVisited(destID): 10169 raise ExplorationStatusError( 10170 f"Cannot return to decision" 10171 f" {now.graph.identityOf(destID)} because it has NOT" 10172 f" already been at least partially explored. Use" 10173 f" explore instead of returnTo when discovering a" 10174 f" connection to a previously-unexplored decision." 10175 ) 10176 10177 now.graph.replaceUnconfirmed( 10178 fromID, 10179 transitionName, 10180 destID, 10181 reciprocal 10182 ) 10183 10184 # A move-from-decision action 10185 actionTaken: base.ExplorationAction = ( 10186 'take', 10187 using, 10188 fromID, 10189 (transitionName, outcomes) 10190 ) 10191 if whichFocus is not None: 10192 # A move-from-specific-focal-point action 10193 actionTaken = ('take', whichFocus, (transitionName, outcomes)) 10194 10195 # Next, advance the situation, applying transition effects 10196 _, finalDest = self.advanceSituation( 10197 actionTaken, 10198 decisionType, 10199 challengePolicy 10200 ) 10201 10202 assert len(finalDest) == 1 10203 return next(x for x in finalDest)
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.
10205 def takeAction( 10206 self, 10207 action: base.AnyTransition, 10208 requires: Optional[base.Requirement] = None, 10209 consequence: Optional[base.Consequence] = None, 10210 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 10211 whichFocus: Optional[base.FocalPointSpecifier] = None, 10212 inCommon: Union[bool, Literal["auto"]] = "auto", 10213 decisionType: base.DecisionType = "active", 10214 challengePolicy: base.ChallengePolicy = "specified" 10215 ) -> base.DecisionID: 10216 """ 10217 Adds a new graph to the exploration based on taking the given 10218 action, which must be a self-transition in the graph. If the 10219 action does not already exist in the graph, it will be created. 10220 Either way if requirements and/or a consequence are supplied, 10221 the requirements and consequence of the action will be updated 10222 to match them, and those are the requirements/consequence that 10223 will count. 10224 10225 Returns the decision ID for the decision reached, which normally 10226 is the same action you were just at, but which might be altered 10227 by goto, bounce, and/or follow effects. 10228 10229 Issues a `TransitionBlockedWarning` if the current game state 10230 doesn't satisfy the requirements for the action. 10231 10232 The `fromDecision`, `whichFocus`, and `inCommon` arguments are 10233 used for `deduceTransitionDetailsAtStep`, while `decisionType` 10234 and `challengePolicy` are used for `advanceSituation`. 10235 10236 When an action is being created, `fromDecision` (or 10237 `whichFocus`) must be specified, since the source decision won't 10238 be deducible from the transition name. Note that if a transition 10239 with the given name exists from *any* active decision, it will 10240 be used instead of creating a new action (possibly resulting in 10241 an error if it's not a self-loop transition). Also, you may get 10242 an `AmbiguousTransitionError` if several transitions with that 10243 name exist; in that case use `fromDecision` and/or `whichFocus` 10244 to disambiguate. 10245 """ 10246 now = self.getSituation() 10247 graph = now.graph 10248 10249 actionName, outcomes = base.nameAndOutcomes(action) 10250 10251 try: 10252 ( 10253 using, 10254 fromID, 10255 destID, 10256 whichFocus 10257 ) = self.deduceTransitionDetailsAtStep( 10258 -1, 10259 actionName, 10260 fromDecision, 10261 whichFocus, 10262 inCommon 10263 ) 10264 10265 if destID != fromID: 10266 raise ValueError( 10267 f"Cannot take action {repr(action)} because it's a" 10268 f" transition to another decision, not an action" 10269 f" (use explore, returnTo, and/or retrace instead)." 10270 ) 10271 10272 except MissingTransitionError: 10273 using = 'active' 10274 if inCommon is True: 10275 using = 'common' 10276 10277 if fromDecision is not None: 10278 fromID = graph.resolveDecision(fromDecision) 10279 elif whichFocus is not None: 10280 maybeFromID = base.resolvePosition(now, whichFocus) 10281 if maybeFromID is None: 10282 raise MissingDecisionError( 10283 f"Focal point {repr(whichFocus)} was specified" 10284 f" in takeAction but that focal point doesn't" 10285 f" have a position." 10286 ) 10287 else: 10288 fromID = maybeFromID 10289 else: 10290 raise AmbiguousTransitionError( 10291 f"Taking action {repr(action)} is ambiguous because" 10292 f" the source decision has not been specified via" 10293 f" either fromDecision or whichFocus, and we" 10294 f" couldn't find an existing action with that name." 10295 ) 10296 10297 # Since the action doesn't exist, add it: 10298 graph.addAction(fromID, actionName, requires, consequence) 10299 10300 # Update the transition requirement/consequence if requested 10301 # (before the action is taken) 10302 if requires is not None: 10303 graph.setTransitionRequirement(fromID, actionName, requires) 10304 if consequence is not None: 10305 graph.setConsequence(fromID, actionName, consequence) 10306 10307 # A move-from-decision action 10308 actionTaken: base.ExplorationAction = ( 10309 'take', 10310 using, 10311 fromID, 10312 (actionName, outcomes) 10313 ) 10314 if whichFocus is not None: 10315 # A move-from-specific-focal-point action 10316 actionTaken = ('take', whichFocus, (actionName, outcomes)) 10317 10318 _, finalDest = self.advanceSituation( 10319 actionTaken, 10320 decisionType, 10321 challengePolicy 10322 ) 10323 10324 assert len(finalDest) in (0, 1) 10325 if len(finalDest) == 1: 10326 return next(x for x in finalDest) 10327 else: 10328 return fromID
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.
10330 def retrace( 10331 self, 10332 transition: base.AnyTransition, 10333 fromDecision: Optional[base.AnyDecisionSpecifier] = None, 10334 whichFocus: Optional[base.FocalPointSpecifier] = None, 10335 inCommon: Union[bool, Literal["auto"]] = "auto", 10336 decisionType: base.DecisionType = "active", 10337 challengePolicy: base.ChallengePolicy = "specified" 10338 ) -> base.DecisionID: 10339 """ 10340 Adds a new graph to the exploration based on taking the given 10341 transition, which must already exist and which must not lead to 10342 an unknown region. Returns the ID of the destination decision, 10343 accounting for goto, bounce, and/or follow effects. 10344 10345 Issues a `TransitionBlockedWarning` if the current game state 10346 doesn't satisfy the requirements for the transition. 10347 10348 The `fromDecision`, `whichFocus`, and `inCommon` arguments are 10349 used for `deduceTransitionDetailsAtStep`, while `decisionType` 10350 and `challengePolicy` are used for `advanceSituation`. 10351 """ 10352 now = self.getSituation() 10353 10354 transitionName, outcomes = base.nameAndOutcomes(transition) 10355 10356 ( 10357 using, 10358 fromID, 10359 destID, 10360 whichFocus 10361 ) = self.deduceTransitionDetailsAtStep( 10362 -1, 10363 transitionName, 10364 fromDecision, 10365 whichFocus, 10366 inCommon 10367 ) 10368 10369 visited = self.hasBeenVisited(destID) 10370 confirmed = now.graph.isConfirmed(destID) 10371 if not confirmed: 10372 raise ExplorationStatusError( 10373 f"Cannot retrace transition {transition!r} from" 10374 f" decision {now.graph.identityOf(fromID)} because it" 10375 f" leads to an unconfirmed decision.\nUse" 10376 f" `DiscreteExploration.explore` and provide" 10377 f" destination decision details instead." 10378 ) 10379 if not visited: 10380 raise ExplorationStatusError( 10381 f"Cannot retrace transition {transition!r} from" 10382 f" decision {now.graph.identityOf(fromID)} because it" 10383 f" leads to an unvisited decision.\nUse" 10384 f" `DiscreteExploration.explore` and provide" 10385 f" destination decision details instead." 10386 ) 10387 10388 # A move-from-decision action 10389 actionTaken: base.ExplorationAction = ( 10390 'take', 10391 using, 10392 fromID, 10393 (transitionName, outcomes) 10394 ) 10395 if whichFocus is not None: 10396 # A move-from-specific-focal-point action 10397 actionTaken = ('take', whichFocus, (transitionName, outcomes)) 10398 10399 _, finalDest = self.advanceSituation( 10400 actionTaken, 10401 decisionType, 10402 challengePolicy 10403 ) 10404 10405 assert len(finalDest) == 1 10406 return next(x for x in finalDest)
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
.
10408 def warp( 10409 self, 10410 destination: base.AnyDecisionSpecifier, 10411 consequence: Optional[base.Consequence] = None, 10412 domain: Optional[base.Domain] = None, 10413 zone: Optional[base.Zone] = base.DefaultZone, 10414 whichFocus: Optional[base.FocalPointSpecifier] = None, 10415 inCommon: Union[bool] = False, 10416 decisionType: base.DecisionType = "active", 10417 challengePolicy: base.ChallengePolicy = "specified" 10418 ) -> base.DecisionID: 10419 """ 10420 Adds a new graph to the exploration that's a copy of the current 10421 graph, with the position updated to be at the destination without 10422 actually creating a transition from the old position to the new 10423 one. Returns the ID of the decision warped to (accounting for 10424 any goto or follow effects triggered). 10425 10426 Any provided consequences are applied, but are not associated 10427 with any transition (so any delays and charges are ignored, and 10428 'bounce' effects don't actually cancel the warp). 'goto' or 10429 'follow' effects might change the warp destination; 'follow' 10430 effects take the original destination as their starting point. 10431 Any mechanisms mentioned in extra consequences will be found 10432 based on the destination. Outcomes in supplied challenges should 10433 be pre-specified, or else they will be resolved with the 10434 `challengePolicy`. 10435 10436 `whichFocus` may be specified when the destination domain's 10437 focalization is 'plural' but for 'singular' or 'spreading' 10438 destination domains it is not allowed. `inCommon` determines 10439 whether the common or the active focal context is updated 10440 (default is to update the active context). The `decisionType` 10441 and `challengePolicy` are used for `advanceSituation`. 10442 10443 - If the destination did not already exist, it will be created. 10444 Initially, it will be disconnected from all other decisions. 10445 In this case, the `domain` value can be used to put it in a 10446 non-default domain. 10447 - The position is set to the specified destination, and if a 10448 `consequence` is specified it is applied. Note that 10449 'deactivate' effects are NOT allowed, and 'edit' effects 10450 must establish their own transition target because there is 10451 no transition that the effects are being applied to. 10452 - If the destination had been unexplored, its exploration status 10453 will be set to 'exploring'. 10454 - If a `zone` is specified, the destination will be added to that 10455 zone (even if the destination already existed) and that zone 10456 will be created (as a level-0 zone) if need be. If `zone` is 10457 set to `None`, then no zone will be applied. If `zone` is 10458 left as the default (`base.DefaultZone`) and the 10459 focalization of the destination domain is 'singular' or 10460 'plural' and the destination is newly created and there is 10461 an origin and the origin is in the same domain as the 10462 destination, then the destination will be added to all zones 10463 that the origin was a part of if the destination is newly 10464 created, but otherwise the destination will not be added to 10465 any zones. If the specified zone has to be created and 10466 there's an origin decision, it will be added as a sub-zone 10467 to all parents of zones directly containing the origin, as 10468 long as the origin is in the same domain as the destination. 10469 """ 10470 now = self.getSituation() 10471 graph = now.graph 10472 10473 fromID: Optional[base.DecisionID] 10474 10475 new = False 10476 try: 10477 destID = graph.resolveDecision(destination) 10478 except MissingDecisionError: 10479 if isinstance(destination, tuple): 10480 # just the name; ignore zone/domain 10481 destination = destination[-1] 10482 10483 if not isinstance(destination, base.DecisionName): 10484 raise TypeError( 10485 f"Warp destination {repr(destination)} does not" 10486 f" exist, and cannot be created as it is not a" 10487 f" decision name." 10488 ) 10489 destID = graph.addDecision(destination, domain) 10490 graph.tagDecision(destID, 'unconfirmed') 10491 self.setExplorationStatus(destID, 'unknown') 10492 new = True 10493 10494 using: base.ContextSpecifier 10495 if inCommon: 10496 targetContext = self.getCommonContext() 10497 using = "common" 10498 else: 10499 targetContext = self.getActiveContext() 10500 using = "active" 10501 10502 destDomain = graph.domainFor(destID) 10503 targetFocalization = base.getDomainFocalization( 10504 targetContext, 10505 destDomain 10506 ) 10507 if targetFocalization == 'singular': 10508 targetActive = targetContext['activeDecisions'] 10509 if destDomain in targetActive: 10510 fromID = cast( 10511 base.DecisionID, 10512 targetContext['activeDecisions'][destDomain] 10513 ) 10514 else: 10515 fromID = None 10516 elif targetFocalization == 'plural': 10517 if whichFocus is None: 10518 raise AmbiguousTransitionError( 10519 f"Warping to {repr(destination)} is ambiguous" 10520 f" becuase domain {repr(destDomain)} has plural" 10521 f" focalization, and no whichFocus value was" 10522 f" specified." 10523 ) 10524 10525 fromID = base.resolvePosition( 10526 self.getSituation(), 10527 whichFocus 10528 ) 10529 else: 10530 fromID = None 10531 10532 # Handle zones 10533 if zone == base.DefaultZone: 10534 if ( 10535 new 10536 and fromID is not None 10537 and graph.domainFor(fromID) == destDomain 10538 ): 10539 for prevZone in graph.zoneParents(fromID): 10540 graph.addDecisionToZone(destination, prevZone) 10541 # Otherwise don't update zones 10542 elif zone is not None: 10543 # Newness is ignored when a zone is specified 10544 zone = cast(base.Zone, zone) 10545 # Create the zone at level 0 if it didn't already exist 10546 if graph.getZoneInfo(zone) is None: 10547 graph.createZone(zone, 0) 10548 # Add the newly created zone to each 2nd-level parent of 10549 # the previous decision if there is one and it's in the 10550 # same domain 10551 if ( 10552 fromID is not None 10553 and graph.domainFor(fromID) == destDomain 10554 ): 10555 for prevZone in graph.zoneParents(fromID): 10556 for prevUpper in graph.zoneParents(prevZone): 10557 graph.addZoneToZone(zone, prevUpper) 10558 # Finally add the destination to the (maybe new) zone 10559 graph.addDecisionToZone(destID, zone) 10560 # else don't touch zones 10561 10562 # Encode the action taken 10563 actionTaken: base.ExplorationAction 10564 if whichFocus is None: 10565 actionTaken = ( 10566 'warp', 10567 using, 10568 destID 10569 ) 10570 else: 10571 actionTaken = ( 10572 'warp', 10573 whichFocus, 10574 destID 10575 ) 10576 10577 # Advance the situation 10578 _, finalDests = self.advanceSituation( 10579 actionTaken, 10580 decisionType, 10581 challengePolicy 10582 ) 10583 now = self.getSituation() # updating just in case 10584 10585 assert len(finalDests) == 1 10586 finalDest = next(x for x in finalDests) 10587 10588 # Apply additional consequences: 10589 if consequence is not None: 10590 altDest = self.applyExtraneousConsequence( 10591 consequence, 10592 where=(destID, None), 10593 # TODO: Mechanism search from both ends? 10594 moveWhich=( 10595 whichFocus[-1] 10596 if whichFocus is not None 10597 else None 10598 ) 10599 ) 10600 if altDest is not None: 10601 finalDest = altDest 10602 now = self.getSituation() # updating just in case 10603 10604 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.
Initially, it will be disconnected from all other decisions.
In this case, the
domain
value can be used to put it in a non-default domain. - The position is set to the specified destination, and if a
consequence
is specified it is applied. Note that 'deactivate' effects are NOT allowed, and 'edit' effects must establish their own transition target because there is no transition that the effects are being applied to. - If the destination had been unexplored, its exploration status will be set to 'exploring'.
- If a
zone
is specified, the destination will be added to that zone (even if the destination already existed) and that zone will be created (as a level-0 zone) if need be. Ifzone
is set toNone
, then no zone will be applied. Ifzone
is left as the default (base.DefaultZone
) and the focalization of the destination domain is 'singular' or 'plural' and the destination is newly created and there is an origin and the origin is in the same domain as the destination, then the destination will be added to all zones that the origin was a part of if the destination is newly created, but otherwise the destination will not be added to any zones. If the specified zone has to be created and there's an origin decision, it will be added as a sub-zone to all parents of zones directly containing the origin, as long as the origin is in the same domain as the destination.
10606 def wait( 10607 self, 10608 consequence: Optional[base.Consequence] = None, 10609 decisionType: base.DecisionType = "active", 10610 challengePolicy: base.ChallengePolicy = "specified" 10611 ) -> Optional[base.DecisionID]: 10612 """ 10613 Adds a wait step. If a consequence is specified, it is applied, 10614 although it will not have any position/transition information 10615 available during resolution/application. 10616 10617 A decision type other than "active" and/or a challenge policy 10618 other than "specified" can be included (see `advanceSituation`). 10619 10620 The "pending" decision type may not be used, a `ValueError` will 10621 result. This allows None as the action for waiting while 10622 preserving the pending/None type/action combination for 10623 unresolved situations. 10624 10625 If a goto or follow effect in the applied consequence implies a 10626 position update, this will return the new destination ID; 10627 otherwise it will return `None`. Triggering a 'bounce' effect 10628 will be an error, because there is no position information for 10629 the effect. 10630 """ 10631 if decisionType == "pending": 10632 raise ValueError( 10633 "The 'pending' decision type may not be used for" 10634 " wait actions." 10635 ) 10636 self.advanceSituation(('noAction',), decisionType, challengePolicy) 10637 now = self.getSituation() 10638 if consequence is not None: 10639 if challengePolicy != "specified": 10640 base.resetChallengeOutcomes(consequence) 10641 observed = base.observeChallengeOutcomes( 10642 base.RequirementContext( 10643 state=now.state, 10644 graph=now.graph, 10645 searchFrom=set() 10646 ), 10647 consequence, 10648 location=None, # No position info 10649 policy=challengePolicy, 10650 knownOutcomes=None # bake outcomes into the consequence 10651 ) 10652 # No location information since we might have multiple 10653 # active decisions and there's no indication of which one 10654 # we're "waiting at." 10655 finalDest = self.applyExtraneousConsequence(observed) 10656 now = self.getSituation() # updating just in case 10657 10658 return finalDest 10659 else: 10660 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.
10662 def revert( 10663 self, 10664 slot: base.SaveSlot = base.DEFAULT_SAVE_SLOT, 10665 aspects: Optional[Set[str]] = None, 10666 decisionType: base.DecisionType = "active" 10667 ) -> None: 10668 """ 10669 Reverts the game state to a previously-saved game state (saved 10670 via a 'save' effect). The save slot name and set of aspects to 10671 revert are required. By default, all aspects except the graph 10672 are reverted. 10673 """ 10674 if aspects is None: 10675 aspects = set() 10676 10677 action: base.ExplorationAction = ("revertTo", slot, aspects) 10678 10679 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.
10681 def observeAll( 10682 self, 10683 where: base.AnyDecisionSpecifier, 10684 *transitions: Union[ 10685 base.Transition, 10686 Tuple[base.Transition, base.AnyDecisionSpecifier], 10687 Tuple[ 10688 base.Transition, 10689 base.AnyDecisionSpecifier, 10690 base.Transition 10691 ] 10692 ] 10693 ) -> List[base.DecisionID]: 10694 """ 10695 Observes one or more new transitions, applying changes to the 10696 current graph. The transitions can be specified in one of three 10697 ways: 10698 10699 1. A transition name. The transition will be created and will 10700 point to a new unexplored node. 10701 2. A pair containing a transition name and a destination 10702 specifier. If the destination does not exist it will be 10703 created as an unexplored node, although in that case the 10704 decision specifier may not be an ID. 10705 3. A triple containing a transition name, a destination 10706 specifier, and a reciprocal name. Works the same as the pair 10707 case but also specifies the name for the reciprocal 10708 transition. 10709 10710 The new transitions are outgoing from specified decision. 10711 10712 Yields the ID of each decision connected to, whether those are 10713 new or existing decisions. 10714 """ 10715 now = self.getSituation() 10716 fromID = now.graph.resolveDecision(where) 10717 result = [] 10718 for entry in transitions: 10719 if isinstance(entry, base.Transition): 10720 result.append(self.observe(fromID, entry)) 10721 else: 10722 result.append(self.observe(fromID, *entry)) 10723 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.
10725 def observe( 10726 self, 10727 where: base.AnyDecisionSpecifier, 10728 transition: base.Transition, 10729 destination: Optional[base.AnyDecisionSpecifier] = None, 10730 reciprocal: Optional[base.Transition] = None 10731 ) -> base.DecisionID: 10732 """ 10733 Observes a single new outgoing transition from the specified 10734 decision. If specified the transition connects to a specific 10735 destination and/or has a specific reciprocal. The specified 10736 destination will be created if it doesn't exist, or where no 10737 destination is specified, a new unexplored decision will be 10738 added. The ID of the decision connected to is returned. 10739 10740 Sets the exploration status of the observed destination to 10741 "noticed" if a destination is specified and needs to be created 10742 (but not when no destination is specified). 10743 10744 For example: 10745 10746 >>> e = DiscreteExploration() 10747 >>> e.start('start') 10748 0 10749 >>> e.observe('start', 'up') 10750 1 10751 >>> g = e.getSituation().graph 10752 >>> g.destinationsFrom('start') 10753 {'up': 1} 10754 >>> e.getExplorationStatus(1) # not given a name: assumed unknown 10755 'unknown' 10756 >>> e.observe('start', 'left', 'A') 10757 2 10758 >>> g.destinationsFrom('start') 10759 {'up': 1, 'left': 2} 10760 >>> g.nameFor(2) 10761 'A' 10762 >>> e.getExplorationStatus(2) # given a name: noticed 10763 'noticed' 10764 >>> e.observe('start', 'up2', 1) 10765 1 10766 >>> g.destinationsFrom('start') 10767 {'up': 1, 'left': 2, 'up2': 1} 10768 >>> e.getExplorationStatus(1) # existing decision: status unchanged 10769 'unknown' 10770 >>> e.observe('start', 'right', 'B', 'left') 10771 3 10772 >>> g.destinationsFrom('start') 10773 {'up': 1, 'left': 2, 'up2': 1, 'right': 3} 10774 >>> g.nameFor(3) 10775 'B' 10776 >>> e.getExplorationStatus(3) # new + name -> noticed 10777 'noticed' 10778 >>> e.observe('start', 'right') # repeat transition name 10779 Traceback (most recent call last): 10780 ... 10781 exploration.core.TransitionCollisionError... 10782 >>> e.observe('start', 'right2', 'B', 'left') # repeat reciprocal 10783 Traceback (most recent call last): 10784 ... 10785 exploration.core.TransitionCollisionError... 10786 >>> g = e.getSituation().graph 10787 >>> g.createZone('Z', 0) 10788 ZoneInfo(level=0, parents=set(), contents=set(), tags={},\ 10789 annotations=[]) 10790 >>> g.addDecisionToZone('start', 'Z') 10791 >>> e.observe('start', 'down', 'C', 'up') 10792 4 10793 >>> g.destinationsFrom('start') 10794 {'up': 1, 'left': 2, 'up2': 1, 'right': 3, 'down': 4} 10795 >>> g.identityOf('C') 10796 '4 (C)' 10797 >>> g.zoneParents(4) # not in any zones, 'cause still unexplored 10798 set() 10799 >>> e.observe( 10800 ... 'C', 10801 ... 'right', 10802 ... base.DecisionSpecifier('main', 'Z2', 'D'), 10803 ... ) # creates zone 10804 5 10805 >>> g.destinationsFrom('C') 10806 {'up': 0, 'right': 5} 10807 >>> g.destinationsFrom('D') # default reciprocal name 10808 {'return': 4} 10809 >>> g.identityOf('D') 10810 '5 (Z2::D)' 10811 >>> g.zoneParents(5) 10812 {'Z2'} 10813 """ 10814 now = self.getSituation() 10815 fromID = now.graph.resolveDecision(where) 10816 10817 kwargs: Dict[ 10818 str, 10819 Union[base.Transition, base.DecisionName, None] 10820 ] = {} 10821 if reciprocal is not None: 10822 kwargs['reciprocal'] = reciprocal 10823 10824 if destination is not None: 10825 try: 10826 destID = now.graph.resolveDecision(destination) 10827 now.graph.addTransition( 10828 fromID, 10829 transition, 10830 destID, 10831 reciprocal 10832 ) 10833 return destID 10834 except MissingDecisionError: 10835 if isinstance(destination, base.DecisionSpecifier): 10836 kwargs['toDomain'] = destination.domain 10837 kwargs['placeInZone'] = destination.zone 10838 kwargs['destinationName'] = destination.name 10839 elif isinstance(destination, base.DecisionName): 10840 kwargs['destinationName'] = destination 10841 else: 10842 assert isinstance(destination, base.DecisionID) 10843 # We got to except by failing to resolve, so it's an 10844 # invalid ID 10845 raise 10846 10847 result = now.graph.addUnexploredEdge( 10848 fromID, 10849 transition, 10850 **kwargs # type: ignore [arg-type] 10851 ) 10852 if 'destinationName' in kwargs: 10853 self.setExplorationStatus(result, 'noticed', upgradeOnly=True) 10854 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') # default reciprocal name
{'return': 4}
>>> g.identityOf('D')
'5 (Z2::D)'
>>> g.zoneParents(5)
{'Z2'}
10856 def observeMechanisms( 10857 self, 10858 where: Optional[base.AnyDecisionSpecifier], 10859 *mechanisms: Union[ 10860 base.MechanismName, 10861 Tuple[base.MechanismName, base.MechanismState] 10862 ] 10863 ) -> List[base.MechanismID]: 10864 """ 10865 Adds one or more mechanisms to the exploration's current graph, 10866 located at the specified decision. Global mechanisms can be 10867 added by using `None` for the location. Mechanisms are named, or 10868 a (name, state) tuple can be used to set them into a specific 10869 state. Mechanisms not set to a state will be in the 10870 `base.DEFAULT_MECHANISM_STATE`. 10871 """ 10872 now = self.getSituation() 10873 result = [] 10874 for mSpec in mechanisms: 10875 setState = None 10876 if isinstance(mSpec, base.MechanismName): 10877 result.append(now.graph.addMechanism(mSpec, where)) 10878 elif ( 10879 isinstance(mSpec, tuple) 10880 and len(mSpec) == 2 10881 and isinstance(mSpec[0], base.MechanismName) 10882 and isinstance(mSpec[1], base.MechanismState) 10883 ): 10884 result.append(now.graph.addMechanism(mSpec[0], where)) 10885 setState = mSpec[1] 10886 else: 10887 raise TypeError( 10888 f"Invalid mechanism: {repr(mSpec)} (must be a" 10889 f" mechanism name or a (name, state) tuple." 10890 ) 10891 10892 if setState: 10893 self.setMechanismStateNow(result[-1], setState) 10894 10895 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
.
10897 def reZone( 10898 self, 10899 zone: base.Zone, 10900 where: base.AnyDecisionSpecifier, 10901 replace: Union[base.Zone, int] = 0 10902 ) -> None: 10903 """ 10904 Alters the current graph without adding a new exploration step. 10905 10906 Calls `DecisionGraph.replaceZonesInHierarchy` targeting the 10907 specified decision. Note that per the logic of that method, ALL 10908 zones at the specified hierarchy level are replaced, even if a 10909 specific zone to replace is specified here. 10910 10911 TODO: not that? 10912 10913 The level value is either specified via `replace` (default 0) or 10914 deduced from the zone provided as the `replace` value using 10915 `DecisionGraph.zoneHierarchyLevel`. 10916 """ 10917 now = self.getSituation() 10918 10919 if isinstance(replace, int): 10920 level = replace 10921 else: 10922 level = now.graph.zoneHierarchyLevel(replace) 10923 10924 now.graph.replaceZonesInHierarchy(where, zone, level)
Alters the current graph without adding a new exploration step.
Calls DecisionGraph.replaceZonesInHierarchy
targeting the
specified decision. Note that per the logic of that method, ALL
zones at the specified hierarchy level are replaced, even if a
specific zone to replace is specified here.
TODO: not that?
The level value is either specified via replace
(default 0) or
deduced from the zone provided as the replace
value using
DecisionGraph.zoneHierarchyLevel
.
10926 def runCommand( 10927 self, 10928 command: commands.Command, 10929 scope: Optional[commands.Scope] = None, 10930 line: int = -1 10931 ) -> commands.CommandResult: 10932 """ 10933 Runs a single `Command` applying effects to the exploration, its 10934 current graph, and the provided execution context, and returning 10935 a command result, which contains the modified scope plus 10936 optional skip and label values (see `CommandResult`). This 10937 function also directly modifies the scope you give it. Variable 10938 references in the command are resolved via entries in the 10939 provided scope. If no scope is given, an empty one is created. 10940 10941 A line number may be supplied for use in error messages; if left 10942 out line -1 will be used. 10943 10944 Raises an error if the command is invalid. 10945 10946 For commands that establish a value as the 'current value', that 10947 value will be stored in the '_' variable. When this happens, the 10948 old contents of '_' are stored in '__' first, and the old 10949 contents of '__' are discarded. Note that non-automatic 10950 assignment to '_' does not move the old value to '__'. 10951 """ 10952 try: 10953 if scope is None: 10954 scope = {} 10955 10956 skip: Union[int, str, None] = None 10957 label: Optional[str] = None 10958 10959 if command.command == 'val': 10960 command = cast(commands.LiteralValue, command) 10961 result = commands.resolveValue(command.value, scope) 10962 commands.pushCurrentValue(scope, result) 10963 10964 elif command.command == 'empty': 10965 command = cast(commands.EstablishCollection, command) 10966 collection = commands.resolveVarName(command.collection, scope) 10967 commands.pushCurrentValue( 10968 scope, 10969 { 10970 'list': [], 10971 'tuple': (), 10972 'set': set(), 10973 'dict': {}, 10974 }[collection] 10975 ) 10976 10977 elif command.command == 'append': 10978 command = cast(commands.AppendValue, command) 10979 target = scope['_'] 10980 addIt = commands.resolveValue(command.value, scope) 10981 if isinstance(target, list): 10982 target.append(addIt) 10983 elif isinstance(target, tuple): 10984 scope['_'] = target + (addIt,) 10985 elif isinstance(target, set): 10986 target.add(addIt) 10987 elif isinstance(target, dict): 10988 raise TypeError( 10989 "'append' command cannot be used with a" 10990 " dictionary. Use 'set' instead." 10991 ) 10992 else: 10993 raise TypeError( 10994 f"Invalid current value for 'append' command." 10995 f" The current value must be a list, tuple, or" 10996 f" set, but it was a '{type(target).__name__}'." 10997 ) 10998 10999 elif command.command == 'set': 11000 command = cast(commands.SetValue, command) 11001 target = scope['_'] 11002 where = commands.resolveValue(command.location, scope) 11003 what = commands.resolveValue(command.value, scope) 11004 if isinstance(target, list): 11005 if not isinstance(where, int): 11006 raise TypeError( 11007 f"Cannot set item in list: index {where!r}" 11008 f" is not an integer." 11009 ) 11010 target[where] = what 11011 elif isinstance(target, tuple): 11012 if not isinstance(where, int): 11013 raise TypeError( 11014 f"Cannot set item in tuple: index {where!r}" 11015 f" is not an integer." 11016 ) 11017 if not ( 11018 0 <= where < len(target) 11019 or -1 >= where >= -len(target) 11020 ): 11021 raise IndexError( 11022 f"Cannot set item in tuple at index" 11023 f" {where}: Tuple has length {len(target)}." 11024 ) 11025 scope['_'] = target[:where] + (what,) + target[where + 1:] 11026 elif isinstance(target, set): 11027 if what: 11028 target.add(where) 11029 else: 11030 try: 11031 target.remove(where) 11032 except KeyError: 11033 pass 11034 elif isinstance(target, dict): 11035 target[where] = what 11036 11037 elif command.command == 'pop': 11038 command = cast(commands.PopValue, command) 11039 target = scope['_'] 11040 if isinstance(target, list): 11041 result = target.pop() 11042 commands.pushCurrentValue(scope, result) 11043 elif isinstance(target, tuple): 11044 result = target[-1] 11045 updated = target[:-1] 11046 scope['__'] = updated 11047 scope['_'] = result 11048 else: 11049 raise TypeError( 11050 f"Cannot 'pop' from a {type(target).__name__}" 11051 f" (current value must be a list or tuple)." 11052 ) 11053 11054 elif command.command == 'get': 11055 command = cast(commands.GetValue, command) 11056 target = scope['_'] 11057 where = commands.resolveValue(command.location, scope) 11058 if isinstance(target, list): 11059 if not isinstance(where, int): 11060 raise TypeError( 11061 f"Cannot get item from list: index" 11062 f" {where!r} is not an integer." 11063 ) 11064 elif isinstance(target, tuple): 11065 if not isinstance(where, int): 11066 raise TypeError( 11067 f"Cannot get item from tuple: index" 11068 f" {where!r} is not an integer." 11069 ) 11070 elif isinstance(target, set): 11071 result = where in target 11072 commands.pushCurrentValue(scope, result) 11073 elif isinstance(target, dict): 11074 result = target[where] 11075 commands.pushCurrentValue(scope, result) 11076 else: 11077 result = getattr(target, where) 11078 commands.pushCurrentValue(scope, result) 11079 11080 elif command.command == 'remove': 11081 command = cast(commands.RemoveValue, command) 11082 target = scope['_'] 11083 where = commands.resolveValue(command.location, scope) 11084 if isinstance(target, (list, tuple)): 11085 # this cast is not correct but suppresses warnings 11086 # given insufficient narrowing by MyPy 11087 target = cast(Tuple[Any, ...], target) 11088 if not isinstance(where, int): 11089 raise TypeError( 11090 f"Cannot remove item from list or tuple:" 11091 f" index {where!r} is not an integer." 11092 ) 11093 scope['_'] = target[:where] + target[where + 1:] 11094 elif isinstance(target, set): 11095 target.remove(where) 11096 elif isinstance(target, dict): 11097 del target[where] 11098 else: 11099 raise TypeError( 11100 f"Cannot use 'remove' on a/an" 11101 f" {type(target).__name__}." 11102 ) 11103 11104 elif command.command == 'op': 11105 command = cast(commands.ApplyOperator, command) 11106 left = commands.resolveValue(command.left, scope) 11107 right = commands.resolveValue(command.right, scope) 11108 op = command.op 11109 if op == '+': 11110 result = left + right 11111 elif op == '-': 11112 result = left - right 11113 elif op == '*': 11114 result = left * right 11115 elif op == '/': 11116 result = left / right 11117 elif op == '//': 11118 result = left // right 11119 elif op == '**': 11120 result = left ** right 11121 elif op == '%': 11122 result = left % right 11123 elif op == '^': 11124 result = left ^ right 11125 elif op == '|': 11126 result = left | right 11127 elif op == '&': 11128 result = left & right 11129 elif op == 'and': 11130 result = left and right 11131 elif op == 'or': 11132 result = left or right 11133 elif op == '<': 11134 result = left < right 11135 elif op == '>': 11136 result = left > right 11137 elif op == '<=': 11138 result = left <= right 11139 elif op == '>=': 11140 result = left >= right 11141 elif op == '==': 11142 result = left == right 11143 elif op == 'is': 11144 result = left is right 11145 else: 11146 raise RuntimeError("Invalid operator '{op}'.") 11147 11148 commands.pushCurrentValue(scope, result) 11149 11150 elif command.command == 'unary': 11151 command = cast(commands.ApplyUnary, command) 11152 value = commands.resolveValue(command.value, scope) 11153 op = command.op 11154 if op == '-': 11155 result = -value 11156 elif op == '~': 11157 result = ~value 11158 elif op == 'not': 11159 result = not value 11160 11161 commands.pushCurrentValue(scope, result) 11162 11163 elif command.command == 'assign': 11164 command = cast(commands.VariableAssignment, command) 11165 varname = commands.resolveVarName(command.varname, scope) 11166 value = commands.resolveValue(command.value, scope) 11167 scope[varname] = value 11168 11169 elif command.command == 'delete': 11170 command = cast(commands.VariableDeletion, command) 11171 varname = commands.resolveVarName(command.varname, scope) 11172 del scope[varname] 11173 11174 elif command.command == 'load': 11175 command = cast(commands.LoadVariable, command) 11176 varname = commands.resolveVarName(command.varname, scope) 11177 commands.pushCurrentValue(scope, scope[varname]) 11178 11179 elif command.command == 'call': 11180 command = cast(commands.FunctionCall, command) 11181 function = command.function 11182 if function.startswith('$'): 11183 function = commands.resolveValue(function, scope) 11184 11185 toCall: Callable 11186 args: Tuple[str, ...] 11187 kwargs: Dict[str, Any] 11188 11189 if command.target == 'builtin': 11190 toCall = commands.COMMAND_BUILTINS[function] 11191 args = (scope['_'],) 11192 kwargs = {} 11193 if toCall == round: 11194 if 'ndigits' in scope: 11195 kwargs['ndigits'] = scope['ndigits'] 11196 elif toCall == range and args[0] is None: 11197 start = scope.get('start', 0) 11198 stop = scope['stop'] 11199 step = scope.get('step', 1) 11200 args = (start, stop, step) 11201 11202 else: 11203 if command.target == 'stored': 11204 toCall = function 11205 elif command.target == 'graph': 11206 toCall = getattr(self.getSituation().graph, function) 11207 elif command.target == 'exploration': 11208 toCall = getattr(self, function) 11209 else: 11210 raise TypeError( 11211 f"Invalid call target '{command.target}'" 11212 f" (must be one of 'builtin', 'stored'," 11213 f" 'graph', or 'exploration'." 11214 ) 11215 11216 # Fill in arguments via kwargs defined in scope 11217 args = () 11218 kwargs = {} 11219 signature = inspect.signature(toCall) 11220 # TODO: Maybe try some type-checking here? 11221 for argName, param in signature.parameters.items(): 11222 if param.kind == inspect.Parameter.VAR_POSITIONAL: 11223 if argName in scope: 11224 args = args + tuple(scope[argName]) 11225 # Else leave args as-is 11226 elif param.kind == inspect.Parameter.KEYWORD_ONLY: 11227 # These must have a default 11228 if argName in scope: 11229 kwargs[argName] = scope[argName] 11230 elif param.kind == inspect.Parameter.VAR_KEYWORD: 11231 # treat as a dictionary 11232 if argName in scope: 11233 argsToUse = scope[argName] 11234 if not isinstance(argsToUse, dict): 11235 raise TypeError( 11236 f"Variable '{argName}' must" 11237 f" hold a dictionary when" 11238 f" calling function" 11239 f" '{toCall.__name__} which" 11240 f" uses that argument as a" 11241 f" keyword catchall." 11242 ) 11243 kwargs.update(scope[argName]) 11244 else: # a normal parameter 11245 if argName in scope: 11246 args = args + (scope[argName],) 11247 elif param.default == inspect.Parameter.empty: 11248 raise TypeError( 11249 f"No variable named '{argName}' has" 11250 f" been defined to supply the" 11251 f" required parameter with that" 11252 f" name for function" 11253 f" '{toCall.__name__}'." 11254 ) 11255 11256 result = toCall(*args, **kwargs) 11257 commands.pushCurrentValue(scope, result) 11258 11259 elif command.command == 'skip': 11260 command = cast(commands.SkipCommands, command) 11261 doIt = commands.resolveValue(command.condition, scope) 11262 if doIt: 11263 skip = commands.resolveValue(command.amount, scope) 11264 if not isinstance(skip, (int, str)): 11265 raise TypeError( 11266 f"Skip amount must be an integer or a label" 11267 f" name (got {skip!r})." 11268 ) 11269 11270 elif command.command == 'label': 11271 command = cast(commands.Label, command) 11272 label = commands.resolveValue(command.name, scope) 11273 if not isinstance(label, str): 11274 raise TypeError( 11275 f"Label name must be a string (got {label!r})." 11276 ) 11277 11278 else: 11279 raise ValueError( 11280 f"Invalid command type: {command.command!r}" 11281 ) 11282 except ValueError as e: 11283 raise commands.CommandValueError(command, line, e) 11284 except TypeError as e: 11285 raise commands.CommandTypeError(command, line, e) 11286 except IndexError as e: 11287 raise commands.CommandIndexError(command, line, e) 11288 except KeyError as e: 11289 raise commands.CommandKeyError(command, line, e) 11290 except Exception as e: 11291 raise commands.CommandOtherError(command, line, e) 11292 11293 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 '__'.
11295 def runCommandBlock( 11296 self, 11297 block: List[commands.Command], 11298 scope: Optional[commands.Scope] = None 11299 ) -> commands.Scope: 11300 """ 11301 Runs a list of commands, using the given scope (or creating a new 11302 empty scope if none was provided). Returns the scope after 11303 running all of the commands, which may also edit the exploration 11304 and/or the current graph of course. 11305 11306 Note that if a skip command would skip past the end of the 11307 block, execution will end. If a skip command would skip before 11308 the beginning of the block, execution will start from the first 11309 command. 11310 11311 Example: 11312 11313 >>> e = DiscreteExploration() 11314 >>> scope = e.runCommandBlock([ 11315 ... commands.command('assign', 'decision', "'START'"), 11316 ... commands.command('call', 'exploration', 'start'), 11317 ... commands.command('assign', 'where', '$decision'), 11318 ... commands.command('assign', 'transition', "'left'"), 11319 ... commands.command('call', 'exploration', 'observe'), 11320 ... commands.command('assign', 'transition', "'right'"), 11321 ... commands.command('call', 'exploration', 'observe'), 11322 ... commands.command('call', 'graph', 'destinationsFrom'), 11323 ... commands.command('call', 'builtin', 'print'), 11324 ... commands.command('assign', 'transition', "'right'"), 11325 ... commands.command('assign', 'destination', "'EastRoom'"), 11326 ... commands.command('call', 'exploration', 'explore'), 11327 ... ]) 11328 {'left': 1, 'right': 2} 11329 >>> scope['decision'] 11330 'START' 11331 >>> scope['where'] 11332 'START' 11333 >>> scope['_'] # result of 'explore' call is dest ID 11334 2 11335 >>> scope['transition'] 11336 'right' 11337 >>> scope['destination'] 11338 'EastRoom' 11339 >>> g = e.getSituation().graph 11340 >>> len(e) 11341 3 11342 >>> len(g) 11343 3 11344 >>> g.namesListing(g) 11345 ' 0 (START)\\n 1 (_u.0)\\n 2 (EastRoom)\\n' 11346 """ 11347 if scope is None: 11348 scope = {} 11349 11350 labelPositions: Dict[str, List[int]] = {} 11351 11352 # Keep going until we've exhausted the commands list 11353 index = 0 11354 while index < len(block): 11355 11356 # Execute the next command 11357 scope, skip, label = self.runCommand( 11358 block[index], 11359 scope, 11360 index + 1 11361 ) 11362 11363 # Increment our index, or apply a skip 11364 if skip is None: 11365 index = index + 1 11366 11367 elif isinstance(skip, int): # Integer skip value 11368 if skip < 0: 11369 index += skip 11370 if index < 0: # can't skip before the start 11371 index = 0 11372 else: 11373 index += skip + 1 # may end loop if we skip too far 11374 11375 else: # must be a label name 11376 if skip in labelPositions: # an established label 11377 # We jump to the last previous index, or if there 11378 # are none, to the first future index. 11379 prevIndices = [ 11380 x 11381 for x in labelPositions[skip] 11382 if x < index 11383 ] 11384 futureIndices = [ 11385 x 11386 for x in labelPositions[skip] 11387 if x >= index 11388 ] 11389 if len(prevIndices) > 0: 11390 index = max(prevIndices) 11391 else: 11392 index = min(futureIndices) 11393 else: # must be a forward-reference 11394 for future in range(index + 1, len(block)): 11395 inspect = block[future] 11396 if inspect.command == 'label': 11397 inspect = cast(commands.Label, inspect) 11398 if inspect.name == skip: 11399 index = future 11400 break 11401 else: 11402 raise KeyError( 11403 f"Skip command indicated a jump to label" 11404 f" {skip!r} but that label had not already" 11405 f" been defined and there is no future" 11406 f" label with that name either (future" 11407 f" labels based on variables cannot be" 11408 f" skipped to from above as their names" 11409 f" are not known yet)." 11410 ) 11411 11412 # If there's a label, record it 11413 if label is not None: 11414 labelPositions.setdefault(label, []).append(index) 11415 11416 # And now the while loop continues, or ends if we're at the 11417 # end of the commands list. 11418 11419 # Return the scope object. 11420 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'
11422 @staticmethod 11423 def example() -> 'DiscreteExploration': 11424 """ 11425 Returns a little example exploration. Has a few decisions 11426 including one that's unexplored, and uses a few steps to explore 11427 them. 11428 11429 >>> e = DiscreteExploration.example() 11430 >>> len(e) 11431 7 11432 >>> def pg(n): 11433 ... print(e[n].graph.namesListing(e[n].graph)) 11434 >>> pg(0) 11435 0 (House) 11436 <BLANKLINE> 11437 >>> pg(1) 11438 0 (House) 11439 1 (_u.0) 11440 2 (_u.1) 11441 3 (_u.2) 11442 <BLANKLINE> 11443 >>> pg(2) 11444 0 (House) 11445 1 (_u.0) 11446 2 (_u.1) 11447 3 (Yard) 11448 4 (_u.3) 11449 5 (_u.4) 11450 <BLANKLINE> 11451 >>> pg(3) 11452 0 (House) 11453 1 (_u.0) 11454 2 (_u.1) 11455 3 (Yard) 11456 4 (_u.3) 11457 5 (_u.4) 11458 <BLANKLINE> 11459 >>> pg(4) 11460 0 (House) 11461 1 (_u.0) 11462 2 (Cellar) 11463 3 (Yard) 11464 5 (_u.4) 11465 <BLANKLINE> 11466 >>> pg(5) 11467 0 (House) 11468 1 (_u.0) 11469 2 (Cellar) 11470 3 (Yard) 11471 5 (_u.4) 11472 <BLANKLINE> 11473 >>> pg(6) 11474 0 (House) 11475 1 (_u.0) 11476 2 (Cellar) 11477 3 (Yard) 11478 5 (Lane) 11479 <BLANKLINE> 11480 """ 11481 result = DiscreteExploration() 11482 result.start("House") 11483 result.observeAll("House", "ladder", "stairsDown", "frontDoor") 11484 result.explore("frontDoor", "Yard", "frontDoor") 11485 result.observe("Yard", "cellarDoors") 11486 result.observe("Yard", "frontGate") 11487 result.retrace("frontDoor") 11488 result.explore("stairsDown", "Cellar", "stairsUp") 11489 result.observe("Cellar", "stairsOut") 11490 result.returnTo("stairsOut", "Yard", "cellarDoors") 11491 result.explore("frontGate", "Lane", "redGate") 11492 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>