A section can outlive an ad: when a card's listing left the active pool after compose and has no swipe history, it is absent from the engine's by_id map. breakdown() indexed the missing row without a guard, so one such card 500'd GET /api/section (and /api/listing/{id}) - live incident 2026-11-15: section 57 + delisted listing 298. Degrade to no-signal lines instead. Regression test added; before/after verified on a copy of the live DB (section 500 -> 200; forge rebuild 200).
403 lines
13 KiB
Python
403 lines
13 KiB
Python
"""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 |