"""Bike-Tinder P3 section engine — compose / read / exhaust / forge. v1.1 batch formula (amends the frozen "<=5 returns" rule — one-shot, re-nope and one-return rules unchanged): every batch targets 25 cards = fresh first (up to 23 slots) + >=2 second looks whenever eligible stock exists + the 2 wildcard slots; when fresh < the remainder, second looks FILL the batch up to 25 (family cap relaxes to 8 for fill returns only). Fresh exhausted -> returns-only batches; returns exhausted -> fresh-only; both -> an empty composed section (the loud empty state, item 8). Pinned order within a section (position 1 first): new (points DESC) -> returns (maybe > like > nope, points DESC within role) -> wildcards (wildcard score DESC). "The best suggestions lead" (docs/PLAN.md); returns form their own sub-sequence in role order; wildcard stretches sit at the back. (Wildcard placement inside the deck is not pinned anywhere in the frozen docs — noted in the P3 report.) Constraints: <=4 per non-NULL family per section (fill-return slots relax to <=8); >=3 price bands when the pool allows (soft repair swaps new-slot items within the final list). <=2 re-nope returns per batch (reserved nope slots must be "surprising"). A re-nope (round>=2 nope event) rests an item: no more second looks. Rounds cap at 2 for influence (taste.py); returns are offered once. Modes: 'ondemand' (default) — forge runs the agent pass then composes; 'strict' — forge refuses (409) unless a taste_profile strictly newer than the current section's profile_version exists. """ import json from . import taste as tastemod from .taste import NOPE FAMILY_CAP = 4 RETURN_FILL_FAMILY_CAP = 8 MAX_RETURN_NOPES = 2 WILDCARDS = 2 FRESH_CAP = 23 MIN_RETURNS = 2 DEFAULT_SIZE = 25 MIN_BANDS = 3 LAST_TWO_SQL = """ SELECT si.listing_id FROM section_items si WHERE si.section_id IN (SELECT id FROM sections ORDER BY id DESC LIMIT 2) """ NEW_POOL_SQL = """ SELECT l.* FROM listings l WHERE l.status = 'active' AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id) """ RETURNS_SQL = """ SELECT l.*, s.action AS verdict FROM listings l JOIN swipes s ON s.listing_id = l.id WHERE l.status = 'active' AND NOT EXISTS (SELECT 1 FROM swipe_events e WHERE e.listing_id = l.id AND e.round >= 2 AND e.action = 'nope') AND NOT EXISTS (SELECT 1 FROM section_items si WHERE si.listing_id = l.id AND si.slot = 'return') """ WILDCARD_SQL = """ SELECT l.* FROM listings l WHERE l.status = 'active' AND NOT EXISTS (SELECT 1 FROM swipe_events e WHERE e.listing_id = l.id) """ # --- helpers ---------------------------------------------------------------- def _iso(now): return now.strftime('%Y-%m-%dT%H:%M:%SZ') def last_two_listing_ids(conn): return {r['listing_id'] for r in conn.execute(LAST_TWO_SQL)} def get_current_section(conn): """The newest open/report section, or None.""" return conn.execute( "SELECT * FROM sections WHERE status IN ('open','report')" ' ORDER BY id DESC LIMIT 1').fetchone() def latest_profile(conn): return conn.execute( 'SELECT * FROM taste_profile ORDER BY version DESC LIMIT 1' ).fetchone() def build_engine(conn, profile=None, now=None): """TasteEngine over active listings + every listing an event touches, with the given profile payload (default: the latest version).""" rows = [r for r in conn.execute( "SELECT * FROM listings WHERE status='active'")] ev_ids = {r['listing_id'] for r in conn.execute( 'SELECT DISTINCT listing_id FROM swipe_events')} missing = ev_ids - {r['id'] for r in rows} if missing: qmarks = ','.join('?' * len(missing)) rows += [r for r in conn.execute( 'SELECT * FROM listings WHERE id IN (%s)' % qmarks, tuple(missing))] events = conn.execute('SELECT * FROM swipe_events ORDER BY id').fetchall() payload = None if profile is None: profile = latest_profile(conn) if profile is not None and profile['payload']: try: payload = json.loads(profile['payload']) except ValueError: payload = None return tastemod.TasteEngine(rows, events, payload, now=now) def fresh_profile_exists(conn, section): """Strict-mode freshness: a profile version newer than the section's.""" cur = section['profile_version'] if section else None if latest_profile(conn) is None: return False if cur is None: return True return latest_profile(conn)['version'] > cur # --- wildcard novelty -------------------------------------------------------- def _cosine(a, b): dot = sum(a.get(k, 0.0) * b.get(k, 0.0) for k in (a.keys() | b.keys())) na = sum(x * x for x in a.values()) ** 0.5 nb = sum(x * x for x in b.values()) ** 0.5 if not na or not nb: return 0.0 return dot / (na * nb) def novelty(rows_by_id, liked_ids, candidate_ids): """0..1 distance from the liked-profile centroid (1 = very novel).""" if not liked_ids: return {lid: 0.5 for lid in candidate_ids} centroid = {} liked = [l for l in liked_ids if l in rows_by_id] for lid in liked: for d, v in tastemod.extract_features(rows_by_id[lid]): centroid[(d, v)] = centroid.get((d, v), 0.0) + 1.0 n = len(liked) or 1 centroid = {k: v / n for k, v in centroid.items()} out = {} for lid in candidate_ids: vec = {(d, v): 1.0 for d, v in tastemod.extract_features(rows_by_id[lid])} out[lid] = 1.0 - _cosine(vec, centroid) return out # --- compose ----------------------------------------------------------------- def _points_key(engine, row): """Deterministic sort key: points, rubric score, first_seen, id. Use with reverse=True for descending order.""" return (engine.points(row['id']), row['score'] or 0, row['first_seen'] or '', row['id']) def pick_new(engine, candidates, want, fam_count, picked_ids): """Greedy new picks by points DESC honoring the family cap.""" out = [] ordered = sorted(candidates, key=lambda r: _points_key(engine, r), reverse=True) for cand in ordered: if len(out) >= want: break lid = cand['id'] if lid in picked_ids: continue fam = cand['family'] if fam and fam_count.get(fam, 0) >= FAMILY_CAP: continue out.append(cand) picked_ids.add(lid) if fam: fam_count[fam] = fam_count.get(fam, 0) + 1 return out def repair_bands(engine, picked, candidates, fam_count, picked_ids): """Swap new-slot items to reach >=3 price bands when the pool allows. Only swaps INTO slots of new items in over-represented bands; returns and wildcard slots are never touched. Deterministic; bounded passes. """ if len(picked) < MIN_BANDS: return picked bands_of = {r['id']: tastemod.price_band(r['price_num']) for r in candidates} bands_of.update({r['id']: tastemod.price_band(r['price_num']) for r in picked}) cur_bands = {bands_of[r['id']] for r in picked if bands_of[r['id']] is not None} if len(cur_bands) >= MIN_BANDS: return picked pool_bands = {bands_of[r['id']] for r in candidates if bands_of[r['id']] is not None} if len(pool_bands) < MIN_BANDS: return picked # the pool does not allow 3 bands picked = list(picked) attempts = len(candidates) for _ in range(attempts): cur_bands = {bands_of[r['id']] for r in picked if bands_of[r['id']] is not None} if len(cur_bands) >= MIN_BANDS: break missing = [b for b in pool_bands if b not in cur_bands] if not missing: break want_band = missing[0] best = None best_key = None for cand in candidates: if cand['id'] in picked_ids: continue if bands_of[cand['id']] != want_band: continue fam = cand['family'] if fam and fam_count.get(fam, 0) >= FAMILY_CAP: continue key = _points_key(engine, cand) if best_key is None or key > best_key: best, best_key = cand, key if best is None: pool_bands = pool_bands - {want_band} if len(pool_bands) < MIN_BANDS: break continue # victim: lowest-points new-slot item from a band with >=2 reps counts = {} for r in picked: b = bands_of[r['id']] if b is not None: counts[b] = counts.get(b, 0) + 1 victim = None worst_first = sorted(picked, key=lambda r: _points_key(engine, r), reverse=True) for band in sorted(counts, key=lambda b: -counts[b]): if counts[band] < 2: continue in_band = [r for r in worst_first if bands_of[r['id']] == band] if in_band: victim = in_band[-1] break if victim is None: break victim_fam = victim['family'] idx = next(i for i, r in enumerate(picked) if r['id'] == victim['id']) picked[idx] = best if victim_fam and victim_fam in fam_count: fam_count[victim_fam] = max(0, fam_count[victim_fam] - 1) if best['family']: fam_count[best['family']] = fam_count.get(best['family'], 0) + 1 picked_ids.remove(victim['id']) picked_ids.add(best['id']) return picked def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None, note='', now=None): """Compose and persist a new open section; returns the section row. Caller closes/reports any prior section first. `engine` scores the candidates (a TasteEngine over active listings + events + profile). v1.1 slot math (see module docstring; this amends the frozen "<=5 returns" rule): reserved returns (>=2 when eligible stock exists) -> wildcards (2) -> fresh (capped at 23) -> fill returns to `size` (family cap 4 preferred, 8 rescue; <=2 re-nopes total). """ import datetime now = now or datetime.datetime.now(datetime.timezone.utc).replace( tzinfo=None) size = int(size) rows_by_id = {r['id']: r for r in engine.listings} last_two = last_two_listing_ids(conn) new_cands = [r for r in conn.execute(NEW_POOL_SQL) if r['id'] not in last_two] ret_cands = [r for r in conn.execute(RETURNS_SQL) if r['id'] not in last_two] wild_cands = [r for r in conn.execute(WILDCARD_SQL) if r['id'] not in last_two] # wildcard family exclusions: >=3 cumulative nopes AND net-negative. # Stats are computed for every family the engine knows (active + event # listings), not just current candidates — a hard-noped family is # excluded even when its rows are gone/cleared. fam_stats = {f: engine.family_net(f) for f in {r['family'] for r in engine.listings if r['family']}} excluded_fams = {f for f, (net, nopes) in fam_stats.items() if nopes >= 3 and net < 0} wild_cands = [r for r in wild_cands if not (r['family'] and r['family'] in excluded_fams)] fam_count = {} picked_ids = set() # 1) preliminary top-3 new (uncommitted) — keys the wildcard preference top3 = pick_new(engine, new_cands, 3, {}, set()) top_fams = {r['family'] for r in top3 if r['family']} top_bands = {tastemod.price_band(r['price_num']) for r in top3 if tastemod.price_band(r['price_num']) is not None} # 2) reserved returns: the >=2 guarantee (role order maybe > like > nope, # points DESC within role). Nopes must be "surprising" for RESERVED # slots only (points >=55 or net-positive family); <=2 re-nope # returns per batch overall; family cap 4 applies here. role_order = {'maybe': 0, 'like': 1, 'nope': 2} ret_ordered = sorted(ret_cands, reverse=True, key=lambda r: (-role_order.get(r['verdict'], 9), _points_key(engine, r))) rets = [] nope_count = 0 for cand in ret_ordered: if len(rets) >= min(MIN_RETURNS, size): break fam = cand['family'] if fam and fam_count.get(fam, 0) >= FAMILY_CAP: continue if cand['verdict'] == NOPE: if nope_count >= MAX_RETURN_NOPES: continue net, _nopes = fam_stats.get(fam, (0.0, 0)) if engine.points(cand['id']) < 55 and not (fam and net > 0): continue nope_count += 1 rets.append(cand) picked_ids.add(cand['id']) if fam: fam_count[fam] = fam_count.get(fam, 0) + 1 # 3) wildcards: min(2, remaining slots), preferred different family AND # price band (existing novel/preferred pick, unchanged) liked_ids = [r['listing_id'] for r in conn.execute( "SELECT listing_id FROM swipes WHERE action='like'")] nov = novelty(rows_by_id, liked_ids, [r['id'] for r in wild_cands]) wild_key = lambda r: ( -(0.5 * engine.points(r['id']) + 50.0 * nov[r['id']]), r['id']) def _wild_preferred(rs): return [r for r in rs if (not r['family'] or r['family'] not in top_fams) and (tastemod.price_band(r['price_num']) is None or tastemod.price_band(r['price_num']) not in top_bands)] n_wilds = min(WILDCARDS, max(0, size - len(rets))) wilds = [] avail = sorted([r for r in wild_cands if r['id'] not in picked_ids], key=wild_key) preferred = sorted(_wild_preferred(avail), key=wild_key) pref_ids = {r['id'] for r in preferred} for cand in preferred + [r for r in avail if r['id'] not in pref_ids]: if len(wilds) >= n_wilds: break fam = cand['family'] if fam and fam_count.get(fam, 0) >= FAMILY_CAP: continue wilds.append(cand) picked_ids.add(cand['id']) if fam: fam_count[fam] = fam_count.get(fam, 0) + 1 n_wilds = len(wilds) # pool shortfall reverts to fresh/fill # 4) fresh fills the remainder (size - returns - wildcards), never # beyond FRESH_CAP. Wildcards draw from the fresh pool but do NOT # count against the fresh-slot cap (25 - 0 - 2 = 23 new + 2 wild on # a returns-free rich day; 21 new + 2 wild + 2 returns when both # guarantees hold — the sign-off shape). Pool-limited by what the # wildcard pick left in new_cands; a dry wild pool reverts slots. want_new = min(size - len(rets) - len(wilds), FRESH_CAP, max(0, len(new_cands) - len(wilds))) picked_new = pick_new(engine, new_cands, want_new, fam_count, picked_ids) # 5) fill returns to size: role order; re-nope cap <=2 total (reserved # included, no surprising gate here); two-pass family cap — prefer # <=4, rescue pass relaxes to <=8 for FILL slots only (fresh picks # and reserved returns keep 4). Deterministic: role order, points # DESC within role. for pass_cap in (FAMILY_CAP, RETURN_FILL_FAMILY_CAP): for cand in ret_ordered: if len(rets) >= size - len(picked_new) - n_wilds: break if cand['id'] in picked_ids: continue fam = cand['family'] if fam and fam_count.get(fam, 0) >= pass_cap: continue if cand['verdict'] == NOPE and nope_count >= MAX_RETURN_NOPES: continue rets.append(cand) picked_ids.add(cand['id']) if cand['verdict'] == NOPE: nope_count += 1 if fam: fam_count[fam] = fam_count.get(fam, 0) + 1 # final pinned order: new -> returns -> wildcards ordered = list(picked_new) + rets + wilds # price-band repair (new slots only — ordered[:len(picked_new)]) n_new = len(picked_new) before = len({tastemod.price_band(r['price_num']) for r in ordered if tastemod.price_band(r['price_num']) is not None}) if n_new: ordered[:n_new] = repair_bands(engine, ordered[:n_new], new_cands, fam_count, picked_ids) after = len({tastemod.price_band(r['price_num']) for r in ordered if tastemod.price_band(r['price_num']) is not None}) recipe = { 'new': len(picked_new), 'return': len(rets), 'wildcard': len(wilds), 'bands': before if before == after else after, 'caps_applied': {f: fam_count[f] for f in fam_count if fam_count[f] >= FAMILY_CAP}, 'note': note or ('bands %d->%d repaired' % (before, after) if before != after else ''), } cur = conn.execute( """INSERT INTO sections (status, size, profile_version, recipe, created_at) VALUES ('open', ?, ?, ?, ?)""", (size, profile_version, json.dumps(recipe), _iso(now))) section_id = cur.lastrowid conn.executemany( """INSERT INTO section_items (section_id, listing_id, slot, position) VALUES (?, ?, ?, ?)""", [(section_id, r['id'], slot, pos) for pos, (r, slot) in enumerate( [(r, 'new') for r in ordered[:n_new]] + [(r, 'return') for r in rets] + [(r, 'wildcard') for r in wilds], 1)]) conn.commit() return conn.execute('SELECT * FROM sections WHERE id=?', (section_id,)).fetchone() # --- read / exhaust ---------------------------------------------------------- def close_section(conn, section, now, status='closed', created_at_col='closed_at'): conn.execute( 'UPDATE sections SET status=?, %s=? WHERE id=?' % created_at_col, (status, _iso(now), section['id'])) conn.commit() def section_items(conn, section): return conn.execute( """SELECT l.*, s.action AS decision, s.last_photo_idx AS last_photo_idx, si.slot, si.position FROM section_items si JOIN listings l ON l.id = si.listing_id LEFT JOIN swipes s ON s.listing_id = l.id WHERE si.section_id = ? ORDER BY si.position ASC""", (section['id'],)).fetchall() def section_decided_ids(conn, section): """Listing ids with a swipe event stamped in THIS section. The decided set for progress/report/exhaustion is always these ids — never the lifetime `swipes.action` verdict (a second-look card enters its section wearing an old decision; only an in-section event means it was actually decided here). The join keeps out-of-section stray events honest (same shape `section_counts` uses). """ return {r['listing_id'] for r in conn.execute( """SELECT e.listing_id FROM swipe_events e JOIN section_items si ON si.section_id = e.section_id AND si.listing_id = e.listing_id WHERE e.section_id = ?""", (section['id'],))} def pool_counts(conn): """Stock answer to \"can the forge serve anything?\" — fresh / eligible returns / wildcards candidate counts, each from the composer's own candidate SQL. The per-section LAST_TWO exclusion inside compose_section is deliberately NOT applied here: the answer is about stock, not about what one batch will pick (item 8).""" def _count(sql): return conn.execute( 'SELECT COUNT(*) AS n FROM (%s)' % sql).fetchone()['n'] return {'fresh': _count(NEW_POOL_SQL), 'returns': _count(RETURNS_SQL), 'wildcards': _count(WILDCARD_SQL)} def section_counts(conn, section): """Per-action counts + second-look / wildcard engagement for this section (events stamped with this section_id only).""" counts = {'likes': 0, 'maybes': 0, 'nopes': 0} # one verdict per listing: the latest in-section event wins (an undo + # re-swipe within a section must not double-count the report numbers) latest = {} for ev in conn.execute( """SELECT e.listing_id, e.action FROM swipe_events e JOIN section_items si ON si.section_id = e.section_id AND si.listing_id = e.listing_id WHERE e.section_id = ? ORDER BY e.id ASC""", (section['id'],)): latest[ev['listing_id']] = ev['action'] for action in latest.values(): if action == 'like': counts['likes'] += 1 elif action == 'maybe': counts['maybes'] += 1 elif action == 'nope': counts['nopes'] += 1 restruck = conn.execute( """SELECT COUNT(DISTINCT e.listing_id) AS n FROM swipe_events e JOIN section_items si ON si.section_id = e.section_id AND si.listing_id = e.listing_id WHERE e.section_id = ? AND si.slot = 'return'""", (section['id'],)).fetchone()['n'] wild_hits = conn.execute( """SELECT COUNT(DISTINCT e.listing_id) AS n FROM swipe_events e JOIN section_items si ON si.section_id = e.section_id AND si.listing_id = e.listing_id WHERE e.section_id = ? AND si.slot = 'wildcard' AND e.action IN ('like','maybe')""", (section['id'],)).fetchone()['n'] counts['restruck'] = restruck counts['wildcard_hits'] = wild_hits return counts def maybe_exhaust(conn, section, now): """Flip an all-decided open section to 'report' (returns the fresh row).""" if section['status'] != 'open': return section decided_ids = section_decided_ids(conn, section) items = section_items(conn, section) if items and not [it for it in items if it['id'] not in decided_ids]: close_section(conn, section, now, status='report') return conn.execute('SELECT * FROM sections WHERE id=?', (section['id'],)).fetchone() return section def read_section(conn, section, now): """Meta + items + progress + exhausted for GET /api/section.""" section = maybe_exhaust(conn, section, now) items = section_items(conn, section) decided_ids = section_decided_ids(conn, section) decided = sum(1 for it in items if it['id'] in decided_ids) return { 'section': { 'id': section['id'], 'status': section['status'], 'size': section['size'], 'profile_version': section['profile_version'], 'recipe': json.loads(section['recipe']) if section['recipe'] else {}, 'created_at': section['created_at'], 'closed_at': section['closed_at'], }, 'items': items, 'progress': {'decided': decided, 'undecided': len(items) - decided, 'total': len(items)}, 'exhausted': section['status'] != 'open', }