feat: Added Partners, Backgrounds, and related variation selections to commander building.

This commit is contained in:
matt 2025-10-06 09:17:59 -07:00
parent 641b305955
commit d416c9b238
65 changed files with 11835 additions and 691 deletions

View file

@ -0,0 +1,662 @@
"""Partner suggestion scoring helpers.
This module provides a scoring helper that ranks potential partner/background
pairings for a selected primary commander. It consumes the normalized metadata
emitted by ``build_partner_suggestions.py`` (themes, role tags, partner flags,
and pairing telemetry) and blends several weighted components:
* Shared theme overlap (normalized Jaccard/role-aware) baseline synergy.
* Theme adjacency (deck export co-occurrence + curated overrides).
* Color compatibility (prefers compact color changes).
* Mechanic affinity (Partner With, Doctor/Companion, Background matches).
* Penalties (illegal configurations, missing tags, restricted conflicts).
Weights are mode-specific so future tuning can adjust emphasis without
rewriting the algorithm. The public ``score_partner_candidate`` helper returns
both the aggregate score and a component breakdown for diagnostics.
"""
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from typing import Dict, Iterable, Mapping, MutableMapping, Sequence
from .combined_commander import PartnerMode
__all__ = [
"PartnerSuggestionContext",
"ScoreWeights",
"ScoreResult",
"MODE_WEIGHTS",
"score_partner_candidate",
"is_noise_theme",
]
def _clean_str(value: object) -> str:
if value is None:
return ""
return str(value).strip()
def _normalize_token(value: str | None) -> str:
return _clean_str(value).casefold()
def _commander_name(payload: Mapping[str, object]) -> str:
name = _clean_str(payload.get("display_name")) or _clean_str(payload.get("name"))
return name or "Unknown Commander"
def _commander_key(payload: Mapping[str, object]) -> str:
return _normalize_token(_commander_name(payload))
def _sequence(payload: Mapping[str, object], key: str) -> tuple[str, ...]:
raw = payload.get(key)
if raw is None:
return tuple()
if isinstance(raw, (list, tuple)):
return tuple(_clean_str(item) for item in raw if _clean_str(item))
return tuple(filter(None, (_clean_str(raw),)))
_EXCLUDED_THEME_TOKENS = {
"legends matter",
"historics matter",
"partner",
"partner - survivors",
}
def _theme_should_be_excluded(theme: str) -> bool:
token = _normalize_token(theme)
if not token:
return False
if token in _EXCLUDED_THEME_TOKENS:
return True
return "kindred" in token
def is_noise_theme(theme: str | None) -> bool:
"""Return True when the provided theme is considered too generic/noisy.
The partner suggestion UI should suppress these themes from overlap summaries to
keep recommendations focused on distinctive archetypes.
"""
if theme is None:
return False
return _theme_should_be_excluded(theme)
def _theme_sequence(payload: Mapping[str, object], key: str = "themes") -> tuple[str, ...]:
return tuple(
theme
for theme in _sequence(payload, key)
if not _theme_should_be_excluded(theme)
)
def _normalize_string_set(values: Iterable[str]) -> tuple[str, ...]:
seen: set[str] = set()
collected: list[str] = []
for value in values:
token = _clean_str(value)
if not token:
continue
key = token.casefold()
if key in seen:
continue
seen.add(key)
collected.append(token)
return tuple(collected)
@dataclass(frozen=True)
class ScoreWeights:
"""Weight multipliers for each scoring component."""
overlap: float
synergy: float
color: float
affinity: float
penalty: float
@dataclass(frozen=True)
class ScoreResult:
"""Result returned by :func:`score_partner_candidate`."""
score: float
mode: PartnerMode
components: Mapping[str, float]
notes: tuple[str, ...]
weights: ScoreWeights
class PartnerSuggestionContext:
"""Container for suggestion dataset fragments used during scoring."""
def __init__(
self,
*,
theme_cooccurrence: Mapping[str, Mapping[str, int]] | None = None,
pairing_counts: Mapping[tuple[str, str, str], int] | None = None,
curated_synergy: Mapping[tuple[str, str], float] | None = None,
) -> None:
self._theme_cooccurrence: Dict[str, Dict[str, float]] = {}
self._pairing_counts: Dict[tuple[str, str, str], float] = {}
self._curated_synergy: Dict[tuple[str, str], float] = {}
max_co = 0
if theme_cooccurrence:
for theme, neighbors in theme_cooccurrence.items():
theme_key = _normalize_token(theme)
if not theme_key:
continue
store: Dict[str, float] = {}
for other, count in neighbors.items():
other_key = _normalize_token(other)
if not other_key:
continue
value = float(count or 0)
if value <= 0:
continue
store[other_key] = value
max_co = max(max_co, value)
if store:
self._theme_cooccurrence[theme_key] = store
self._theme_co_max = max(max_co, 1.0)
max_pair = 0
if pairing_counts:
for key, count in pairing_counts.items():
if not isinstance(key, tuple) or len(key) != 3:
continue
mode, primary, secondary = key
norm_key = (
_normalize_token(mode),
_normalize_token(primary),
_normalize_token(secondary),
)
value = float(count or 0)
if value <= 0:
continue
self._pairing_counts[norm_key] = value
# Store symmetric entry to simplify lookups.
symmetric = (
_normalize_token(mode),
_normalize_token(secondary),
_normalize_token(primary),
)
self._pairing_counts[symmetric] = value
max_pair = max(max_pair, value)
self._pairing_max = max(max_pair, 1.0)
if curated_synergy:
for key, value in curated_synergy.items():
if not isinstance(key, tuple) or len(key) != 2:
continue
primary, secondary = key
normalized = (
_normalize_token(primary),
_normalize_token(secondary),
)
if value is None:
continue
magnitude = max(0.0, float(value))
if magnitude <= 0:
continue
self._curated_synergy[normalized] = min(1.0, magnitude)
self._curated_synergy[(normalized[1], normalized[0])] = min(1.0, magnitude)
@classmethod
def from_dataset(cls, payload: Mapping[str, object] | None) -> "PartnerSuggestionContext":
if not payload:
return cls()
themes_raw = payload.get("themes")
theme_cooccurrence: Dict[str, Dict[str, int]] = {}
if isinstance(themes_raw, Mapping):
for theme_key, entry in themes_raw.items():
co = entry.get("co_occurrence") if isinstance(entry, Mapping) else None
if not isinstance(co, Mapping):
continue
inner: Dict[str, int] = {}
for other, info in co.items():
if isinstance(info, Mapping):
count = info.get("count")
else:
count = info
try:
inner[str(other)] = int(count)
except Exception:
continue
theme_cooccurrence[str(theme_key)] = inner
pairings = payload.get("pairings")
pairing_counts: Dict[tuple[str, str, str], int] = {}
if isinstance(pairings, Mapping):
records = pairings.get("records")
if isinstance(records, Sequence):
for entry in records:
if not isinstance(entry, Mapping):
continue
mode = str(entry.get("mode", "unknown"))
primary = str(entry.get("primary_canonical") or entry.get("primary") or "")
secondary = str(entry.get("secondary_canonical") or entry.get("secondary") or "")
if not primary or not secondary:
continue
try:
count = int(entry.get("count", 0))
except Exception:
continue
pairing_counts[(mode, primary, secondary)] = count
curated = payload.get("curated_overrides")
curated_synergy: Dict[tuple[str, str], float] = {}
if isinstance(curated, Mapping):
entries = curated.get("entries")
if isinstance(entries, Mapping):
for raw_key, raw_value in entries.items():
if not isinstance(raw_key, str):
continue
parts = [part.strip() for part in raw_key.split("::") if part.strip()]
if len(parts) != 2:
continue
try:
magnitude = float(raw_value)
except Exception:
continue
curated_synergy[(parts[0], parts[1])] = magnitude
return cls(
theme_cooccurrence=theme_cooccurrence,
pairing_counts=pairing_counts,
curated_synergy=curated_synergy,
)
@lru_cache(maxsize=256)
def theme_synergy(self, theme_a: str, theme_b: str) -> float:
key_a = _normalize_token(theme_a)
key_b = _normalize_token(theme_b)
if not key_a or not key_b or key_a == key_b:
return 0.0
co = self._theme_cooccurrence.get(key_a, {})
value = co.get(key_b, 0.0)
normalized = value / self._theme_co_max
curated = self._curated_synergy.get((key_a, key_b), 0.0)
return max(0.0, min(1.0, max(normalized, curated)))
@lru_cache(maxsize=128)
def pairing_strength(self, mode: PartnerMode, primary: str, secondary: str) -> float:
key = (
mode.value,
_normalize_token(primary),
_normalize_token(secondary),
)
value = self._pairing_counts.get(key, 0.0)
return max(0.0, min(1.0, value / self._pairing_max))
DEFAULT_WEIGHTS = ScoreWeights(
overlap=0.45,
synergy=0.25,
color=0.15,
affinity=0.10,
penalty=0.20,
)
MODE_WEIGHTS: Mapping[PartnerMode, ScoreWeights] = {
PartnerMode.PARTNER: DEFAULT_WEIGHTS,
PartnerMode.PARTNER_WITH: ScoreWeights(overlap=0.40, synergy=0.20, color=0.10, affinity=0.20, penalty=0.25),
PartnerMode.BACKGROUND: ScoreWeights(overlap=0.50, synergy=0.30, color=0.10, affinity=0.10, penalty=0.25),
PartnerMode.DOCTOR_COMPANION: ScoreWeights(overlap=0.30, synergy=0.20, color=0.10, affinity=0.30, penalty=0.25),
PartnerMode.NONE: DEFAULT_WEIGHTS,
}
def _clamp(value: float, minimum: float = 0.0, maximum: float = 1.0) -> float:
if value < minimum:
return minimum
if value > maximum:
return maximum
return value
def score_partner_candidate(
primary: Mapping[str, object],
candidate: Mapping[str, object],
*,
mode: PartnerMode | str | None = None,
context: PartnerSuggestionContext | None = None,
) -> ScoreResult:
"""Score a partner/background candidate for the provided primary.
Args:
primary: Commander metadata dictionary (as produced by the dataset).
candidate: Potential partner/background metadata dictionary.
mode: Desired partner mode (auto-detected when omitted).
context: Optional suggestion context providing theme/pairing statistics.
Returns:
ScoreResult with aggregate score ``0.0`` ``1.0`` and component details.
"""
mode = _resolve_mode(primary, candidate, mode)
weights = MODE_WEIGHTS.get(mode, DEFAULT_WEIGHTS)
ctx = context or PartnerSuggestionContext()
overlap = _theme_overlap(primary, candidate)
synergy = _theme_synergy(primary, candidate, ctx)
color_value = _color_compatibility(primary, candidate)
affinity, affinity_notes, affinity_penalties = _mechanic_affinity(primary, candidate, mode, ctx)
penalty_value, penalty_notes = _collect_penalties(primary, candidate, mode, affinity_penalties)
positive_total = weights.overlap + weights.synergy + weights.color + weights.affinity
positive_total = positive_total or 1.0
blended = (
weights.overlap * overlap
+ weights.synergy * synergy
+ weights.color * color_value
+ weights.affinity * affinity
) / positive_total
adjusted = blended - weights.penalty * penalty_value
final_score = _clamp(adjusted)
notes = tuple(note for note in (*affinity_notes, *penalty_notes) if note)
components = {
"overlap": overlap,
"synergy": synergy,
"color": color_value,
"affinity": affinity,
"penalty": penalty_value,
}
return ScoreResult(
score=final_score,
mode=mode,
components=components,
notes=notes,
weights=weights,
)
def _resolve_mode(
primary: Mapping[str, object],
candidate: Mapping[str, object],
provided: PartnerMode | str | None,
) -> PartnerMode:
if isinstance(provided, PartnerMode):
return provided
if isinstance(provided, str) and provided:
normalized = provided.replace("-", "_").strip().casefold()
for mode in PartnerMode:
if mode.value == normalized:
return mode
partner_meta_primary = _partner_meta(primary)
partner_meta_candidate = _partner_meta(candidate)
candidate_name = _commander_name(candidate)
if partner_meta_candidate.get("is_background"):
return PartnerMode.BACKGROUND
partner_with = {
_normalize_token(name)
for name in partner_meta_primary.get("partner_with", [])
}
if partner_with and _normalize_token(candidate_name) in partner_with:
return PartnerMode.PARTNER_WITH
if partner_meta_primary.get("is_doctor") and partner_meta_candidate.get("is_doctors_companion"):
return PartnerMode.DOCTOR_COMPANION
if partner_meta_primary.get("is_doctors_companion") and partner_meta_candidate.get("is_doctor"):
return PartnerMode.DOCTOR_COMPANION
if partner_meta_primary.get("has_partner") and partner_meta_candidate.get("has_partner"):
return PartnerMode.PARTNER
if partner_meta_candidate.get("supports_backgrounds") and partner_meta_primary.get("is_background"):
return PartnerMode.BACKGROUND
if partner_meta_candidate.get("has_partner"):
return PartnerMode.PARTNER
return PartnerMode.PARTNER
def _partner_meta(payload: Mapping[str, object]) -> MutableMapping[str, object]:
meta = payload.get("partner")
if isinstance(meta, Mapping):
return dict(meta)
return {}
def _theme_overlap(primary: Mapping[str, object], candidate: Mapping[str, object]) -> float:
theme_primary = {
_normalize_token(theme)
for theme in _theme_sequence(primary)
}
theme_candidate = {
_normalize_token(theme)
for theme in _theme_sequence(candidate)
}
theme_primary.discard("")
theme_candidate.discard("")
role_primary = {
_normalize_token(tag)
for tag in _sequence(primary, "role_tags")
}
role_candidate = {
_normalize_token(tag)
for tag in _sequence(candidate, "role_tags")
}
role_primary.discard("")
role_candidate.discard("")
# Base Jaccard over theme tags.
union = theme_primary | theme_candidate
if not union:
base = 0.0
else:
base = len(theme_primary & theme_candidate) / len(union)
# Role-aware bonus (weighted at 30% of overlap component).
role_union = role_primary | role_candidate
if not role_union:
role_score = 0.0
else:
role_score = len(role_primary & role_candidate) / len(role_union)
combined = 0.7 * base + 0.3 * role_score
return _clamp(combined)
def _theme_synergy(
primary: Mapping[str, object],
candidate: Mapping[str, object],
context: PartnerSuggestionContext,
) -> float:
themes_primary = _theme_sequence(primary)
themes_candidate = _theme_sequence(candidate)
if not themes_primary or not themes_candidate:
return 0.0
total = 0.0
weight = 0
for theme_a in themes_primary:
for theme_b in themes_candidate:
value = context.theme_synergy(theme_a, theme_b)
if value <= 0:
continue
total += value
weight += 1
if weight == 0:
return 0.0
average = total / weight
# Observed pairing signal augments synergy.
primary_name = _commander_name(primary)
candidate_name = _commander_name(candidate)
observed_partner = context.pairing_strength(PartnerMode.PARTNER, primary_name, candidate_name)
observed_background = context.pairing_strength(PartnerMode.BACKGROUND, primary_name, candidate_name)
observed_doctor = context.pairing_strength(PartnerMode.DOCTOR_COMPANION, primary_name, candidate_name)
observed_any = max(observed_partner, observed_background, observed_doctor)
return _clamp(max(average, observed_any))
def _color_compatibility(primary: Mapping[str, object], candidate: Mapping[str, object]) -> float:
primary_colors = {
_clean_str(color).upper()
for color in _sequence(primary, "color_identity")
}
candidate_colors = {
_clean_str(color).upper()
for color in _sequence(candidate, "color_identity")
}
if not candidate_colors:
# Colorless partners still provide value when primary is colored.
return 0.6 if primary_colors else 0.0
overlap = primary_colors & candidate_colors
union = primary_colors | candidate_colors
overlap_ratio = len(overlap) / max(len(candidate_colors), 1)
added_colors = len(union) - len(primary_colors)
if added_colors <= 0:
delta = 1.0
elif added_colors == 1:
delta = 0.75
elif added_colors == 2:
delta = 0.45
else:
delta = 0.20
colorless_bonus = 0.1 if candidate_colors == {"C"} else 0.0
blended = 0.6 * overlap_ratio + 0.4 * delta + colorless_bonus
return _clamp(blended)
def _mechanic_affinity(
primary: Mapping[str, object],
candidate: Mapping[str, object],
mode: PartnerMode,
context: PartnerSuggestionContext,
) -> tuple[float, list[str], list[tuple[str, float]]]:
primary_meta = _partner_meta(primary)
candidate_meta = _partner_meta(candidate)
primary_name = _commander_name(primary)
candidate_name = _commander_name(candidate)
notes: list[str] = []
penalties: list[tuple[str, float]] = []
score = 0.0
if mode is PartnerMode.PARTNER_WITH:
partner_with = {
_normalize_token(name)
for name in primary_meta.get("partner_with", [])
}
if partner_with and _normalize_token(candidate_name) in partner_with:
score = 1.0
notes.append("partner_with_match")
else:
penalties.append(("missing_partner_with_link", 0.9))
elif mode is PartnerMode.BACKGROUND:
if candidate_meta.get("is_background") and primary_meta.get("supports_backgrounds"):
score = 0.9
notes.append("background_compatible")
else:
if not candidate_meta.get("is_background"):
penalties.append(("candidate_not_background", 1.0))
if not primary_meta.get("supports_backgrounds"):
penalties.append(("primary_cannot_use_background", 1.0))
elif mode is PartnerMode.DOCTOR_COMPANION:
primary_is_doctor = bool(primary_meta.get("is_doctor"))
primary_is_companion = bool(primary_meta.get("is_doctors_companion"))
candidate_is_doctor = bool(candidate_meta.get("is_doctor"))
candidate_is_companion = bool(candidate_meta.get("is_doctors_companion"))
if primary_is_doctor and candidate_is_companion:
score = 1.0
notes.append("doctor_companion_match")
elif primary_is_companion and candidate_is_doctor:
score = 1.0
notes.append("doctor_companion_match")
else:
penalties.append(("doctor_pairing_illegal", 1.0))
else: # Partner-style default
if primary_meta.get("has_partner") and candidate_meta.get("has_partner"):
score = 0.6
notes.append("shared_partner_keyword")
else:
penalties.append(("missing_partner_keyword", 1.0))
primary_labels = {
_normalize_token(label)
for label in _sequence(primary_meta, "restricted_partner_labels")
}
candidate_labels = {
_normalize_token(label)
for label in _sequence(candidate_meta, "restricted_partner_labels")
}
shared_labels = primary_labels & candidate_labels
if primary_labels or candidate_labels:
if shared_labels:
score = max(score, 0.85)
notes.append("restricted_label_match")
else:
penalties.append(("restricted_label_mismatch", 0.7))
observed = context.pairing_strength(mode, primary_name, candidate_name)
if observed > 0:
score = max(score, observed)
notes.append("observed_pairing")
return _clamp(score), notes, penalties
def _collect_penalties(
primary: Mapping[str, object],
candidate: Mapping[str, object],
mode: PartnerMode,
extra: Iterable[tuple[str, float]],
) -> tuple[float, list[str]]:
penalties: list[tuple[str, float]] = list(extra)
themes_primary_raw = _sequence(primary, "themes")
themes_candidate_raw = _sequence(candidate, "themes")
themes_primary = _theme_sequence(primary)
themes_candidate = _theme_sequence(candidate)
if (not themes_primary or not themes_candidate) and (not themes_primary_raw or not themes_candidate_raw):
penalties.append(("missing_theme_metadata", 0.5))
if mode is PartnerMode.PARTNER_WITH:
partner_with = {
_normalize_token(name)
for name in _sequence(primary.get("partner", {}), "partner_with")
}
if not partner_with:
penalties.append(("primary_missing_partner_with", 0.7))
colors_candidate = set(_sequence(candidate, "color_identity"))
if len(colors_candidate) >= 4:
penalties.append(("candidate_color_spread", 0.25))
total = 0.0
reasons: list[str] = []
for reason, magnitude in penalties:
if magnitude <= 0:
continue
total += magnitude
reasons.append(reason)
return _clamp(total), reasons