"""Bike-Tinder P3 taste engine — deterministic, pure, stdlib-only. Implements docs/RANKING.md "Stage 2 — P3 taste engine": - event weights: action (like +1.0 / maybe +0.4 / nope -1.0) x round multiplier (round 1 x1, round >=2 x2) x recency (0.5^(age_days/21)). - feature weights per (feature, value): raw = sum of event weights, shrunk = raw * n/(n+3) with n the number of events in that cell. - taste_raw = sum of shrunk weights over the listing's feature values. - taste_norm = percentile rank of taste_raw among active listings (0-100). - ramp w = min(0.85, N/60) with N = total events; zero events => w = 0. - points = clip(round((1-w)*rubric + w*taste_norm + agent_adj), 0, 100) with agent_adj = sum of matching profile adjustments clamped to +/-10. - band() maps points to a non-numeric tank band: <30 nope / 30-70 maybe / >70 like. No randomness, no network. Everything is a pure function of (now, listings, events, profile_payload). """ import bisect import datetime import json import math import re from collections import Counter from typing import Any, Dict, List, Optional, Tuple LIKE, MAYBE, NOPE = 'like', 'maybe', 'nope' ACTIONS = (LIKE, MAYBE, NOPE) ACTION_WEIGHT = {LIKE: 1.0, MAYBE: 0.4, NOPE: -1.0} ROUND_CAP = 2 # engine caps round influence at 2 HALF_LIFE_DAYS = 21.0 RAMP_MAX = 0.85 RAMP_DENOM = 60.0 ADJ_CLAMP = 10.0 # --- feature extraction ------------------------------------------------------ PRICE_BANDS = [ (None, 3000, '<3k'), (3000, 5000, '3-5k'), (5000, 8000, '5-8k'), (8000, 12000, '8-12k'), (12000, None, '12k+'), ] DISP_BANDS = [ (None, 400, '<400'), (400, 650, '400-649'), (650, 900, '650-899'), (900, 1250, '900-1249'), (1250, None, '1250+'), ] YEAR_BANDS = [ (None, 2000, '<2000'), (2000, 2010, '2000-2009'), (2010, 2020, '2010-2019'), (2020, None, '2020+'), ] KM_BANDS = [ (None, 10000, '<10k'), (10000, 25000, '10-25k'), (25000, 60000, '25-60k'), (60000, None, '60k+'), ] SILHOUETTES = ['bobber', 'custom', 'classic', 'cruiser'] COLORS = [ ('red', 'red'), ('black', 'black'), ('white', 'white'), ('blue', 'blue'), ('green', 'green'), ('silver|grey|gray', 'silver'), ('orange', 'orange'), ('yellow', 'yellow'), ('brown|maroon', 'brown'), ('tan', 'tan'), ('gold', 'gold'), ] _CC_RE = re.compile(r'(\d[\d,]*)\s*cc\b', re.IGNORECASE) def band_for(sorted_edges, value): """Find the containing band (edges [(lo, hi, label)]); value None -> None.""" if value is None: return None for lo, hi, label in sorted_edges: if (lo is None or value >= lo) and (hi is None or value < hi): return label return None def price_band(price_num): return band_for(PRICE_BANDS, price_num) def disp_cc(title, desc): m = _CC_RE.search('%s %s' % (title or '', desc or '')) if not m: return None return int(m.group(1).replace(',', '')) def disp_band(cc): return band_for(DISP_BANDS, cc) def year_band(year): return band_for(YEAR_BANDS, year) def km_band(km): return band_for(KM_BANDS, km) def extract_features(row): """Return the listing's list of (dimension, value) feature cells. Missing values yield no cell (neutral per RANKING.md). """ title = row['title'] or '' desc = (row['desc'] or '').split(' [also listed:')[0] text = (title + ' ' + desc).lower() feats = [] if row['family']: feats.append(('family', row['family'])) pb = price_band(row['price_num']) if pb: feats.append(('price_band', pb)) db_ = disp_band(disp_cc(title, desc)) if db_: feats.append(('displacement', db_)) yb = year_band(row['year']) if yb: feats.append(('year', yb)) kb = km_band(row['km']) if kb: feats.append(('km', kb)) extras = row['extras'] if isinstance(extras, str): try: extras = json.loads(extras) except ValueError: extras = [] for label in extras or []: feats.append(('extras', label)) if row['seller_type'] in ('dealer', 'private'): feats.append(('seller', row['seller_type'])) for pat in SILHOUETTES: if re.search(r'\b%s\b' % pat, text): feats.append(('silhouette', pat)) for pat, label in COLORS: if re.search(r'\b%s\b' % pat, text): feats.append(('color', label)) if row['region'] in ('ns', 'nb'): feats.append(('region', row['region'])) return feats FEATURE_DIMS = ('family', 'price_band', 'displacement', 'year', 'km', 'extras', 'seller', 'silhouette', 'color', 'region') def family_short(family): """Compact family label for display (' — ' suffix trimmed).""" if not family: return family return family.split(' — ')[0].strip() # --- datetime handling ------------------------------------------------------- def parse_ts(value, now): """Parse an event created_at ('...T...Z' ISO or 'YYYY-MM-DD HH:MM:SS' sqlite default, both UTC) into a naive-UTC datetime; failures -> None.""" del now # kept in the signature for call sites base = (value or '').strip() if not base: return None if base.endswith('Z'): base = base[:-1] try: return datetime.datetime.fromisoformat(base) except ValueError: pass return None def event_weight(action, round_, created_at, now): """Weight of one swipe event: action x round x recency.""" w = ACTION_WEIGHT.get(action, 0.0) r = ROUND_CAP if (round_ or 1) >= ROUND_CAP else (round_ or 1) if r >= 2: w *= 2.0 ts = parse_ts(created_at, now) if ts is not None: age_days = max(0.0, (now - ts).total_seconds() / 86400.0) w *= math.pow(0.5, age_days / HALF_LIFE_DAYS) return w # --- the engine -------------------------------------------------------------- class TasteEngine: """Compute points / breakdown / tank for each listing from events. listings: rows (sqlite.Row or dict) with keys id, title, desc, family, score, price_num, km, year, seller_type, extras, region. events: rows with keys listing_id, action, round, created_at. profile: taste_profile payload dict (adjustments key used) or None. now: naive-UTC datetime for recency (default: datetime.utcnow()). """ def __init__( self, listings: List[Dict[str, Any]], events: List[Dict[str, Any]], profile: Optional[Dict[str, Any]] = None, now: Optional[datetime.datetime] = None, ) -> None: self.now = now or datetime.datetime.now(datetime.timezone.utc).replace( tzinfo=None) self.listings: List[Dict[str, Any]] = listings self.profile: Dict[str, Any] = profile or {} self.adjustments: List[Dict[str, Any]] = ( self.profile.get('adjustments') or []) # listing_id -> row (also covers listings reachable only via events) self.by_id: Dict[int, Dict[str, Any]] = { r['id']: r for r in self.listings} # feature cells per listing id self.cells: Dict[int, List[Tuple[str, str]]] = { r['id']: extract_features(r) for r in self.listings} # events -> weights, aggregated per (dim, value) self.events = list(events) cells_raw = {} cells_n = {} for ev in self.events: lid = ev['listing_id'] w = event_weight(ev['action'], ev['round'], ev['created_at'], self.now) for dim, val in self.cells.get(lid, []): key = (dim, val) cells_raw[key] = cells_raw.get(key, 0.0) + w cells_n[key] = cells_n.get(key, 0) + 1 self.cells_raw = cells_raw self.cells_n = cells_n self.event_counts = Counter(ev['listing_id'] for ev in self.events) self._norm_cache = None def round_next(self, listing_id): """Round the NEXT event on this listing would get, capped at 2.""" return min(self.event_counts.get(listing_id, 0) + 1, 2) # -- aggregates ----------------------------------------------------------- @property def n_events(self): return len(self.events) @property def w(self): """Taste share of the blend (ramp).""" return min(RAMP_MAX, self.n_events / RAMP_DENOM) def shrunk(self, dim, val): raw = self.cells_raw.get((dim, val), 0.0) n = self.cells_n.get((dim, val), 0) return raw * (n / (n + 3.0)) if n else 0.0 def family_net(self, family): """Net raw weight + nope count for a family label (all events).""" raw = 0.0 nopes = 0 for ev in self.events: row = self.by_id.get(ev['listing_id']) if row and row['family'] == family and family: raw += event_weight(ev['action'], ev['round'], ev['created_at'], self.now) if ev['action'] == NOPE: nopes += 1 return raw, nopes # -- per-listing scores --------------------------------------------------- def taste_raw(self, listing_id): return sum(self.shrunk(d, v) for d, v in self.cells.get(listing_id, [])) def taste_norm(self, listing_id): """Median-rank percentile (0-100) among active listings.""" if self._norm_cache is None: raws = {} for lid, cells in self.cells.items(): raws[lid] = sum(self.shrunk(d, v) for d, v in cells) ranked = sorted(raws.values()) n = len(ranked) self._norm_cache = {} for lid, r in raws.items(): less = bisect.bisect_left(ranked, r) eq = bisect.bisect_right(ranked, r) - less pct = 100.0 * (less + 0.5 * eq) / n if n else 50.0 self._norm_cache[lid] = pct return self._norm_cache.get(listing_id, 50.0) def agent_adj(self, listing_id): """Sum of matching profile adjustments, clamped to +/-10.""" total = 0.0 cells = {d: set() for d, _ in self.cells.get(listing_id, [])} for d, v in self.cells.get(listing_id, []): cells[d].add(v) for adj in self.adjustments: dim = adj.get('feature') val = adj.get('value') delta = adj.get('delta') if dim not in FEATURE_DIMS: continue if not isinstance(delta, (int, float)): continue if val in cells.get(dim, set()): total += float(delta) return max(-ADJ_CLAMP, min(ADJ_CLAMP, total)) def points(self, listing_id): row = self.by_id.get(listing_id) if row is None: return None pts = (1.0 - self.w) * (row['score'] or 0) pts += self.w * self.taste_norm(listing_id) pts += self.agent_adj(listing_id) return int(max(0, min(100, round(pts)))) @staticmethod def band(points): if points is None: return None if points < 30: return NOPE if points <= 70: return MAYBE return LIKE def tank(self, listing_id): pts = self.points(listing_id) band = self.band(pts) return {'value': pts, 'band': band} # -- explainability ------------------------------------------------------- def contributions(self, listing_id): """[(display_label, shrunk_weight, dim, val)] sorted by |w| DESC.""" out = [] for d, v in self.cells.get(listing_id, []): s = self.shrunk(d, v) if not s: continue label = ('%s %s' % (d, v)) if d == 'family': label = family_short(v) + ' family' out.append((label, s, d, v)) out.sort(key=lambda t: abs(t[1]), reverse=True) return out def breakdown(self, listing_id): """Source-labeled breakdown lines for the dossier.""" row = self.by_id.get(listing_id) lines = [] # issue #3: a section can outlive an ad. A listing that left the # active pool mid-batch (and has no swipe history) is not in # by_id; its breakdown degrades to no-signal lines instead of a # TypeError that would 500 the whole section render. if row is not None: lines.append({'source': 'rubric', 'label': 'rubric fit', 'value': int(row['score'] or 0)}) for label, s, _d, _v in self.contributions(listing_id)[:3]: lines.append({'source': 'taste', 'label': label, 'value': round(s, 1)}) for adj in self.adjustments: dim = adj.get('feature') val = adj.get('value') delta = adj.get('delta') if dim not in FEATURE_DIMS or not isinstance(delta, (int, float)): continue if val in {v for d, v in self.cells.get(listing_id, []) if d == dim}: why = adj.get('why') or ('%s %s' % (dim, val)) lines.append({'source': 'agent', 'label': why, 'value': round(float(delta), 1)}) return lines