"""Attract / match scoring (R4, R5) — pure functions.""" from __future__ import annotations from typing import Dict, Iterable, Mapping, Optional, Sequence, Set def clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float: return max(lo, min(hi, x)) def l1_normalize(vec: Mapping[str, float]) -> Dict[str, float]: cleaned = {k: max(0.0, float(v)) for k, v in vec.items()} s = sum(cleaned.values()) if s <= 0: return {k: 0.0 for k in cleaned} return {k: v / s for k, v in cleaned.items()} def preference_match( preferred_atmosphere: Mapping[str, float], shop_atmosphere_snapshot: Mapping[str, float], ) -> float: """dot(L1_norm(pref), shop_snapshot) clamped — shop dims NOT renormalized.""" pref = l1_normalize(preferred_atmosphere) keys = set(pref) | set(shop_atmosphere_snapshot) total = 0.0 for k in keys: total += pref.get(k, 0.0) * float(shop_atmosphere_snapshot.get(k, 0.0)) return clamp(total) def shop_product_match(desired_tags: Sequence[str], displayed_product_tags: Iterable[str]) -> float: desired = set(desired_tags) if not desired: return 0.0 disp = set(displayed_product_tags) return len(desired & disp) / max(1, len(desired)) def item_match(desired_tags: Sequence[str], product_tags: Sequence[str]) -> float: desired = set(desired_tags) if not desired: return 0.0 return len(desired & set(product_tags)) / max(1, len(desired)) def layout_comfort(has_free_browse_spot: bool, current_customers: int, max_customers: int = 5) -> float: free = 1.0 if has_free_browse_spot else 0.3 crowding = (current_customers / max(1, max_customers)) if max_customers else 0.0 return clamp(0.5 * free + 0.5 * (1.0 - crowding)) def relationship_pull(stage: str, table: Optional[Mapping[str, float]] = None) -> float: default = { "STRANGER": 0.0, "ACQUAINTANCE": 0.25, "FAMILIAR": 0.45, "TRUSTED": 0.65, "BOND": 0.85, } t = table or default return float(t.get(stage, 0.0)) def compute_attract( *, preferred_atmosphere: Mapping[str, float], shop_atmosphere: Mapping[str, float], desired_tags: Sequence[str], displayed_tags: Iterable[str], has_free_browse_spot: bool, current_customers: int, stage: str = "STRANGER", weights: Optional[Mapping[str, float]] = None, rel_table: Optional[Mapping[str, float]] = None, max_customers: int = 5, weather_time_mod: float = 0.0, ) -> float: w = { "w_atm": 0.35, "w_prod": 0.30, "w_layout": 0.15, "w_rel": 0.15, "w_weather": 0.0, } if weights: w.update({k: float(v) for k, v in weights.items()}) pref = preference_match(preferred_atmosphere, shop_atmosphere) prod = shop_product_match(desired_tags, displayed_tags) lay = layout_comfort(has_free_browse_spot, current_customers, max_customers) rel = relationship_pull(stage, rel_table) return ( w["w_atm"] * pref + w["w_prod"] * prod + w["w_layout"] * lay + w["w_rel"] * rel + w["w_weather"] * weather_time_mod ) def displayed_tags_from_state(state: dict, products: Mapping[str, dict]) -> Set[str]: tags: Set[str] = set() for slot in state.get("shop", {}).get("displays", {}).values(): if not slot: continue pid = slot.get("product_id") qty = int(slot.get("qty") or 0) if not pid or qty <= 0: continue p = products.get(pid) or {} tags.update(p.get("tags") or []) return tags