Source code for mllm_shap.masking.partitions

"""Player partition plugins: whitespace tokens and whole messages.

@domain masking
@see [[knowledge/architecture/masking]]
@tested-by [[tests/masking/test_partitions.py]]
"""

from typing import TYPE_CHECKING, ClassVar, Final

from pydantic import Field

from ..domain.conversation import ConversationSnapshot, Message
from ..domain.enums import RoleName
from ..domain.players import PlayerSet, PlayerSpan
from ..protocols.plugin import PluginOptions
from .tokens import token_spans

if TYPE_CHECKING:
    from ..plugins.context import PluginContext


[docs] class RolesOptions(PluginOptions): """Options of the ``tokens`` and ``messages`` partitions.""" roles: list[RoleName] = Field(default=["user", "assistant", "system"]) """Roles whose text becomes players; other messages are always present."""
MessagesOptions = RolesOptions """Options of the ``messages`` partition (same fields as ``tokens``).""" def _roles(context: "PluginContext | None") -> frozenset[str]: roles = RolesOptions().roles if context is not None: roles = context.option("roles", roles) return frozenset(roles)
[docs] class TokenPartitioner: """Partition a snapshot into whitespace-delimited text tokens.""" options_model: ClassVar[type[PluginOptions]] = RolesOptions """Options accepted from ``explanation.plugin_options``.""" def __init__(self, context: "PluginContext | None" = None) -> None: """Read option ``roles`` from ``context``; every role by default.""" self.roles = _roles(context) """Roles whose tokens become players.""" @property def name(self) -> str: """Return the registered partition identifier.""" return "tokens"
[docs] def partition(self, snapshot: ConversationSnapshot) -> PlayerSet: """Derive ordered token players and their spans from the snapshot text. Tokens of messages whose role is not in :attr:`roles` are no players and stay present in every coalition; player ids do not depend on the filter. """ messages = snapshot.messages() pairs = tuple( (player_id, span) for player_id, span in token_spans(snapshot) if messages[span.message_index].role.value in self.roles ) if not pairs: msg = "snapshot must contain at least one explainable token" raise ValueError(msg) return PlayerSet( player_ids=tuple(player_id for player_id, _ in pairs), spans=tuple(span for _, span in pairs), )
LABEL_PREVIEW_CHARS: Final[int] = 30 """Characters of the message text shown in a message player's label."""
[docs] class MessagePartitioner: """Partition a snapshot into one player per non-empty message.""" options_model: ClassVar[type[PluginOptions]] = RolesOptions """Options accepted from ``explanation.plugin_options``.""" def __init__(self, context: "PluginContext | None" = None) -> None: """Read option ``roles`` from ``context``; every role by default.""" self.roles = _roles(context) """Roles whose messages become players.""" @property def name(self) -> str: """Return the registered partition identifier.""" return "messages"
[docs] def partition(self, snapshot: ConversationSnapshot) -> PlayerSet: """Make each selected message a player whose span is its whole text. Player ids are ``{snapshot_id}:{turn}:{message}``; labels read ``"<role>[/<speaker>] <n>: <first 30 characters>"`` with ``n`` the 1-based message position. """ ids: list[str] = [] spans: list[PlayerSpan] = [] index = 0 for turn_index, turn in enumerate(snapshot.turns): for local_index, message in enumerate(turn.messages): if message.text and message.role.value in self.roles: ids.append(f"{snapshot.snapshot_id}:{turn_index}:{local_index}") spans.append(_message_span(index, message)) index += 1 if not spans: msg = "snapshot must contain at least one non-empty message to explain" raise ValueError(msg) return PlayerSet(player_ids=tuple(ids), spans=tuple(spans))
def _message_span(index: int, message: Message) -> PlayerSpan: head = message.role.value if message.speaker is not None: head += f"/{message.speaker}" preview = message.text[:LABEL_PREVIEW_CHARS] if len(message.text) > LABEL_PREVIEW_CHARS: preview += "…" return PlayerSpan( message_index=index, start=0, end=len(message.text), text=message.text, speaker=message.speaker, label=f"{head} {index + 1}: {preview}", )