exploration.analysis

  • Authors: Peter Mawhorter
  • Consulted:
  • Date: 2022-10-24
  • Purpose: Analysis functions for decision graphs an explorations.
  1"""
  2- Authors: Peter Mawhorter
  3- Consulted:
  4- Date: 2022-10-24
  5- Purpose: Analysis functions for decision graphs an explorations.
  6"""
  7
  8from typing import (
  9    List, Dict, Tuple, Optional, TypeVar, Callable, Union, Any,
 10    ParamSpec, Concatenate, Set, cast
 11)
 12
 13from . import base, core, parsing
 14
 15import textwrap
 16
 17
 18#-------------------#
 19# Text descriptions #
 20#-------------------#
 21
 22def describeConsequence(consequence: base.Consequence) -> str:
 23    """
 24    Returns a string which concisely describes a consequence list.
 25    Returns an empty string if given an empty consequence. Examples:
 26
 27    >>> describeConsequence([])
 28    ''
 29    >>> describeConsequence([
 30    ...     base.effect(gain=('gold', 5), delay=2, charges=3),
 31    ...     base.effect(lose='flight')
 32    ... ])
 33    'gain gold*5 ,2 =3; lose flight'
 34    >>> from . import commands
 35    >>> d = describeConsequence([
 36    ...     base.effect(edit=[
 37    ...         [
 38    ...             commands.command('val', '5'),
 39    ...             commands.command('empty', 'list'),
 40    ...             commands.command('append')
 41    ...         ],
 42    ...         [
 43    ...             commands.command('val', '11'),
 44    ...             commands.command('assign', 'var'),
 45    ...             commands.command('op', '+', '$var', '$var')
 46    ...         ],
 47    ...     ])
 48    ... ])
 49    >>> d
 50    'with consequences:\
 51\\n    edit {\
 52\\n      val 5;\
 53\\n      empty list;\
 54\\n      append $_;\
 55\\n    } {\
 56\\n      val 11;\
 57\\n      assign var $_;\
 58\\n      op + $var $var;\
 59\\n    }\
 60\\n'
 61    >>> for line in d.splitlines():
 62    ...     print(line)
 63    with consequences:
 64        edit {
 65          val 5;
 66          empty list;
 67          append $_;
 68        } {
 69          val 11;
 70          assign var $_;
 71          op + $var $var;
 72        }
 73    """
 74    edesc = ''
 75    pf = parsing.ParseFormat()
 76    if consequence:
 77        parts = []
 78        for item in consequence:
 79            # TODO: Challenges and Conditions here!
 80            if 'skills' in item:  # a Challenge
 81                item = cast(base.Challenge, item)
 82                parts.append(pf.unparseChallenge(item))
 83            elif 'value' in item:  # an Effect
 84                item = cast(base.Effect, item)
 85                parts.append(pf.unparseEffect(item))
 86            elif 'condition' in item:  # a Condition
 87                item = cast(base.Condition, item)
 88                parts.append(pf.unparseCondition(item))
 89            else:
 90                raise TypeError(
 91                    f"Invalid consequence item (no 'skills', 'value', or"
 92                    f" 'condition' key found):\n{repr(item)}"
 93                )
 94        edesc = '; '.join(parts)
 95        if len(edesc) > 60 or '\n' in edesc:
 96            edesc = 'with consequences:\n' + ';\n'.join(
 97                textwrap.indent(part, '    ')
 98                for part in parts
 99            ) + '\n'
100
101    return edesc
102
103
104def describeProgress(exploration: core.DiscreteExploration) -> str:
105    """
106    Describes the progress of an exploration by noting each room/zone
107    visited and explaining the options visible at each point plus which
108    option was taken. Notes powers/tokens gained/lost along the way.
109    Returns a string.
110
111    Example:
112    >>> from exploration import journal
113    >>> e = journal.convertJournal('''\\
114    ... S Start::pit
115    ... A gain jump
116    ... A gain attack
117    ... n button check
118    ... zz Wilds
119    ... o up
120    ...   q _flight
121    ... o left
122    ... x left left_nook right
123    ... a geo_rock
124    ...   At gain geo*15
125    ...   At deactivate
126    ... o up
127    ...   q _tall_narrow
128    ... t right
129    ... o right
130    ...   q attack
131    ... ''')
132    >>> for line in describeProgress(e).splitlines():
133    ...    print(line)
134    Start of the exploration
135    Start exploring domain main at 0 (Start::pit)
136      Gained capability 'attack'
137      Gained capability 'jump'
138    At decision 0 (Start::pit)
139      In zone Start
140      In region Wilds
141      There are transitions:
142        left to unconfirmed
143        up to unconfirmed; requires _flight
144      1 note(s) at this step
145    Explore left from decision 0 (Start::pit) to 2 (now Start::left_nook)
146    At decision 2 (Start::left_nook)
147      There are transitions:
148        right to 0 (Start::pit)
149      There are actions:
150        geo_rock
151    Do action geo_rock
152      Gained 15 geo(s)
153    Take right from decision 2 (Start::left_nook) to 0 (Start::pit)
154    At decision 0 (Start::pit)
155      There are transitions:
156        left to 2 (Start::left_nook)
157        right to unconfirmed; requires attack
158        up to unconfirmed; requires _flight
159    Waiting for another action...
160    End of the exploration.
161    """
162    result = ''
163
164    regions: Set[base.Zone] = set()
165    zones: Set[base.Zone] = set()
166    last: Union[base.DecisionID, Set[base.DecisionID], None] = None
167    lastState: base.State = base.emptyState()
168    prevCapabilities = base.effectiveCapabilitySet(lastState)
169    prevMechanisms = lastState['mechanisms']
170    oldActiveDecisions: Set[base.DecisionID] = set()
171    for i, situation in enumerate(exploration):
172        if i == 0:
173            result += "Start of the exploration\n"
174
175        # Extract info
176        graph = situation.graph
177        activeDecisions = exploration.getActiveDecisions(i)
178        newActive = activeDecisions - oldActiveDecisions
179        departedFrom = exploration.movementAtStep(i)[0]
180        # TODO: use the other parts of this?
181        nowZones: Set[base.Zone] = set()
182        for active in activeDecisions:
183            nowZones |= graph.zoneAncestors(active)
184        regionsHere = set(
185            z
186            for z in nowZones
187            if graph.zoneHierarchyLevel(z) == 1
188        )
189        zonesHere = set(
190            z
191            for z in nowZones
192            if graph.zoneHierarchyLevel(z) == 0
193        )
194        here = departedFrom
195        state = situation.state
196        capabilities = base.effectiveCapabilitySet(state)
197        mechanisms = state['mechanisms']
198
199        # Describe capabilities gained/lost relative to previous step
200        # (i.e., as a result of the previous action)
201        gained = (
202            capabilities['capabilities']
203          - prevCapabilities['capabilities']
204        )
205        gainedTokens = []
206        for tokenType in capabilities['tokens']:
207            net = (
208                capabilities['tokens'][tokenType]
209              - prevCapabilities['tokens'].get(tokenType, 0)
210            )
211            if net != 0:
212                gainedTokens.append((tokenType, net))
213        changed = [
214            mID
215            for mID in list(mechanisms.keys()) + list(prevMechanisms.keys())
216            if mechanisms.get(mID) != prevMechanisms.get(mID)
217        ]
218
219        for capability in sorted(gained):
220            result += f"  Gained capability '{capability}'\n"
221
222        for tokenType, net in gainedTokens:
223            if net > 0:
224                result += f"  Gained {net} {tokenType}(s)\n"
225            else:
226                result += f"  Lost {-net} {tokenType}(s)\n"
227
228        for mID in changed:
229            oldState = prevMechanisms.get(mID, base.DEFAULT_MECHANISM_STATE)
230            newState = mechanisms.get(mID, base.DEFAULT_MECHANISM_STATE)
231
232            details = graph.mechanismDetails(mID)
233            if details is None:
234                mName = "(unknown)"
235            else:
236                mName = details[1]
237            result += (
238                f"  Set mechanism {mID} ({mName}) to {newState} (was"
239                f" {oldState})"
240            )
241            # TODO: Test this!
242
243        if isinstance(departedFrom, base.DecisionID):
244            # Print location info
245            if here != last:
246                if here is None:
247                    result += "Without a position...\n"
248                elif isinstance(here, set):
249                    result += f"With {len(here)} active decisions\n"
250                    # TODO: List them using namesListing?
251                else:
252                    result += f"At decision {graph.identityOf(here)}\n"
253            newZones = zonesHere - zones
254            for zone in sorted(newZones):
255                result += f"  In zone {zone}\n"
256            newRegions = regionsHere - regions
257            for region in sorted(newRegions):
258                result += f"  In region {region}\n"
259
260        elif isinstance(departedFrom, set):  # active in spreading domain
261            spreadingDomain = graph.domainFor(list(departedFrom)[0])
262            result += (
263                f"  In domain {spreadingDomain} with {len(departedFrom)}"
264                f" active decisions...\n"
265            )
266
267        else:
268            assert departedFrom is None
269
270        # Describe new position/positions at start of this step
271        if len(newActive) > 1:
272            newListing = ', '.join(
273                sorted(graph.identityOf(n) for n in newActive)
274            )
275            result += (
276                f"  There are {len(newActive)} new active decisions:"
277                f"\n  {newListing}"
278            )
279
280        elif len(newActive) == 1:
281            here = list(newActive)[0]
282
283            outgoing = graph.destinationsFrom(here)
284
285            transitions = {t: d for (t, d) in outgoing.items() if d != here}
286            actions = {t: d for (t, d) in outgoing.items() if d == here}
287            if transitions:
288                result += "  There are transitions:\n"
289                for transition in sorted(transitions):
290                    dest = transitions[transition]
291                    if not graph.isConfirmed(dest):
292                        destSpec = 'unconfirmed'
293                    else:
294                        destSpec = graph.identityOf(dest)
295                    req = graph.getTransitionRequirement(here, transition)
296                    rDesc = ''
297                    if req != base.ReqNothing():
298                        rDesc = f"; requires {req.unparse()}"
299                    cDesc = describeConsequence(
300                        graph.getConsequence(here, transition)
301                    )
302                    if cDesc:
303                        cDesc = '; ' + cDesc
304                    result += (
305                        f"    {transition} to {destSpec}{rDesc}{cDesc}\n"
306                    )
307
308            if actions:
309                result += "  There are actions:\n"
310                for action in sorted(actions):
311                    req = graph.getTransitionRequirement(here, action)
312                    rDesc = ''
313                    if req != base.ReqNothing():
314                        rDesc = f"; requires {req.unparse()}"
315                    cDesc = describeConsequence(
316                        graph.getConsequence(here, action)
317                    )
318                    if cDesc:
319                        cDesc = '; ' + cDesc
320                    if rDesc or cDesc:
321                        desc = (rDesc + cDesc)[2:]  # chop '; ' from either
322                        result += f"    {action} ({desc})\n"
323                    else:
324                        result += f"    {action}\n"
325
326        # note annotations
327        if len(situation.annotations) > 0:
328            result += (
329                f"  {len(situation.annotations)} note(s) at this step\n"
330            )
331
332        # Describe action taken
333        if situation.action is None and situation.type == "pending":
334            result += "Waiting for another action...\n"
335        else:
336            desc = base.describeExplorationAction(situation, situation.action)
337            desc = desc[0].capitalize() + desc[1:]
338            result += desc + '\n'
339
340        if i == len(exploration) - 1:
341            result += "End of the exploration.\n"
342
343        # Update state variables
344        oldActiveDecisions = activeDecisions
345        prevCapabilities = capabilities
346        prevMechanisms = mechanisms
347        regions = regionsHere
348        zones = zonesHere
349        if here is not None:
350            last = here
351        lastState = state
352
353    return result
354
355
356#--------------------#
357# Analysis functions #
358#--------------------#
359
360def lastIdentity(
361    exploration: core.DiscreteExploration,
362    decision: base.DecisionID
363):
364    """
365    Returns the `identityOf` result for the specified decision in the
366    last step in which that decision existed.
367    """
368    for i in range(-1, -len(exploration) - 1, -1):
369        situation = exploration.getSituation(i)
370        try:
371            return situation.graph.identityOf(decision)
372        except core.MissingDecisionError:
373            pass
374    raise core.MissingDecisionError(
375        f"Decision {decision!r} never existed."
376    )
377
378
379def unexploredBranches(
380    graph: core.DecisionGraph,
381    context: Optional[base.RequirementContext] = None
382) -> List[Tuple[base.DecisionID, base.Transition]]:
383    """
384    Returns a list of from-decision, transition-at-that-decision pairs
385    which each identify an unexplored branch in the given graph.
386
387    When a `context` is provided it only counts options whose
388    requirements are satisfied in that `RequirementContext`, and the
389    'searchFrom' part of the context will be replaced by both ends of
390    each transition tested. This doesn't perfectly map onto actually
391    reachability since nodes between where the player is and where the
392    option is might force changes in the game state that make it
393    un-takeable.
394
395    TODO: add logic to detect trivially-unblocked edges?
396    """
397    result = []
398    # TODO: Fix networkx type stubs for MultiDiGraph!
399    for (src, dst, transition) in graph.edges(keys=True):  # type:ignore
400        req = graph.getTransitionRequirement(src, transition)
401        localContext: Optional[base.RequirementContext] = None
402        if context is not None:
403            localContext = base.RequirementContext(
404                state=context.state,
405                graph=context.graph,
406                searchFrom=graph.bothEnds(src, transition)
407            )
408        # Check if this edge goes from a confirmed to an unconfirmed node
409        if (
410            graph.isConfirmed(src)
411        and not graph.isConfirmed(dst)
412        and (localContext is None or req.satisfied(localContext))
413        ):
414            result.append((src, transition))
415    return result
416
417
418def currentDecision(situation: base.Situation) -> str:
419    """
420    Returns the `identityOf` string for the current decision in a given
421    situation.
422    """
423    return situation.graph.identityOf(situation.state['primaryDecision'])
424
425
426def countAllUnexploredBranches(situation: base.Situation) -> int:
427    """
428    Counts the number of unexplored branches in the given situation's
429    graph, regardless of traversibility (see `unexploredBranches`).
430    """
431    return len(unexploredBranches(situation.graph))
432
433
434def countTraversableUnexploredBranches(situation: base.Situation) -> int:
435    """
436    Counts the number of traversable unexplored branches (see
437    `unexploredBranches`) in a given situation, using the situation's
438    game state to determine which branches are traversable or not
439    (although this isn't strictly perfect TODO: Fix that).
440    """
441    context = base.genericContextForSituation(
442        situation,
443        base.combinedDecisionSet(situation.state)
444    )
445    return len(unexploredBranches(situation.graph, context))
446
447
448def countActionsAtDecision(
449    graph: core.DecisionGraph,
450    decision: base.DecisionID
451) -> Optional[int]:
452    """
453    Given a graph and a particular decision within that graph, returns
454    the number of actions available at that decision. Returns None if the
455    specified decision does not exist.
456    """
457    if decision not in graph:
458        return None
459    return len(graph.decisionActions(decision))
460
461
462def countBranches(
463    graph: core.DecisionGraph,
464    decision: base.DecisionID
465) -> Optional[int]:
466    """
467    Computes the number of branches at a particular decision, not
468    counting actions. Returns `None` for unvisited and nonexistent
469    decisions so that they aren't counted as part of averages.
470    """
471    if decision not in graph or not graph.isConfirmed(decision):
472        return None
473
474    dests = graph.destinationsFrom(decision)
475    branches = 0
476    for transition, dest in dests.items():
477        if dest != decision:
478            branches += 1
479
480    return branches
481
482
483def countRevisits(
484    exploration: core.DiscreteExploration,
485    decision: base.DecisionID
486) -> int:
487    """
488    Given an `DiscreteExploration` object and a particular `Decision`
489    which exists at some point during that exploration, counts the number
490    of times that decision was activated after its initial discovery (not
491    counting steps where we remain in it due to a wait or action).
492
493    Returns 0 even for decisions that aren't part of the exploration.
494    """
495    result = 0
496    wasActive = False
497    for i in range(len(exploration)):
498        active = exploration.getActiveDecisions(i)
499        if decision in active:
500            if not wasActive:
501                result += 1
502            wasActive = True
503        else:
504            wasActive = False
505
506    # Make sure not to return -1 for decisions that were never visited
507    if result >= 1:
508        return result - 1
509    else:
510        return 0
511
512
513#-----------------------#
514# Generalizer Functions #
515#-----------------------#
516
517# Some type variables to make type annotations work
518T = TypeVar('T')
519P = ParamSpec('P')
520
521
522def analyzeGraph(
523    routine: Callable[Concatenate[core.DecisionGraph, P], T]
524) -> Callable[Concatenate[base.Situation, P], T]:
525    """
526    Wraps a `DecisionGraph` analysis routine (possibly with extra
527    arguments), returning a function which applies that analysis to a
528    `Situation`.
529    """
530    def analyzesGraph(
531        situation: base.Situation,
532        *args: P.args,
533        **kwargs: P.kwargs
534    ) -> T:
535        "Created by `analyzeGraph`."
536        return routine(situation.graph, *args, **kwargs)
537
538    analyzesGraph.__name__ = routine.__name__ + "InSituation"
539    analyzesGraph.__doc__ = f"""
540    Application of a graph analysis routine to a situation.
541
542    The analysis routine applied is: {routine.__name__}
543    """ + (routine.__doc__ or '')
544    return analyzesGraph
545
546
547def perDecision(
548    routine: Callable[[base.Situation, base.DecisionID], T]
549) -> Callable[[base.Situation], Dict[base.DecisionID, T]]:
550    """
551    Returns a wrapped function that applies the given
552    individual-decision analysis routine to each decision in a
553    situation, returning a dictionary mapping decisions to results.
554    """
555    def appliedPerDecision(
556        situation: base.Situation,
557    ) -> Dict[base.DecisionID, T]:
558        'Created by `perDecision`.'
559        result = {}
560        for decision in situation.graph:
561            result[decision] = routine(situation, decision)
562        return result
563    appliedPerDecision.__name__ = routine.__name__ + "PerDecision"
564    appliedPerDecision.__doc__ = f"""
565    Application of an analysis routine to each decision in a situation,
566    returning a dictionary mapping decisions to results. The analysis
567    routine applied is: {routine.__name__}
568    """ + (routine.__doc__ or '')
569    return appliedPerDecision
570
571
572def perExplorationDecision(
573    routine: Callable[[core.DiscreteExploration, base.DecisionID], T],
574    mode: str = "all"
575) -> Callable[[core.DiscreteExploration], Dict[base.DecisionID, T]]:
576    """
577    Returns a wrapped function that applies the given
578    decision-in-exploration analysis routine to each decision in an
579    exploration, returning a dictionary mapping decisions to results.
580
581    The `mode` argument controls what we mean by "each decision:" use
582    "all" to apply it to all decisions which ever existed, "known" to
583    apply it to all decisions which were known at any point, "visited"
584    to apply it to all visited decisions, and "final" to apply it to
585    each decision in the final decision graph.
586    """
587    def appliedPerDecision(
588        exploration: core.DiscreteExploration,
589    ) -> Dict[base.DecisionID, T]:
590        'Created by `perExplorationDecision`.'
591        result = {}
592        now = exploration.getSituation()
593        graph = now.graph
594        if mode == "all":
595            applyTo = exploration.allDecisions()
596        elif mode == "known":
597            applyTo = exploration.allExploredDecisions()
598        elif mode == "visited":
599            applyTo = exploration.allVisitedDecisions()
600        elif mode == "final":
601            applyTo = list(graph)
602
603        for decision in applyTo:
604            result[decision] = routine(exploration, decision)
605
606        return result
607
608    appliedPerDecision.__name__ = routine.__name__ + "PerExplorationDecision"
609    desc = mode + ' '
610    if desc == "all ":
611        desc = ''
612    appliedPerDecision.__doc__ = f"""
613    Application of an analysis routine to each {desc}decision in an
614    exploration, returning a dictionary mapping decisions to results. The
615    analysis routine applied is: {routine.__name__}
616    """ + (routine.__doc__ or '')
617    return appliedPerDecision
618
619
620Base = TypeVar('Base', base.Situation, core.DiscreteExploration)
621"Either a situation or an exploration."
622
623
624def sumOfResults(
625    routine: Callable[
626        [Base],
627        Dict[Any, Union[int, float, complex, None]]
628    ]
629) -> Callable[[Base], Union[int, float, complex]]:
630    """
631    Given an analysis routine that applies to either a situation or an
632    exploration and which returns a dictionary mapping some analysis
633    units to individual numerical results, returns a new analysis
634    routine which applies to the same input and which returns a single
635    number that's the sum of the individual results, ignoring `None`s.
636    Returns 0 if there are no results.
637    """
638    def sumResults(base: Base) -> Union[int, float, complex]:
639        "Created by sumOfResults"
640        results = routine(base)
641        return sum(v for v in results.values() if v is not None)
642
643    sumResults.__name__ = routine.__name__ + "Sum"
644    sumResults.__doc__ = f"""
645    Sum of analysis results over analysis units.
646    The analysis routine applied is: {routine.__name__}
647    """ + (routine.__doc__ or '')
648    return sumResults
649
650
651def meanOfResults(
652    routine: Callable[
653        [Base],
654        Dict[Any, Union[int, float, complex, None]]
655    ]
656) -> Callable[[Base], Union[int, float, complex, None]]:
657    """
658    Works like `sumOfResults` but returns a function which gives the
659    mean, not the sum. The function will return `None` if there are no
660    results.
661    """
662    def meanResult(base: Base) -> Union[int, float, complex, None]:
663        "Created by meanOfResults"
664        results = routine(base)
665        nums = [v for v in results.values() if v is not None]
666        if len(nums) == 0:
667            return None
668        else:
669            return sum(nums) / len(nums)
670
671    meanResult.__name__ = routine.__name__ + "Mean"
672    meanResult.__doc__ = f"""
673    Mean of analysis results over analysis units.
674    The analysis routine applied is: {routine.__name__}
675    """ + (routine.__doc__ or '')
676    return meanResult
677
678
679def medianOfResults(
680    routine: Callable[
681        [Base],
682        Dict[Any, Union[int, float, None]]
683    ]
684) -> Callable[[Base], Union[int, float, None]]:
685    """
686    Works like `sumOfResults` but returns a function which gives the
687    median, not the sum. The function will return `None` if there are no
688    results.
689    """
690    def medianResult(base: Base) -> Union[int, float, None]:
691        "Created by medianOfResults"
692        results = routine(base)
693        nums = sorted(v for v in results.values() if v is not None)
694        half = len(nums) // 2
695        if len(nums) == 0:
696            return None
697        elif len(nums) % 2 == 0:
698            return (nums[half] + nums[half + 1]) / 2
699        else:
700            return nums[half]
701
702    medianResult.__name__ = routine.__name__ + "Mean"
703    medianResult.__doc__ = f"""
704    Mean of analysis results over analysis units.
705    The analysis routine applied is: {routine.__name__}
706    """ + (routine.__doc__ or '')
707    return medianResult
708
709
710def perSituation(
711    routine: Callable[[base.Situation], T]
712) -> Callable[[core.DiscreteExploration], List[T]]:
713    """
714    Returns a function which will apply an analysis routine to each
715    situation in an exploration, returning a list of results.
716    """
717    def appliedPerSituation(
718        exploration: core.DiscreteExploration
719    ) -> List[T]:
720        result = []
721        for situ in exploration:
722            result.append(routine(situ))
723        return result
724
725    appliedPerSituation.__name__ = routine.__name__ + "PerSituation"
726    appliedPerSituation.__doc__ = f"""
727    Analysis routine applied to each situation in an exploration,
728    returning a list of results.
729
730    The analysis routine applied is: {routine.__name__}
731    """ + (routine.__doc__ or '')
732    return appliedPerSituation
def describeConsequence( consequence: List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]) -> str:
 23def describeConsequence(consequence: base.Consequence) -> str:
 24    """
 25    Returns a string which concisely describes a consequence list.
 26    Returns an empty string if given an empty consequence. Examples:
 27
 28    >>> describeConsequence([])
 29    ''
 30    >>> describeConsequence([
 31    ...     base.effect(gain=('gold', 5), delay=2, charges=3),
 32    ...     base.effect(lose='flight')
 33    ... ])
 34    'gain gold*5 ,2 =3; lose flight'
 35    >>> from . import commands
 36    >>> d = describeConsequence([
 37    ...     base.effect(edit=[
 38    ...         [
 39    ...             commands.command('val', '5'),
 40    ...             commands.command('empty', 'list'),
 41    ...             commands.command('append')
 42    ...         ],
 43    ...         [
 44    ...             commands.command('val', '11'),
 45    ...             commands.command('assign', 'var'),
 46    ...             commands.command('op', '+', '$var', '$var')
 47    ...         ],
 48    ...     ])
 49    ... ])
 50    >>> d
 51    'with consequences:\
 52\\n    edit {\
 53\\n      val 5;\
 54\\n      empty list;\
 55\\n      append $_;\
 56\\n    } {\
 57\\n      val 11;\
 58\\n      assign var $_;\
 59\\n      op + $var $var;\
 60\\n    }\
 61\\n'
 62    >>> for line in d.splitlines():
 63    ...     print(line)
 64    with consequences:
 65        edit {
 66          val 5;
 67          empty list;
 68          append $_;
 69        } {
 70          val 11;
 71          assign var $_;
 72          op + $var $var;
 73        }
 74    """
 75    edesc = ''
 76    pf = parsing.ParseFormat()
 77    if consequence:
 78        parts = []
 79        for item in consequence:
 80            # TODO: Challenges and Conditions here!
 81            if 'skills' in item:  # a Challenge
 82                item = cast(base.Challenge, item)
 83                parts.append(pf.unparseChallenge(item))
 84            elif 'value' in item:  # an Effect
 85                item = cast(base.Effect, item)
 86                parts.append(pf.unparseEffect(item))
 87            elif 'condition' in item:  # a Condition
 88                item = cast(base.Condition, item)
 89                parts.append(pf.unparseCondition(item))
 90            else:
 91                raise TypeError(
 92                    f"Invalid consequence item (no 'skills', 'value', or"
 93                    f" 'condition' key found):\n{repr(item)}"
 94                )
 95        edesc = '; '.join(parts)
 96        if len(edesc) > 60 or '\n' in edesc:
 97            edesc = 'with consequences:\n' + ';\n'.join(
 98                textwrap.indent(part, '    ')
 99                for part in parts
100            ) + '\n'
101
102    return edesc

Returns a string which concisely describes a consequence list. Returns an empty string if given an empty consequence. Examples:

>>> describeConsequence([])
''
>>> describeConsequence([
...     base.effect(gain=('gold', 5), delay=2, charges=3),
...     base.effect(lose='flight')
... ])
'gain gold*5 ,2 =3; lose flight'
>>> from . import commands
>>> d = describeConsequence([
...     base.effect(edit=[
...         [
...             commands.command('val', '5'),
...             commands.command('empty', 'list'),
...             commands.command('append')
...         ],
...         [
...             commands.command('val', '11'),
...             commands.command('assign', 'var'),
...             commands.command('op', '+', '$var', '$var')
...         ],
...     ])
... ])
>>> d
'with consequences:\n    edit {\n      val 5;\n      empty list;\n      append $_;\n    } {\n      val 11;\n      assign var $_;\n      op + $var $var;\n    }\n'
>>> for line in d.splitlines():
...     print(line)
with consequences:
    edit {
      val 5;
      empty list;
      append $_;
    } {
      val 11;
      assign var $_;
      op + $var $var;
    }
def describeProgress(exploration: exploration.core.DiscreteExploration) -> str:
105def describeProgress(exploration: core.DiscreteExploration) -> str:
106    """
107    Describes the progress of an exploration by noting each room/zone
108    visited and explaining the options visible at each point plus which
109    option was taken. Notes powers/tokens gained/lost along the way.
110    Returns a string.
111
112    Example:
113    >>> from exploration import journal
114    >>> e = journal.convertJournal('''\\
115    ... S Start::pit
116    ... A gain jump
117    ... A gain attack
118    ... n button check
119    ... zz Wilds
120    ... o up
121    ...   q _flight
122    ... o left
123    ... x left left_nook right
124    ... a geo_rock
125    ...   At gain geo*15
126    ...   At deactivate
127    ... o up
128    ...   q _tall_narrow
129    ... t right
130    ... o right
131    ...   q attack
132    ... ''')
133    >>> for line in describeProgress(e).splitlines():
134    ...    print(line)
135    Start of the exploration
136    Start exploring domain main at 0 (Start::pit)
137      Gained capability 'attack'
138      Gained capability 'jump'
139    At decision 0 (Start::pit)
140      In zone Start
141      In region Wilds
142      There are transitions:
143        left to unconfirmed
144        up to unconfirmed; requires _flight
145      1 note(s) at this step
146    Explore left from decision 0 (Start::pit) to 2 (now Start::left_nook)
147    At decision 2 (Start::left_nook)
148      There are transitions:
149        right to 0 (Start::pit)
150      There are actions:
151        geo_rock
152    Do action geo_rock
153      Gained 15 geo(s)
154    Take right from decision 2 (Start::left_nook) to 0 (Start::pit)
155    At decision 0 (Start::pit)
156      There are transitions:
157        left to 2 (Start::left_nook)
158        right to unconfirmed; requires attack
159        up to unconfirmed; requires _flight
160    Waiting for another action...
161    End of the exploration.
162    """
163    result = ''
164
165    regions: Set[base.Zone] = set()
166    zones: Set[base.Zone] = set()
167    last: Union[base.DecisionID, Set[base.DecisionID], None] = None
168    lastState: base.State = base.emptyState()
169    prevCapabilities = base.effectiveCapabilitySet(lastState)
170    prevMechanisms = lastState['mechanisms']
171    oldActiveDecisions: Set[base.DecisionID] = set()
172    for i, situation in enumerate(exploration):
173        if i == 0:
174            result += "Start of the exploration\n"
175
176        # Extract info
177        graph = situation.graph
178        activeDecisions = exploration.getActiveDecisions(i)
179        newActive = activeDecisions - oldActiveDecisions
180        departedFrom = exploration.movementAtStep(i)[0]
181        # TODO: use the other parts of this?
182        nowZones: Set[base.Zone] = set()
183        for active in activeDecisions:
184            nowZones |= graph.zoneAncestors(active)
185        regionsHere = set(
186            z
187            for z in nowZones
188            if graph.zoneHierarchyLevel(z) == 1
189        )
190        zonesHere = set(
191            z
192            for z in nowZones
193            if graph.zoneHierarchyLevel(z) == 0
194        )
195        here = departedFrom
196        state = situation.state
197        capabilities = base.effectiveCapabilitySet(state)
198        mechanisms = state['mechanisms']
199
200        # Describe capabilities gained/lost relative to previous step
201        # (i.e., as a result of the previous action)
202        gained = (
203            capabilities['capabilities']
204          - prevCapabilities['capabilities']
205        )
206        gainedTokens = []
207        for tokenType in capabilities['tokens']:
208            net = (
209                capabilities['tokens'][tokenType]
210              - prevCapabilities['tokens'].get(tokenType, 0)
211            )
212            if net != 0:
213                gainedTokens.append((tokenType, net))
214        changed = [
215            mID
216            for mID in list(mechanisms.keys()) + list(prevMechanisms.keys())
217            if mechanisms.get(mID) != prevMechanisms.get(mID)
218        ]
219
220        for capability in sorted(gained):
221            result += f"  Gained capability '{capability}'\n"
222
223        for tokenType, net in gainedTokens:
224            if net > 0:
225                result += f"  Gained {net} {tokenType}(s)\n"
226            else:
227                result += f"  Lost {-net} {tokenType}(s)\n"
228
229        for mID in changed:
230            oldState = prevMechanisms.get(mID, base.DEFAULT_MECHANISM_STATE)
231            newState = mechanisms.get(mID, base.DEFAULT_MECHANISM_STATE)
232
233            details = graph.mechanismDetails(mID)
234            if details is None:
235                mName = "(unknown)"
236            else:
237                mName = details[1]
238            result += (
239                f"  Set mechanism {mID} ({mName}) to {newState} (was"
240                f" {oldState})"
241            )
242            # TODO: Test this!
243
244        if isinstance(departedFrom, base.DecisionID):
245            # Print location info
246            if here != last:
247                if here is None:
248                    result += "Without a position...\n"
249                elif isinstance(here, set):
250                    result += f"With {len(here)} active decisions\n"
251                    # TODO: List them using namesListing?
252                else:
253                    result += f"At decision {graph.identityOf(here)}\n"
254            newZones = zonesHere - zones
255            for zone in sorted(newZones):
256                result += f"  In zone {zone}\n"
257            newRegions = regionsHere - regions
258            for region in sorted(newRegions):
259                result += f"  In region {region}\n"
260
261        elif isinstance(departedFrom, set):  # active in spreading domain
262            spreadingDomain = graph.domainFor(list(departedFrom)[0])
263            result += (
264                f"  In domain {spreadingDomain} with {len(departedFrom)}"
265                f" active decisions...\n"
266            )
267
268        else:
269            assert departedFrom is None
270
271        # Describe new position/positions at start of this step
272        if len(newActive) > 1:
273            newListing = ', '.join(
274                sorted(graph.identityOf(n) for n in newActive)
275            )
276            result += (
277                f"  There are {len(newActive)} new active decisions:"
278                f"\n  {newListing}"
279            )
280
281        elif len(newActive) == 1:
282            here = list(newActive)[0]
283
284            outgoing = graph.destinationsFrom(here)
285
286            transitions = {t: d for (t, d) in outgoing.items() if d != here}
287            actions = {t: d for (t, d) in outgoing.items() if d == here}
288            if transitions:
289                result += "  There are transitions:\n"
290                for transition in sorted(transitions):
291                    dest = transitions[transition]
292                    if not graph.isConfirmed(dest):
293                        destSpec = 'unconfirmed'
294                    else:
295                        destSpec = graph.identityOf(dest)
296                    req = graph.getTransitionRequirement(here, transition)
297                    rDesc = ''
298                    if req != base.ReqNothing():
299                        rDesc = f"; requires {req.unparse()}"
300                    cDesc = describeConsequence(
301                        graph.getConsequence(here, transition)
302                    )
303                    if cDesc:
304                        cDesc = '; ' + cDesc
305                    result += (
306                        f"    {transition} to {destSpec}{rDesc}{cDesc}\n"
307                    )
308
309            if actions:
310                result += "  There are actions:\n"
311                for action in sorted(actions):
312                    req = graph.getTransitionRequirement(here, action)
313                    rDesc = ''
314                    if req != base.ReqNothing():
315                        rDesc = f"; requires {req.unparse()}"
316                    cDesc = describeConsequence(
317                        graph.getConsequence(here, action)
318                    )
319                    if cDesc:
320                        cDesc = '; ' + cDesc
321                    if rDesc or cDesc:
322                        desc = (rDesc + cDesc)[2:]  # chop '; ' from either
323                        result += f"    {action} ({desc})\n"
324                    else:
325                        result += f"    {action}\n"
326
327        # note annotations
328        if len(situation.annotations) > 0:
329            result += (
330                f"  {len(situation.annotations)} note(s) at this step\n"
331            )
332
333        # Describe action taken
334        if situation.action is None and situation.type == "pending":
335            result += "Waiting for another action...\n"
336        else:
337            desc = base.describeExplorationAction(situation, situation.action)
338            desc = desc[0].capitalize() + desc[1:]
339            result += desc + '\n'
340
341        if i == len(exploration) - 1:
342            result += "End of the exploration.\n"
343
344        # Update state variables
345        oldActiveDecisions = activeDecisions
346        prevCapabilities = capabilities
347        prevMechanisms = mechanisms
348        regions = regionsHere
349        zones = zonesHere
350        if here is not None:
351            last = here
352        lastState = state
353
354    return result

Describes the progress of an exploration by noting each room/zone visited and explaining the options visible at each point plus which option was taken. Notes powers/tokens gained/lost along the way. Returns a string.

Example:

>>> from exploration import journal
>>> e = journal.convertJournal('''\
... S Start::pit
... A gain jump
... A gain attack
... n button check
... zz Wilds
... o up
...   q _flight
... o left
... x left left_nook right
... a geo_rock
...   At gain geo*15
...   At deactivate
... o up
...   q _tall_narrow
... t right
... o right
...   q attack
... ''')
>>> for line in describeProgress(e).splitlines():
...    print(line)
Start of the exploration
Start exploring domain main at 0 (Start::pit)
  Gained capability 'attack'
  Gained capability 'jump'
At decision 0 (Start::pit)
  In zone Start
  In region Wilds
  There are transitions:
    left to unconfirmed
    up to unconfirmed; requires _flight
  1 note(s) at this step
Explore left from decision 0 (Start::pit) to 2 (now Start::left_nook)
At decision 2 (Start::left_nook)
  There are transitions:
    right to 0 (Start::pit)
  There are actions:
    geo_rock
Do action geo_rock
  Gained 15 geo(s)
Take right from decision 2 (Start::left_nook) to 0 (Start::pit)
At decision 0 (Start::pit)
  There are transitions:
    left to 2 (Start::left_nook)
    right to unconfirmed; requires attack
    up to unconfirmed; requires _flight
Waiting for another action...
End of the exploration.
def lastIdentity(exploration: exploration.core.DiscreteExploration, decision: int):
361def lastIdentity(
362    exploration: core.DiscreteExploration,
363    decision: base.DecisionID
364):
365    """
366    Returns the `identityOf` result for the specified decision in the
367    last step in which that decision existed.
368    """
369    for i in range(-1, -len(exploration) - 1, -1):
370        situation = exploration.getSituation(i)
371        try:
372            return situation.graph.identityOf(decision)
373        except core.MissingDecisionError:
374            pass
375    raise core.MissingDecisionError(
376        f"Decision {decision!r} never existed."
377    )

Returns the identityOf result for the specified decision in the last step in which that decision existed.

def unexploredBranches( graph: exploration.core.DecisionGraph, context: Optional[exploration.base.RequirementContext] = None) -> List[Tuple[int, str]]:
380def unexploredBranches(
381    graph: core.DecisionGraph,
382    context: Optional[base.RequirementContext] = None
383) -> List[Tuple[base.DecisionID, base.Transition]]:
384    """
385    Returns a list of from-decision, transition-at-that-decision pairs
386    which each identify an unexplored branch in the given graph.
387
388    When a `context` is provided it only counts options whose
389    requirements are satisfied in that `RequirementContext`, and the
390    'searchFrom' part of the context will be replaced by both ends of
391    each transition tested. This doesn't perfectly map onto actually
392    reachability since nodes between where the player is and where the
393    option is might force changes in the game state that make it
394    un-takeable.
395
396    TODO: add logic to detect trivially-unblocked edges?
397    """
398    result = []
399    # TODO: Fix networkx type stubs for MultiDiGraph!
400    for (src, dst, transition) in graph.edges(keys=True):  # type:ignore
401        req = graph.getTransitionRequirement(src, transition)
402        localContext: Optional[base.RequirementContext] = None
403        if context is not None:
404            localContext = base.RequirementContext(
405                state=context.state,
406                graph=context.graph,
407                searchFrom=graph.bothEnds(src, transition)
408            )
409        # Check if this edge goes from a confirmed to an unconfirmed node
410        if (
411            graph.isConfirmed(src)
412        and not graph.isConfirmed(dst)
413        and (localContext is None or req.satisfied(localContext))
414        ):
415            result.append((src, transition))
416    return result

Returns a list of from-decision, transition-at-that-decision pairs which each identify an unexplored branch in the given graph.

When a context is provided it only counts options whose requirements are satisfied in that RequirementContext, and the 'searchFrom' part of the context will be replaced by both ends of each transition tested. This doesn't perfectly map onto actually reachability since nodes between where the player is and where the option is might force changes in the game state that make it un-takeable.

TODO: add logic to detect trivially-unblocked edges?

def currentDecision(situation: exploration.base.Situation) -> str:
419def currentDecision(situation: base.Situation) -> str:
420    """
421    Returns the `identityOf` string for the current decision in a given
422    situation.
423    """
424    return situation.graph.identityOf(situation.state['primaryDecision'])

Returns the identityOf string for the current decision in a given situation.

def countAllUnexploredBranches(situation: exploration.base.Situation) -> int:
427def countAllUnexploredBranches(situation: base.Situation) -> int:
428    """
429    Counts the number of unexplored branches in the given situation's
430    graph, regardless of traversibility (see `unexploredBranches`).
431    """
432    return len(unexploredBranches(situation.graph))

Counts the number of unexplored branches in the given situation's graph, regardless of traversibility (see unexploredBranches).

def countTraversableUnexploredBranches(situation: exploration.base.Situation) -> int:
435def countTraversableUnexploredBranches(situation: base.Situation) -> int:
436    """
437    Counts the number of traversable unexplored branches (see
438    `unexploredBranches`) in a given situation, using the situation's
439    game state to determine which branches are traversable or not
440    (although this isn't strictly perfect TODO: Fix that).
441    """
442    context = base.genericContextForSituation(
443        situation,
444        base.combinedDecisionSet(situation.state)
445    )
446    return len(unexploredBranches(situation.graph, context))

Counts the number of traversable unexplored branches (see unexploredBranches) in a given situation, using the situation's game state to determine which branches are traversable or not (although this isn't strictly perfect TODO: Fix that).

def countActionsAtDecision(graph: exploration.core.DecisionGraph, decision: int) -> Optional[int]:
449def countActionsAtDecision(
450    graph: core.DecisionGraph,
451    decision: base.DecisionID
452) -> Optional[int]:
453    """
454    Given a graph and a particular decision within that graph, returns
455    the number of actions available at that decision. Returns None if the
456    specified decision does not exist.
457    """
458    if decision not in graph:
459        return None
460    return len(graph.decisionActions(decision))

Given a graph and a particular decision within that graph, returns the number of actions available at that decision. Returns None if the specified decision does not exist.

def countBranches(graph: exploration.core.DecisionGraph, decision: int) -> Optional[int]:
463def countBranches(
464    graph: core.DecisionGraph,
465    decision: base.DecisionID
466) -> Optional[int]:
467    """
468    Computes the number of branches at a particular decision, not
469    counting actions. Returns `None` for unvisited and nonexistent
470    decisions so that they aren't counted as part of averages.
471    """
472    if decision not in graph or not graph.isConfirmed(decision):
473        return None
474
475    dests = graph.destinationsFrom(decision)
476    branches = 0
477    for transition, dest in dests.items():
478        if dest != decision:
479            branches += 1
480
481    return branches

Computes the number of branches at a particular decision, not counting actions. Returns None for unvisited and nonexistent decisions so that they aren't counted as part of averages.

def countRevisits(exploration: exploration.core.DiscreteExploration, decision: int) -> int:
484def countRevisits(
485    exploration: core.DiscreteExploration,
486    decision: base.DecisionID
487) -> int:
488    """
489    Given an `DiscreteExploration` object and a particular `Decision`
490    which exists at some point during that exploration, counts the number
491    of times that decision was activated after its initial discovery (not
492    counting steps where we remain in it due to a wait or action).
493
494    Returns 0 even for decisions that aren't part of the exploration.
495    """
496    result = 0
497    wasActive = False
498    for i in range(len(exploration)):
499        active = exploration.getActiveDecisions(i)
500        if decision in active:
501            if not wasActive:
502                result += 1
503            wasActive = True
504        else:
505            wasActive = False
506
507    # Make sure not to return -1 for decisions that were never visited
508    if result >= 1:
509        return result - 1
510    else:
511        return 0

Given an DiscreteExploration object and a particular Decision which exists at some point during that exploration, counts the number of times that decision was activated after its initial discovery (not counting steps where we remain in it due to a wait or action).

Returns 0 even for decisions that aren't part of the exploration.

P = ~P
def analyzeGraph( routine: Callable[Concatenate[exploration.core.DecisionGraph, ~P], ~T]) -> Callable[Concatenate[exploration.base.Situation, ~P], ~T]:
523def analyzeGraph(
524    routine: Callable[Concatenate[core.DecisionGraph, P], T]
525) -> Callable[Concatenate[base.Situation, P], T]:
526    """
527    Wraps a `DecisionGraph` analysis routine (possibly with extra
528    arguments), returning a function which applies that analysis to a
529    `Situation`.
530    """
531    def analyzesGraph(
532        situation: base.Situation,
533        *args: P.args,
534        **kwargs: P.kwargs
535    ) -> T:
536        "Created by `analyzeGraph`."
537        return routine(situation.graph, *args, **kwargs)
538
539    analyzesGraph.__name__ = routine.__name__ + "InSituation"
540    analyzesGraph.__doc__ = f"""
541    Application of a graph analysis routine to a situation.
542
543    The analysis routine applied is: {routine.__name__}
544    """ + (routine.__doc__ or '')
545    return analyzesGraph

Wraps a DecisionGraph analysis routine (possibly with extra arguments), returning a function which applies that analysis to a Situation.

def perDecision( routine: Callable[[exploration.base.Situation, int], ~T]) -> Callable[[exploration.base.Situation], Dict[int, ~T]]:
548def perDecision(
549    routine: Callable[[base.Situation, base.DecisionID], T]
550) -> Callable[[base.Situation], Dict[base.DecisionID, T]]:
551    """
552    Returns a wrapped function that applies the given
553    individual-decision analysis routine to each decision in a
554    situation, returning a dictionary mapping decisions to results.
555    """
556    def appliedPerDecision(
557        situation: base.Situation,
558    ) -> Dict[base.DecisionID, T]:
559        'Created by `perDecision`.'
560        result = {}
561        for decision in situation.graph:
562            result[decision] = routine(situation, decision)
563        return result
564    appliedPerDecision.__name__ = routine.__name__ + "PerDecision"
565    appliedPerDecision.__doc__ = f"""
566    Application of an analysis routine to each decision in a situation,
567    returning a dictionary mapping decisions to results. The analysis
568    routine applied is: {routine.__name__}
569    """ + (routine.__doc__ or '')
570    return appliedPerDecision

Returns a wrapped function that applies the given individual-decision analysis routine to each decision in a situation, returning a dictionary mapping decisions to results.

def perExplorationDecision( routine: Callable[[exploration.core.DiscreteExploration, int], ~T], mode: str = 'all') -> Callable[[exploration.core.DiscreteExploration], Dict[int, ~T]]:
573def perExplorationDecision(
574    routine: Callable[[core.DiscreteExploration, base.DecisionID], T],
575    mode: str = "all"
576) -> Callable[[core.DiscreteExploration], Dict[base.DecisionID, T]]:
577    """
578    Returns a wrapped function that applies the given
579    decision-in-exploration analysis routine to each decision in an
580    exploration, returning a dictionary mapping decisions to results.
581
582    The `mode` argument controls what we mean by "each decision:" use
583    "all" to apply it to all decisions which ever existed, "known" to
584    apply it to all decisions which were known at any point, "visited"
585    to apply it to all visited decisions, and "final" to apply it to
586    each decision in the final decision graph.
587    """
588    def appliedPerDecision(
589        exploration: core.DiscreteExploration,
590    ) -> Dict[base.DecisionID, T]:
591        'Created by `perExplorationDecision`.'
592        result = {}
593        now = exploration.getSituation()
594        graph = now.graph
595        if mode == "all":
596            applyTo = exploration.allDecisions()
597        elif mode == "known":
598            applyTo = exploration.allExploredDecisions()
599        elif mode == "visited":
600            applyTo = exploration.allVisitedDecisions()
601        elif mode == "final":
602            applyTo = list(graph)
603
604        for decision in applyTo:
605            result[decision] = routine(exploration, decision)
606
607        return result
608
609    appliedPerDecision.__name__ = routine.__name__ + "PerExplorationDecision"
610    desc = mode + ' '
611    if desc == "all ":
612        desc = ''
613    appliedPerDecision.__doc__ = f"""
614    Application of an analysis routine to each {desc}decision in an
615    exploration, returning a dictionary mapping decisions to results. The
616    analysis routine applied is: {routine.__name__}
617    """ + (routine.__doc__ or '')
618    return appliedPerDecision

Returns a wrapped function that applies the given decision-in-exploration analysis routine to each decision in an exploration, returning a dictionary mapping decisions to results.

The mode argument controls what we mean by "each decision:" use "all" to apply it to all decisions which ever existed, "known" to apply it to all decisions which were known at any point, "visited" to apply it to all visited decisions, and "final" to apply it to each decision in the final decision graph.

Base = ~Base

Either a situation or an exploration.

def sumOfResults( routine: Callable[[~Base], Dict[Any, Union[int, float, complex, NoneType]]]) -> Callable[[~Base], Union[int, float, complex]]:
625def sumOfResults(
626    routine: Callable[
627        [Base],
628        Dict[Any, Union[int, float, complex, None]]
629    ]
630) -> Callable[[Base], Union[int, float, complex]]:
631    """
632    Given an analysis routine that applies to either a situation or an
633    exploration and which returns a dictionary mapping some analysis
634    units to individual numerical results, returns a new analysis
635    routine which applies to the same input and which returns a single
636    number that's the sum of the individual results, ignoring `None`s.
637    Returns 0 if there are no results.
638    """
639    def sumResults(base: Base) -> Union[int, float, complex]:
640        "Created by sumOfResults"
641        results = routine(base)
642        return sum(v for v in results.values() if v is not None)
643
644    sumResults.__name__ = routine.__name__ + "Sum"
645    sumResults.__doc__ = f"""
646    Sum of analysis results over analysis units.
647    The analysis routine applied is: {routine.__name__}
648    """ + (routine.__doc__ or '')
649    return sumResults

Given an analysis routine that applies to either a situation or an exploration and which returns a dictionary mapping some analysis units to individual numerical results, returns a new analysis routine which applies to the same input and which returns a single number that's the sum of the individual results, ignoring Nones. Returns 0 if there are no results.

def meanOfResults( routine: Callable[[~Base], Dict[Any, Union[int, float, complex, NoneType]]]) -> Callable[[~Base], Union[int, float, complex, NoneType]]:
652def meanOfResults(
653    routine: Callable[
654        [Base],
655        Dict[Any, Union[int, float, complex, None]]
656    ]
657) -> Callable[[Base], Union[int, float, complex, None]]:
658    """
659    Works like `sumOfResults` but returns a function which gives the
660    mean, not the sum. The function will return `None` if there are no
661    results.
662    """
663    def meanResult(base: Base) -> Union[int, float, complex, None]:
664        "Created by meanOfResults"
665        results = routine(base)
666        nums = [v for v in results.values() if v is not None]
667        if len(nums) == 0:
668            return None
669        else:
670            return sum(nums) / len(nums)
671
672    meanResult.__name__ = routine.__name__ + "Mean"
673    meanResult.__doc__ = f"""
674    Mean of analysis results over analysis units.
675    The analysis routine applied is: {routine.__name__}
676    """ + (routine.__doc__ or '')
677    return meanResult

Works like sumOfResults but returns a function which gives the mean, not the sum. The function will return None if there are no results.

def medianOfResults( routine: Callable[[~Base], Dict[Any, Union[int, float, NoneType]]]) -> Callable[[~Base], Union[int, float, NoneType]]:
680def medianOfResults(
681    routine: Callable[
682        [Base],
683        Dict[Any, Union[int, float, None]]
684    ]
685) -> Callable[[Base], Union[int, float, None]]:
686    """
687    Works like `sumOfResults` but returns a function which gives the
688    median, not the sum. The function will return `None` if there are no
689    results.
690    """
691    def medianResult(base: Base) -> Union[int, float, None]:
692        "Created by medianOfResults"
693        results = routine(base)
694        nums = sorted(v for v in results.values() if v is not None)
695        half = len(nums) // 2
696        if len(nums) == 0:
697            return None
698        elif len(nums) % 2 == 0:
699            return (nums[half] + nums[half + 1]) / 2
700        else:
701            return nums[half]
702
703    medianResult.__name__ = routine.__name__ + "Mean"
704    medianResult.__doc__ = f"""
705    Mean of analysis results over analysis units.
706    The analysis routine applied is: {routine.__name__}
707    """ + (routine.__doc__ or '')
708    return medianResult

Works like sumOfResults but returns a function which gives the median, not the sum. The function will return None if there are no results.

def perSituation( routine: Callable[[exploration.base.Situation], ~T]) -> Callable[[exploration.core.DiscreteExploration], List[~T]]:
711def perSituation(
712    routine: Callable[[base.Situation], T]
713) -> Callable[[core.DiscreteExploration], List[T]]:
714    """
715    Returns a function which will apply an analysis routine to each
716    situation in an exploration, returning a list of results.
717    """
718    def appliedPerSituation(
719        exploration: core.DiscreteExploration
720    ) -> List[T]:
721        result = []
722        for situ in exploration:
723            result.append(routine(situ))
724        return result
725
726    appliedPerSituation.__name__ = routine.__name__ + "PerSituation"
727    appliedPerSituation.__doc__ = f"""
728    Analysis routine applied to each situation in an exploration,
729    returning a list of results.
730
731    The analysis routine applied is: {routine.__name__}
732    """ + (routine.__doc__ or '')
733    return appliedPerSituation

Returns a function which will apply an analysis routine to each situation in an exploration, returning a list of results.