Files
Bike-Tinder/app/taste.py
Alexander Rozhenko beeace231d fix #3: guard breakdown() so a delisted mid-batch card cannot 500 the deck
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).
2026-11-15 11:06:51 -04:00

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