"""Types for GOAP planning results."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
from langgoap.actions import ActionSpec
from langgoap.score import Score, SimpleScore
from langgoap.state import PlanningState
if TYPE_CHECKING:
from langgoap.planner.csp import CSPMetadata
[docs]
@dataclass(frozen=True, slots=True)
class Plan:
"""A sequence of actions that achieves a goal from a given start state.
Attributes:
actions: Ordered list of ActionSpecs to execute.
expected_states: The world state expected after each action.
total_cost: Sum of action costs along the plan.
metadata: Planning algorithm statistics.
score: :class:`~langgoap.score.Score` for the plan. A*-only plans
carry a :class:`~langgoap.score.SimpleScore` equal to
``total_cost``; after CSP evaluation, the pipeline replaces
this with a :class:`~langgoap.score.HardSoftScore` whose
``hard`` level is 0.0 for feasible plans and whose ``soft``
level aggregates weighted objectives and soft penalties.
"""
actions: tuple[ActionSpec, ...]
expected_states: tuple[PlanningState, ...] = ()
total_cost: float = 0.0
metadata: PlanMetadata = field(default_factory=PlanMetadata)
score: Score = field(default_factory=lambda: SimpleScore(scalar=0.0))
@property
def action_names(self) -> list[str]:
"""Return the names of all actions in the plan."""
return [a.name for a in self.actions]
def __len__(self) -> int:
return len(self.actions)
def __repr__(self) -> str:
return (
f"Plan(actions={self.action_names!r}, "
f"total_cost={self.total_cost:.4g}, "
f"steps={len(self)})"
)
@classmethod
def empty(cls) -> Plan:
"""Create an empty plan (goal already satisfied)."""
return cls(actions=(), expected_states=(), total_cost=0.0)
def net_value(self, goal: Any, world_state: Any | None = None) -> float:
"""Return ``goal.value - total_cost`` for ``MultiGoal`` selection.
``goal.value`` may be a static float or a callable resolved
against ``world_state`` if supplied (preferred — preserves
rich, non-planning context that's stripped from the planner's
:class:`PlanningState`); otherwise it falls back to the plan's
expected end state, then to an empty mapping.
Used by ``MultiGoal(mode='best_value')`` to pick the goal with
the highest net utility.
"""
value_attr = getattr(goal, "value", 1.0)
if callable(value_attr):
if world_state is not None:
ctx: Any = world_state
elif self.expected_states:
ctx = self.expected_states[-1].to_dict()
else:
ctx = {}
value = float(value_attr(ctx))
else:
value = float(value_attr)
return value - self.total_cost
# ------------------------------------------------------------------
# Visualization
# ------------------------------------------------------------------
def to_mermaid(
self,
*,
show_resources: bool = True,
show_schedule: bool = True,
) -> str:
"""Render this plan as a Mermaid ``flowchart TD`` diagram.
Returns:
Mermaid source code as a plain string.
"""
from langgoap.viz.mermaid import render_mermaid
return render_mermaid(
self,
show_resources=show_resources,
show_schedule=show_schedule,
)
def to_dot(
self,
*,
show_resources: bool = True,
show_schedule: bool = True,
) -> str:
"""Render this plan as Graphviz DOT source.
Returns:
DOT source code as a plain string.
"""
from langgoap.viz.dot import render_dot
return render_dot(
self,
show_resources=show_resources,
show_schedule=show_schedule,
)
def to_ascii(
self,
*,
show_resources: bool = True,
show_schedule: bool = True,
) -> str:
"""Render this plan as an ASCII tree.
Returns:
Plain-text ASCII representation of the plan.
"""
from langgoap.viz.ascii import render_ascii
return render_ascii(
self,
show_resources=show_resources,
show_schedule=show_schedule,
)
def visualize(
self,
*,
format: Literal[
"auto", "mermaid", "dot", "ascii", "gantt", "ascii_gantt"
] = "auto",
show_resources: bool = True,
show_schedule: bool = True,
) -> str | Any:
"""Display-aware visualization helper.
When IPython is available, ``format="auto"`` returns an
``IPython.display.Markdown`` wrapping the Mermaid source so
Jupyter renders it as a diagram. When IPython is absent, returns
the ASCII rendering as a plain string.
See :func:`langgoap.viz.jupyter.visualize` for the full format
table.
"""
from langgoap.viz.jupyter import visualize as _visualize
return _visualize(
self,
format=format,
show_resources=show_resources,
show_schedule=show_schedule,
)
def to_gantt(self) -> str:
"""Render this plan's schedule as a Mermaid ``gantt`` chart.
Requires ``plan.metadata.csp.schedule`` to be populated.
Returns:
Mermaid gantt source code as a plain string.
Raises:
ValueError: If the plan has no schedule attached.
"""
from langgoap.viz.mermaid import render_mermaid_gantt
return render_mermaid_gantt(self)
def draw_gantt_png(self, **kwargs: Any) -> bytes:
"""Render this plan's schedule as a PNG Gantt chart via Mermaid.
Thin shim over :func:`langgoap.viz.mermaid.draw_gantt_png`.
"""
from langgoap.viz.mermaid import draw_gantt_png
return draw_gantt_png(self, **kwargs)
def draw_mermaid_png(self, **kwargs: Any) -> bytes:
"""Render this plan as a PNG image via Mermaid.
Thin shim over :func:`langgoap.viz.mermaid.draw_mermaid_png`.
"""
from langgoap.viz.mermaid import draw_mermaid_png
return draw_mermaid_png(self, **kwargs)
def _repr_mimebundle_(self, **kwargs: Any) -> dict[str, Any]:
"""Jupyter rich display \u2014 thin shim over
:func:`langgoap.viz.jupyter.repr_mimebundle`.
"""
from langgoap.viz.jupyter import repr_mimebundle
return repr_mimebundle(self, **kwargs)
def save(
self,
path: str | Path,
*,
format: Literal["mermaid", "dot", "ascii"] | None = None,
show_resources: bool = True,
show_schedule: bool = True,
) -> Path:
"""Write a rendered representation of this plan to ``path``.
Thin shim over :func:`langgoap.viz.save.save_plan` \u2014 see that
function for the format-inference rules.
"""
from langgoap.viz.save import save_plan
return save_plan(
self,
path,
format=format,
show_resources=show_resources,
show_schedule=show_schedule,
)