Skip to content

Solvers

patiencepilot.solvers

Solver contracts and implementations.

Advice dataclass

Solver advice containing zero or more ranked move alternatives.

Source code in src/patiencepilot/solvers/base.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
@dataclass(frozen=True, slots=True)
class Advice:
    """Solver advice containing zero or more ranked move alternatives."""

    recommendations: tuple[RankedMove, ...]
    solver_name: str | None = None
    limit: SearchLimit | None = None
    nodes_searched: int | None = None
    depth_reached: int | None = None
    elapsed_seconds: float | None = None
    assumptions: tuple[str, ...] = ()

    def __post_init__(self) -> None:
        """Sort and validate advice metadata."""
        object.__setattr__(self, "recommendations", tuple(sorted(self.recommendations, key=lambda item: item.rank)))
        if self.nodes_searched is not None and self.nodes_searched < 0:
            msg = "nodes_searched must be non-negative"
            raise InvalidStateError(msg)
        if self.depth_reached is not None and self.depth_reached < 0:
            msg = "depth_reached must be non-negative"
            raise InvalidStateError(msg)
        if self.elapsed_seconds is not None and self.elapsed_seconds < 0:
            msg = "elapsed_seconds must be non-negative"
            raise InvalidStateError(msg)

    @classmethod
    def from_move(
        cls,
        move: Move,
        *,
        solver_name: str | None = None,
        limit: SearchLimit | None = None,
        score: float | None = None,
        confidence: float | None = None,
        reason: str | None = None,
    ) -> Advice:
        """Return advice with a single recommended move.

        Args:
            move: Recommended move.
            solver_name: Optional solver identifier.
            limit: Optional search limits used for the recommendation.
            score: Optional solver-specific move score.
            confidence: Optional confidence from 0 to 1.
            reason: Optional human-readable reason.
        """
        return cls(
            recommendations=(RankedMove(move=move, rank=1, score=score, confidence=confidence, reason=reason),),
            solver_name=solver_name,
            limit=limit,
        )

    @property
    def best(self) -> RankedMove | None:
        """Return the highest-ranked recommendation, if any."""
        if not self.recommendations:
            return None
        return self.recommendations[0]

    @property
    def best_move(self) -> Move | None:
        """Return the highest-ranked move, if any."""
        best = self.best
        if best is None:
            return None
        return best.move

    @property
    def alternatives(self) -> tuple[RankedMove, ...]:
        """Return recommendations after the best move."""
        return self.recommendations[1:]

best property

best: RankedMove | None

Return the highest-ranked recommendation, if any.

best_move property

best_move: Move | None

Return the highest-ranked move, if any.

alternatives property

alternatives: tuple[RankedMove, ...]

Return recommendations after the best move.

__post_init__

__post_init__() -> None

Sort and validate advice metadata.

Source code in src/patiencepilot/solvers/base.py
67
68
69
70
71
72
73
74
75
76
77
78
def __post_init__(self) -> None:
    """Sort and validate advice metadata."""
    object.__setattr__(self, "recommendations", tuple(sorted(self.recommendations, key=lambda item: item.rank)))
    if self.nodes_searched is not None and self.nodes_searched < 0:
        msg = "nodes_searched must be non-negative"
        raise InvalidStateError(msg)
    if self.depth_reached is not None and self.depth_reached < 0:
        msg = "depth_reached must be non-negative"
        raise InvalidStateError(msg)
    if self.elapsed_seconds is not None and self.elapsed_seconds < 0:
        msg = "elapsed_seconds must be non-negative"
        raise InvalidStateError(msg)

from_move classmethod

from_move(
    move: Move,
    *,
    solver_name: str | None = None,
    limit: SearchLimit | None = None,
    score: float | None = None,
    confidence: float | None = None,
    reason: str | None = None,
) -> Advice

Return advice with a single recommended move.

Parameters:

Name Type Description Default
move Move

Recommended move.

required
solver_name str | None

Optional solver identifier.

None
limit SearchLimit | None

Optional search limits used for the recommendation.

None
score float | None

Optional solver-specific move score.

None
confidence float | None

Optional confidence from 0 to 1.

None
reason str | None

Optional human-readable reason.

None
Source code in src/patiencepilot/solvers/base.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
@classmethod
def from_move(
    cls,
    move: Move,
    *,
    solver_name: str | None = None,
    limit: SearchLimit | None = None,
    score: float | None = None,
    confidence: float | None = None,
    reason: str | None = None,
) -> Advice:
    """Return advice with a single recommended move.

    Args:
        move: Recommended move.
        solver_name: Optional solver identifier.
        limit: Optional search limits used for the recommendation.
        score: Optional solver-specific move score.
        confidence: Optional confidence from 0 to 1.
        reason: Optional human-readable reason.
    """
    return cls(
        recommendations=(RankedMove(move=move, rank=1, score=score, confidence=confidence, reason=reason),),
        solver_name=solver_name,
        limit=limit,
    )

AdviceProvider

Bases: Protocol

Protocol for components that produce advice from player-known state.

Source code in src/patiencepilot/solvers/base.py
128
129
130
131
132
133
class AdviceProvider(Protocol):
    """Protocol for components that produce advice from player-known state."""

    def suggest(self, view: PlayerView, *, limit: SearchLimit | None = None) -> Advice:
        """Return move advice using only the supplied player-known view."""
        ...

suggest

suggest(
    view: PlayerView, *, limit: SearchLimit | None = None
) -> Advice

Return move advice using only the supplied player-known view.

Source code in src/patiencepilot/solvers/base.py
131
132
133
def suggest(self, view: PlayerView, *, limit: SearchLimit | None = None) -> Advice:
    """Return move advice using only the supplied player-known view."""
    ...

RankedMove dataclass

One ranked solver move recommendation.

Source code in src/patiencepilot/solvers/base.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@dataclass(frozen=True, slots=True)
class RankedMove:
    """One ranked solver move recommendation."""

    move: Move
    rank: int = 1
    score: float | None = None
    confidence: float | None = None
    reason: str | None = None
    tags: tuple[str, ...] = ()

    def __post_init__(self) -> None:
        """Validate ranked-move metadata."""
        if self.rank < 1:
            msg = "rank must be at least 1"
            raise InvalidStateError(msg)
        if self.confidence is not None and not 0 <= self.confidence <= 1:
            msg = "confidence must be between 0 and 1"
            raise InvalidStateError(msg)

__post_init__

__post_init__() -> None

Validate ranked-move metadata.

Source code in src/patiencepilot/solvers/base.py
45
46
47
48
49
50
51
52
def __post_init__(self) -> None:
    """Validate ranked-move metadata."""
    if self.rank < 1:
        msg = "rank must be at least 1"
        raise InvalidStateError(msg)
    if self.confidence is not None and not 0 <= self.confidence <= 1:
        msg = "confidence must be between 0 and 1"
        raise InvalidStateError(msg)

SearchLimit dataclass

Optional limits for bounded solver work.

Source code in src/patiencepilot/solvers/base.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@dataclass(frozen=True, slots=True)
class SearchLimit:
    """Optional limits for bounded solver work."""

    time_seconds: float | None = None
    node_limit: int | None = None
    depth_limit: int | None = None

    def __post_init__(self) -> None:
        """Validate search-limit values."""
        if self.time_seconds is not None and self.time_seconds <= 0:
            msg = "time_seconds must be positive when provided"
            raise InvalidStateError(msg)
        if self.node_limit is not None and self.node_limit < 1:
            msg = "node_limit must be at least 1 when provided"
            raise InvalidStateError(msg)
        if self.depth_limit is not None and self.depth_limit < 0:
            msg = "depth_limit must be non-negative when provided"
            raise InvalidStateError(msg)

__post_init__

__post_init__() -> None

Validate search-limit values.

Source code in src/patiencepilot/solvers/base.py
21
22
23
24
25
26
27
28
29
30
31
def __post_init__(self) -> None:
    """Validate search-limit values."""
    if self.time_seconds is not None and self.time_seconds <= 0:
        msg = "time_seconds must be positive when provided"
        raise InvalidStateError(msg)
    if self.node_limit is not None and self.node_limit < 1:
        msg = "node_limit must be at least 1 when provided"
        raise InvalidStateError(msg)
    if self.depth_limit is not None and self.depth_limit < 0:
        msg = "depth_limit must be non-negative when provided"
        raise InvalidStateError(msg)

Solver

Bases: AdviceProvider, Protocol

Protocol for pluggable Solitaire solvers.

Source code in src/patiencepilot/solvers/base.py
136
137
138
139
class Solver(AdviceProvider, Protocol):
    """Protocol for pluggable Solitaire solvers."""

    name: str

DummySolver dataclass

Deterministic solver that recommends the first visible legal move.

Source code in src/patiencepilot/solvers/dummy.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@dataclass(frozen=True, slots=True)
class DummySolver:
    """Deterministic solver that recommends the first visible legal move."""

    name: str = "dummy"

    def suggest(self, view: PlayerView, *, limit: SearchLimit | None = None) -> Advice:
        """Return the first legal move available in ``view``.

        The solver derives legal moves only from player-visible information:
        foundations, waste, visible tableau cards, stock count, and redeal
        metadata. It does not reconstruct or inspect hidden card identities.
        """
        moves = visible_klondike_moves(view)
        if not moves:
            return Advice(recommendations=(), solver_name=self.name, limit=limit, nodes_searched=0, depth_reached=0)
        return Advice(
            recommendations=(RankedMove(move=moves[0], rank=1),),
            solver_name=self.name,
            limit=limit,
            nodes_searched=len(moves),
            depth_reached=0,
        )

suggest

suggest(
    view: PlayerView, *, limit: SearchLimit | None = None
) -> Advice

Return the first legal move available in view.

The solver derives legal moves only from player-visible information: foundations, waste, visible tableau cards, stock count, and redeal metadata. It does not reconstruct or inspect hidden card identities.

Source code in src/patiencepilot/solvers/dummy.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
def suggest(self, view: PlayerView, *, limit: SearchLimit | None = None) -> Advice:
    """Return the first legal move available in ``view``.

    The solver derives legal moves only from player-visible information:
    foundations, waste, visible tableau cards, stock count, and redeal
    metadata. It does not reconstruct or inspect hidden card identities.
    """
    moves = visible_klondike_moves(view)
    if not moves:
        return Advice(recommendations=(), solver_name=self.name, limit=limit, nodes_searched=0, depth_reached=0)
    return Advice(
        recommendations=(RankedMove(move=moves[0], rank=1),),
        solver_name=self.name,
        limit=limit,
        nodes_searched=len(moves),
        depth_reached=0,
    )

SolverDefinition dataclass

Registered solver metadata and factory.

Source code in src/patiencepilot/solvers/registry.py
21
22
23
24
25
26
27
28
29
30
31
32
@dataclass(frozen=True, slots=True)
class SolverDefinition:
    """Registered solver metadata and factory."""

    name: str
    factory: SolverFactory
    aliases: tuple[str, ...] = ()
    description: str | None = None

    def create(self) -> AdviceProvider:
        """Return a new advice provider instance."""
        return self.factory()

create

create() -> AdviceProvider

Return a new advice provider instance.

Source code in src/patiencepilot/solvers/registry.py
30
31
32
def create(self) -> AdviceProvider:
    """Return a new advice provider instance."""
    return self.factory()

SolverFactory

Bases: Protocol

Callable that creates an advice provider.

Source code in src/patiencepilot/solvers/registry.py
13
14
15
16
17
18
class SolverFactory(Protocol):
    """Callable that creates an advice provider."""

    def __call__(self) -> AdviceProvider:
        """Return an advice provider instance."""
        ...

__call__

__call__() -> AdviceProvider

Return an advice provider instance.

Source code in src/patiencepilot/solvers/registry.py
16
17
18
def __call__(self) -> AdviceProvider:
    """Return an advice provider instance."""
    ...

SolverRegistry dataclass

Small immutable registry of supported solvers.

Source code in src/patiencepilot/solvers/registry.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
@dataclass(frozen=True, slots=True)
class SolverRegistry:
    """Small immutable registry of supported solvers."""

    definitions: tuple[SolverDefinition, ...]

    def __post_init__(self) -> None:
        """Validate registry keys."""
        keys: set[str] = set()
        for definition in self.definitions:
            for name in (definition.name, *definition.aliases):
                normalized = _normalize_solver_name(name)
                if normalized in keys:
                    msg = f"duplicate solver registry name: {name!r}"
                    raise InvalidStateError(msg)
                keys.add(normalized)

    @property
    def names(self) -> tuple[str, ...]:
        """Return canonical registered solver names."""
        return tuple(definition.name for definition in self.definitions)

    def register(self, definition: SolverDefinition) -> SolverRegistry:
        """Return a new registry that also contains ``definition``.

        Args:
            definition: Solver definition to add.
        """
        return SolverRegistry((*self.definitions, definition))

    def definition_for(self, name: str) -> SolverDefinition:
        """Return the registered definition for ``name`` or alias.

        Args:
            name: Solver name or alias.

        Raises:
            UnsupportedSolverError: If no solver is registered for ``name``.
        """
        normalized = _normalize_solver_name(name)
        for definition in self.definitions:
            if normalized == _normalize_solver_name(definition.name):
                return definition
            if any(normalized == _normalize_solver_name(alias) for alias in definition.aliases):
                return definition

        msg = f"unsupported solver: {name!r}"
        raise UnsupportedSolverError(msg)

    def create(self, name: str) -> AdviceProvider:
        """Return a new advice provider by solver name.

        Args:
            name: Solver name or alias.
        """
        return self.definition_for(name).create()

names property

names: tuple[str, ...]

Return canonical registered solver names.

__post_init__

__post_init__() -> None

Validate registry keys.

Source code in src/patiencepilot/solvers/registry.py
41
42
43
44
45
46
47
48
49
50
def __post_init__(self) -> None:
    """Validate registry keys."""
    keys: set[str] = set()
    for definition in self.definitions:
        for name in (definition.name, *definition.aliases):
            normalized = _normalize_solver_name(name)
            if normalized in keys:
                msg = f"duplicate solver registry name: {name!r}"
                raise InvalidStateError(msg)
            keys.add(normalized)

register

register(definition: SolverDefinition) -> SolverRegistry

Return a new registry that also contains definition.

Parameters:

Name Type Description Default
definition SolverDefinition

Solver definition to add.

required
Source code in src/patiencepilot/solvers/registry.py
57
58
59
60
61
62
63
def register(self, definition: SolverDefinition) -> SolverRegistry:
    """Return a new registry that also contains ``definition``.

    Args:
        definition: Solver definition to add.
    """
    return SolverRegistry((*self.definitions, definition))

definition_for

definition_for(name: str) -> SolverDefinition

Return the registered definition for name or alias.

Parameters:

Name Type Description Default
name str

Solver name or alias.

required

Raises:

Type Description
UnsupportedSolverError

If no solver is registered for name.

Source code in src/patiencepilot/solvers/registry.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def definition_for(self, name: str) -> SolverDefinition:
    """Return the registered definition for ``name`` or alias.

    Args:
        name: Solver name or alias.

    Raises:
        UnsupportedSolverError: If no solver is registered for ``name``.
    """
    normalized = _normalize_solver_name(name)
    for definition in self.definitions:
        if normalized == _normalize_solver_name(definition.name):
            return definition
        if any(normalized == _normalize_solver_name(alias) for alias in definition.aliases):
            return definition

    msg = f"unsupported solver: {name!r}"
    raise UnsupportedSolverError(msg)

create

create(name: str) -> AdviceProvider

Return a new advice provider by solver name.

Parameters:

Name Type Description Default
name str

Solver name or alias.

required
Source code in src/patiencepilot/solvers/registry.py
84
85
86
87
88
89
90
def create(self, name: str) -> AdviceProvider:
    """Return a new advice provider by solver name.

    Args:
        name: Solver name or alias.
    """
    return self.definition_for(name).create()

visible_klondike_moves

visible_klondike_moves(
    view: PlayerView,
) -> tuple[Move, ...]

Return Klondike moves legal from player-visible state.

Source code in src/patiencepilot/solvers/dummy.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def visible_klondike_moves(view: PlayerView) -> tuple[Move, ...]:
    """Return Klondike moves legal from player-visible state."""
    if view.variant != "klondike":
        msg = f"dummy solver only supports 'klondike', got {view.variant!r}"
        raise UnsupportedVariantError(msg)

    moves: list[Move] = []
    if view.stock_count:
        moves.append(DrawFromStock())
    elif view.waste and _can_recycle(view):
        moves.append(RecycleWaste())

    if view.waste:
        waste_card = view.waste[-1]
        if _can_move_to_foundation(waste_card, view.foundation(waste_card.suit)):
            moves.append(WasteToFoundation())
        for destination in range(N_TABLEAU_COLUMNS):
            if _can_place_on_tableau(waste_card, view.tableau[destination]):
                moves.append(WasteToTableau(destination=destination))

    for source, column in enumerate(view.tableau):
        top_card = _top_visible_card(column)
        if top_card is not None and _can_move_to_foundation(top_card, view.foundation(top_card.suit)):
            moves.append(TableauToFoundation(source=source))

        for start_index in range(len(column)):
            moving_stack = column[start_index:]
            if not _is_movable_tableau_stack(moving_stack):
                continue
            moving_card = moving_stack[0].visible_card
            if moving_card is None:
                continue
            count = len(moving_stack)
            for destination in range(N_TABLEAU_COLUMNS):
                if source == destination:
                    continue
                if _can_place_on_tableau(moving_card, view.tableau[destination]):
                    moves.append(TableauToTableau(source=source, destination=destination, count=count))

    return tuple(moves)

resolve_solver

resolve_solver(
    name: str = "dummy",
    *,
    registry: SolverRegistry | None = None,
) -> AdviceProvider

Return an advice provider for a registered solver name.

Parameters:

Name Type Description Default
name str

Solver name or alias. Defaults to "dummy".

'dummy'
registry SolverRegistry | None

Registry to use. Defaults to the package registry.

None
Source code in src/patiencepilot/solvers/registry.py
 93
 94
 95
 96
 97
 98
 99
100
def resolve_solver(name: str = "dummy", *, registry: SolverRegistry | None = None) -> AdviceProvider:
    """Return an advice provider for a registered solver name.

    Args:
        name: Solver name or alias. Defaults to ``"dummy"``.
        registry: Registry to use. Defaults to the package registry.
    """
    return _registry_or_default(registry).create(name)

solver_names

solver_names(
    *, registry: SolverRegistry | None = None
) -> tuple[str, ...]

Return canonical names of registered solvers.

Source code in src/patiencepilot/solvers/registry.py
103
104
105
def solver_names(*, registry: SolverRegistry | None = None) -> tuple[str, ...]:
    """Return canonical names of registered solvers."""
    return _registry_or_default(registry).names