Source code for langgoap.score

"""Score hierarchy for plan evaluation.

LangGOAP uses a tiered score hierarchy for comparing plans:

* :class:`SimpleScore` \u2014 one scalar; used when a plan has no constraint
  context (e.g. after pure A* planning).
* :class:`HardSoftScore` \u2014 two levels, ``hard`` and ``soft``.  A feasible
  plan has ``hard == 0.0``.  Lexicographic comparison: plans with higher
  ``hard`` win first, ties broken by ``soft``.
* :class:`BendableScore` \u2014 variable-width hard/soft levels for problems
  that need multiple tiers.

Sign convention:
  * ``hard`` is ``<= 0``. A feasible plan has ``hard == 0.0`` and every
    hard-constraint violation subtracts the corresponding penalty amount.
  * ``soft`` has **no** sign restriction.  Minimize objectives and soft
    violations contribute negative values; maximize objectives contribute
    positive values.

Rewards can be positive and penalties are negative (penalize/reward
convention).

Comparison is lexicographic within one subclass; comparing across
subclasses raises ``TypeError`` to force callers to stay within a single
score type per planning run.  All four ordering operators are derived
from a single ``_compare_payload`` hook via ``functools.total_ordering``
on :class:`Score` (see :mod:`langgoap._score_base`).
"""

from __future__ import annotations

from dataclasses import dataclass, field

from langgoap._score_base import Score


[docs] @dataclass(frozen=True, order=False, slots=True) class SimpleScore(Score): """A single scalar score. ``value`` returns the stored value; ``is_feasible`` is always ``True`` (A*-produced plans are feasible by construction because A* would not return them otherwise). Comparison: lower is better. This matches the existing A* convention where ``total_cost`` is minimized. """ scalar: float = 0.0 @property def value(self) -> float: return self.scalar def is_feasible(self) -> bool: return True def _compare_payload(self) -> float: return self.scalar def __repr__(self) -> str: return f"SimpleScore({self.scalar:g})"
[docs] @dataclass(frozen=True, order=False, slots=True) class HardSoftScore(Score): """A two-level score with hard and soft components. ``hard`` is bounded above by 0. A feasible plan has ``hard == 0.0``; every hard-constraint violation subtracts its penalty amount. ``soft`` has no sign restriction — maximize objectives push it positive, minimize objectives and soft-constraint violations push it negative. Comparison is lexicographic: a plan with a greater (less-negative) ``hard`` is strictly better; ties break by ``soft`` (greater is better). Using ``>`` is more natural than ``<`` because rewards accumulate positively. """ hard: float = 0.0 soft: float = 0.0 @property def value(self) -> float: """Sum of the two levels. Useful for logging only. Do **not** use this for plan selection — it discards the hard/soft distinction. Compare ``HardSoftScore`` instances directly instead. """ return self.hard + self.soft def is_feasible(self) -> bool: return self.hard == 0.0 def _compare_payload(self) -> tuple[float, float]: return (self.hard, self.soft) def __repr__(self) -> str: return f"HardSoftScore(hard={self.hard:g}, soft={self.soft:g})"
[docs] @dataclass(frozen=True, order=False, slots=True) class BendableScore(Score): """A variable-width score with any number of hard and soft levels. Use this when the problem has multiple tiers of constraints that must be respected in strict priority order (e.g. "never violate legal limits; then never violate corporate policy; then minimize cost"). Every level in ``hard_levels`` is bounded above by 0. ``soft_levels`` are unconstrained in sign. """ hard_levels: tuple[float, ...] = field(default_factory=tuple) soft_levels: tuple[float, ...] = field(default_factory=tuple) def __post_init__(self) -> None: if not isinstance(self.hard_levels, tuple): object.__setattr__(self, "hard_levels", tuple(self.hard_levels)) if not isinstance(self.soft_levels, tuple): object.__setattr__(self, "soft_levels", tuple(self.soft_levels)) @property def value(self) -> float: """Sum of every level. Useful for logging; do not use for selection.""" return sum(self.hard_levels) + sum(self.soft_levels) def is_feasible(self) -> bool: return all(h == 0.0 for h in self.hard_levels) def _compare_check(self, other: Score) -> None: assert isinstance(other, BendableScore) # narrowed by Score.__lt__ if len(self.hard_levels) != len(other.hard_levels) or len( self.soft_levels ) != len(other.soft_levels): raise TypeError( "Cannot compare BendableScore instances with different " f"shapes: {len(self.hard_levels)}h/{len(self.soft_levels)}s " f"vs {len(other.hard_levels)}h/{len(other.soft_levels)}s." ) def _compare_payload(self) -> tuple[tuple[float, ...], tuple[float, ...]]: return (self.hard_levels, self.soft_levels) def __repr__(self) -> str: return ( f"BendableScore(hard={list(self.hard_levels)!r}, " f"soft={list(self.soft_levels)!r})" )
__all__ = [ "Score", "SimpleScore", "HardSoftScore", "BendableScore", ]