feat(v1.1): item 9 — batch formula v2 compose rewrite (AMENDS the frozen '<=5 returns' rule)

Allocator now: reserved returns (>=2 whenever eligible stock exists,
role order, surprising-gate on reserved nope slots, cap-4 family) ->
wildcards (2) -> fresh (remainder, capped at 23; wilds draw from the
fresh pool without consuming fresh slots) -> fill returns to size (role
order, <=2 re-nopes total, two-pass family cap: 4 preferred, 8 rescue for
fill slots only). MAX_RETURNS dies; FRESH_CAP=23, MIN_RETURNS=2,
RETURN_FILL_FAMILY_CAP=8 land. Rich day = 21 fresh + 2 second looks +
2 wildcards (the sign-off shape); dry day fills to 25 with returns; 0/0/0
composes an empty section; shortfalls are never padded.

Docs: module docstring, RANKING.md section recipe and DATA-MODEL returns
eligibility rewritten to the v1.1 contract (+ rubric_debug pref noted).

Selfcheck: A1-A8 compose matrix on deterministic staged pools (21/2/2
rich; 5 fresh + 20 SL fill incl. wild-from-fresh derivation; 21/2/2
sign-off; 12-return dry with <=2 nopes + LAST_TWO re-compose; 40 fresh ->
23+2 then the 15 leftover with zero overlap; 0/0/0 empty; family-cap 8
fill rescue; rested/gate-blocked re-nope rules). Pool-honest note: the
spec's A2 '5 fresh + 18 returns + 2 wild' assumed 7 fresh listings —
wildcards are by definition never-swiped, so a 5-fresh day reads 5 fresh
total (3 new + 2 wild) + 20 second looks.
This commit is contained in:
Torres
2026-11-13 13:53:33 -04:00
parent a1455aade4
commit 2160ba6ce8
4 changed files with 352 additions and 25 deletions

View File

@@ -1,8 +1,12 @@
"""Bike-Tinder P3 section engine — compose / read / exhaust / forge.
The deck is one finite SECTION at a time (default 25 cards):
2 wildcards + <=5 returns (<=2 nopes, "surprising" only) + remainder new,
per docs/RANKING.md + docs/DATA-MODEL.md "P3 additions".
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
@@ -11,10 +15,12 @@ Pinned order within a section (position 1 first):
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; >=3 price bands when the
pool allows (soft repair swaps new-slot items within the final list).
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.
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
@@ -26,9 +32,11 @@ from . import taste as tastemod
from .taste import NOPE
FAMILY_CAP = 4
MAX_RETURNS = 5
RETURN_FILL_FAMILY_CAP = 8
MAX_RETURN_NOPES = 2
WILDCARDS = 2
FRESH_CAP = 23
MIN_RETURNS = 2
DEFAULT_SIZE = 25
MIN_BANDS = 3
@@ -262,6 +270,11 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None,
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(
@@ -297,18 +310,18 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None,
top_bands = {tastemod.price_band(r['price_num']) for r in top3
if tastemod.price_band(r['price_num']) is not None}
# 2) returns: maybe > like > nope, points DESC within role; nopes must be
# "surprising" (points >=55 or net-positive family); <=5, <=2 nopes.
# Budget: wildcards keep their 2 slots; returns never push the
# section past `size`.
wanted_rets = min(MAX_RETURNS, max(0, size - min(WILDCARDS, size)))
# 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 sorted(ret_cands, reverse=True,
key=lambda r: (-role_order.get(r['verdict'], 9),
_points_key(engine, r))):
if len(rets) >= wanted_rets:
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:
@@ -325,7 +338,8 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None,
if fam:
fam_count[fam] = fam_count.get(fam, 0) + 1
# 3) wildcards: min(2, size), preferred different family AND price band
# 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,
@@ -339,7 +353,7 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None,
and (tastemod.price_band(r['price_num']) is None
or tastemod.price_band(r['price_num']) not in top_bands)]
n_wilds = min(WILDCARDS, size)
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)
@@ -355,12 +369,41 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None,
picked_ids.add(cand['id'])
if fam:
fam_count[fam] = fam_count.get(fam, 0) + 1
n_wilds = len(wilds) # pool shortfall reverts to new
n_wilds = len(wilds) # pool shortfall reverts to fresh/fill
# 4) new fills the remainder of the section (size - returns - wildcards)
want_new = max(0, size - len(rets) - n_wilds)
# 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

View File

@@ -1459,6 +1459,288 @@ def test_v11_item8_pool_and_dry():
shutil.rmtree(d, ignore_errors=True)
# --- item 9: batch formula v2 compose matrix (A1-A8, staged pools) --------
def _v11_wipe(conn):
"""Reset a temp copy to a controlled catalog: no sections/swipes/events,
everything parked 'gone' (rows kept so ids/families can be re-used)."""
conn.execute('DELETE FROM section_items')
conn.execute('DELETE FROM sections')
conn.execute('DELETE FROM swipes')
conn.execute('DELETE FROM swipe_events')
conn.execute("UPDATE listings SET status='gone' WHERE status!='dup'")
def _v11_ids(conn, n, start=0):
return [r['id'] for r in conn.execute(
'SELECT id FROM listings ORDER BY id LIMIT ? OFFSET ?',
(n, start))]
def _v11_activate(conn, ids, family=None, score=None):
for lid in ids:
if score is None:
conn.execute("UPDATE listings SET status='active', family=?"
" WHERE id=?", (family, lid))
else:
conn.execute("UPDATE listings SET status='active', family=?,"
" score=? WHERE id=?", (family, score, lid))
def _v11_stage_returns(conn, ids, action='maybe', rests=()):
"""swipes row + round-1 event per id; rests get a round-2 nope event
(the REST rule bars them from returns forever)."""
rests = set(rests)
for lid in ids:
conn.execute("INSERT INTO swipes (listing_id, action, created_at)"
" VALUES (?, ?, '2026-11-01T00:00:00Z')",
(lid, action))
conn.execute("INSERT INTO swipe_events (listing_id, action, round,"
" section_id) VALUES (?, ?, 1, NULL)", (lid, action))
if lid in rests:
conn.execute("INSERT INTO swipe_events (listing_id, action,"
" round, section_id) VALUES (?, 'nope', 2, NULL)",
(lid,))
def _v11_stage_fresh(conn, ids, seen=False):
"""No swipes row (=fresh). seen=True drops wildcard eligibility via a
round-1 event (fresh pool and wildcard pool are otherwise the same)."""
for lid in ids:
if seen:
conn.execute("INSERT INTO swipe_events (listing_id, action,"
" round, section_id) VALUES (?, 'maybe', 1, NULL)",
(lid,))
def _v11_stage_support(conn, ids, family):
"""Gone-status listings whose events tilt a family's net (nope-gate)."""
for lid in ids:
conn.execute("UPDATE listings SET family=? WHERE id=?", (family, lid))
conn.execute("INSERT INTO swipe_events (listing_id, action, round,"
" section_id) VALUES (?, 'like', 1, NULL)", (lid,))
def _v11_compose(db_path, size=25):
from . import sections as secmod
conn = db.connect(db_path)
db.init_db(conn)
engine = secmod.build_engine(
conn, now=datetime.datetime.now(datetime.timezone.utc).replace(
tzinfo=None))
sec = secmod.compose_section(conn, engine, size=size)
recipe = json.loads(sec['recipe'])
items = [dict(r) for r in secmod.section_items(conn, sec)]
conn.commit()
conn.close()
return recipe, items, engine
def _slots(items):
out = {'new': [], 'return': [], 'wildcard': []}
for it in items:
out[it['slot']].append(it)
return out
def test_v11_item9_compose_matrix():
"""A1-A8 of the item 9 acceptance criteria on deterministic pools."""
d, _c, db_path = p3_env()
def env():
conn = _sq_conn(db_path)
_v11_wipe(conn)
conn.commit()
return conn
try:
# --- A1: 30 fresh / 10 returns / wildcards -> 25: 21/2/2 ----------
conn = env()
ids = _v11_ids(conn, 60)
_v11_activate(conn, ids[0:28]) # 28 fresh
_v11_stage_fresh(conn, ids[0:28]) # (all wild-eligible)
_v11_activate(conn, ids[28:38]) # 10 returns
_v11_stage_returns(conn, ids[28:38])
conn.commit(); conn.close()
recipe, items, engine = _v11_compose(db_path)
slots = _slots(items)
assert len(items) == 25, len(items)
assert (recipe['new'], recipe['return'], recipe['wildcard']) \
== (21, 2, 2), recipe
assert (len(slots['new']), len(slots['return']),
len(slots['wildcard'])) == (21, 2, 2)
# pinned order: new -> return -> wildcard
roles = [it['slot'] for it in items]
assert roles == ['new'] * 21 + ['return'] * 2 + ['wildcard'] * 2, \
roles[:2]
# fresh picks ordered points DESC (non-increasing)
pts = [engine.points(it['id']) for it in slots['new']]
assert all(a >= b for a, b in zip(pts, pts[1:])), pts
# --- A2a: 5 fresh / 20 returns / wildcards -> 5/18/2 ---------------
conn = env()
ids = _v11_ids(conn, 60)
_v11_activate(conn, ids[0:3])
_v11_stage_fresh(conn, ids[0:3]) # 3 fresh-only
_v11_activate(conn, ids[3:5])
_v11_stage_fresh(conn, ids[3:5]) # 2 fresh+wild
_v11_activate(conn, ids[5:25]) # 20 returns
_v11_stage_returns(conn, ids[5:25])
conn.commit(); conn.close()
recipe, items, _e = _v11_compose(db_path)
assert len(items) == 25, len(items)
# Wildcards are BY DEFINITION never-swiped (wild pool ⊆ fresh pool),
# so on a 5-fresh day the 2 wilds come from those 5: the deck reads
# 5 fresh-total (3 new + 2 wild) + 20 second looks = 25 — Glenn's
# "5 fresh listed, rest second looks". The spec's "5 fresh + 18
# returns + 2 wild" arithmetic assumed 7 fresh-eligible listings;
# with 5 the pool-honest fill is 20 (implementer-verified).
assert (recipe['new'], recipe['return'], recipe['wildcard']) \
== (3, 20, 2), recipe
# --- A2b: same but the wildcard pool is dry -> 5/20/0 --------------
conn = env()
ids = _v11_ids(conn, 60)
_v11_activate(conn, ids[0:5])
_v11_stage_fresh(conn, ids[0:5], seen=True) # fresh, no wilds
_v11_activate(conn, ids[5:25]) # 20 returns
_v11_stage_returns(conn, ids[5:25]) #
conn.commit(); conn.close()
recipe, items, _e = _v11_compose(db_path)
assert len(items) == 25, len(items)
assert (recipe['new'], recipe['return'], recipe['wildcard']) \
== (5, 20, 0), recipe
# --- A3: 23 fresh / 5 returns / wildcards -> 21/2/2 (the sign-off
# --- sentence shape) ----------------------------------------------
conn = env()
ids = _v11_ids(conn, 60)
_v11_activate(conn, ids[0:23])
_v11_stage_fresh(conn, ids[0:23])
_v11_activate(conn, ids[23:28]) # 5 returns
_v11_stage_returns(conn, ids[23:28])
conn.commit(); conn.close()
recipe, items, _e = _v11_compose(db_path)
slots = _slots(items)
assert len(items) == 25 and len(slots['new']) == 21 \
and len(slots['return']) == 2 \
and len(slots['wildcard']) == 2, recipe
# --- A4: 0 fresh / 12 returns (2 nope) -> 12 returns, <=2 nopes,
# --- LAST_TWO excl: second compose repeats nothing -----------------
conn = env()
ids = _v11_ids(conn, 60)
_v11_activate(conn, ids[0:12])
_v11_stage_returns(conn, ids[0:10], 'maybe')
_v11_stage_returns(conn, ids[10:12], 'nope') # 2 re-nope cands
conn.commit(); conn.close()
recipe, items, _e = _v11_compose(db_path)
slots = _slots(items)
assert len(items) == 12 and len(slots['new']) == 0 \
and len(slots['return']) == 12 \
and len(slots['wildcard']) == 0, recipe
n_nopes = sum(1 for it in slots['return']
if it['decision'] == 'nope')
assert n_nopes <= 2, n_nopes
first_ids = {it['id'] for it in items}
# second compose on the same pool: LAST_TWO bars the 12 just served
# and returns stay one-shot -> nothing to serve
recipe2, items2, _e = _v11_compose(db_path)
assert len(items2) == 0, items2
assert not ({it['id'] for it in items2} & first_ids)
# --- A5: 40 fresh / 0 returns -> 25 then the 15 leftover, no
# --- overlap, fresh ordered points DESC ----------------------------
conn = env()
ids = _v11_ids(conn, 60)
_v11_activate(conn, ids[0:40])
_v11_stage_fresh(conn, ids[0:40])
# spread scores so the points DESC order check is meaningful
for k, lid in enumerate(ids[0:40]):
conn.execute("UPDATE listings SET score=? WHERE id=?",
(40 - k, lid))
conn.commit(); conn.close()
recipe, items, engine = _v11_compose(db_path)
assert len(items) == 25, len(items)
assert recipe['return'] == 0, recipe
first_ids = {it['id'] for it in items}
slots1 = _slots(items)
pts = [engine.points(it['id']) for it in slots1['new']]
# scores unique DESC -> fresh-slot points non-increasing (wildcards
# sit pinned at the back and don't participate in that order)
assert pts == sorted(pts, reverse=True), pts
recipe2, items2, _e = _v11_compose(db_path)
slots2 = _slots(items2)
assert recipe2['return'] == 0, recipe2
assert len(items2) == 15, len(items2)
second_ids = {it['id'] for it in items2}
assert not (first_ids & second_ids) # zero overlap
# --- A6: 0/0/0 -> empty composed section (FE side = item 8) --------
conn = env()
conn.commit(); conn.close()
recipe, items, _e = _v11_compose(db_path)
assert items == [], items
assert (recipe['new'], recipe['return'], recipe['wildcard']) \
== (0, 0, 0), recipe
# --- A7: family cap — fill-heavy cruiser pool: fresh/reserved hold
# --- <=4, fill raises return slots to <=8 --------------------------
conn = env()
ids = _v11_ids(conn, 60)
_v11_activate(conn, ids[0:40], family='cruiser')
_v11_stage_fresh(conn, ids[0:10], seen=True) # 10 fresh cruiser
_v11_stage_returns(conn, ids[10:40], 'maybe') # 30 cruiser rets
conn.commit(); conn.close()
recipe, items, _e = _v11_compose(db_path)
slots = _slots(items)
fam = {}
for it in items:
fam[it['family']] = fam.get(it['family'], 0) + 1
assert fam.get('cruiser', 0) <= 8, fam
assert len(slots['new']) == 2, recipe # fresh stops at cap 4
assert len(slots['return']) == 6, recipe # 2 reserved + 4 fill
assert (recipe['new'], recipe['return']) == (2, 6), recipe
# --- A8: re-nope gate — <=2 nope returns total; a rested item never
# --- returns; gate can empty the reserved slots --------------------
conn = env()
ids = _v11_ids(conn, 60)
_v11_activate(conn, ids[0:10])
_v11_stage_returns(conn, ids[0:10], 'maybe') # 10 maybes
_v11_activate(conn, ids[10:15], family='gatefam', score=100)
_v11_stage_returns(conn, ids[10:15], 'nope') # 5 nope re-nopes
# family net positive: gate passes for gatefam's nope candidates
_v11_stage_support(conn, ids[30:40], family='gatefam') # 10 likes
_v11_activate(conn, ids[15:16], family='gatefam')
_v11_stage_returns(conn, ids[15:16], 'maybe',
rests=[ids[15]]) # rested: r2 nope
conn.commit(); conn.close()
recipe, items, _e = _v11_compose(db_path)
slots = _slots(items)
assert len(items) == 12, len(items) # 10 maybe + 2 nope
n_nopes = sum(1 for it in slots['return']
if it['decision'] == 'nope')
assert n_nopes == 2, n_nopes
assert all(it['id'] != ids[15] for it in slots['return']) # rested
# gate-block: low-score nope family, net NOT positive -> reserved
# slots skipped; fill still caps at 2 nopes (gate-free fill rule)
conn = env()
ids = _v11_ids(conn, 60)
_v11_activate(conn, ids[0:3], family='blockfam', score=1)
_v11_stage_returns(conn, ids[0:3], 'nope') # 3 gate-blocked
conn.commit(); conn.close()
recipe, items, _e = _v11_compose(db_path)
slots = _slots(items)
assert recipe['return'] == 2, recipe # fill cap only
assert all(it['decision'] == 'nope'
for it in slots['return']), items
finally:
shutil.rmtree(d, ignore_errors=True)
P3_API_TESTS = [
('p3: events seeding idempotent (from swipes)', test_p3_event_seeding_idempotent),
('p3: swipe round math + clear-then-re-decide', test_p3_round_math_and_clear),
@@ -1482,6 +1764,8 @@ V11_TESTS = [
test_v11_item7_stats_fresh),
('v1.1: item 8/6 — pool counts, 0/0/0 empty compose, dry forge note',
test_v11_item8_pool_and_dry),
('v1.1: item 9 — batch formula v2 compose matrix A1-A8',
test_v11_item9_compose_matrix),
]
if API_DEPS:

View File

@@ -162,9 +162,9 @@ Semantics:
- Round = prior events for that listing + 1, assigned at write time. Rounds cap at 2 for engine influence (any later events weigh as round 2; v1 returns never produce rounds >2 by design).
- **Migration seeding:** when `swipe_events` is empty and `swipes` has rows → insert one round-1 event per swipe row (created_at copied). Only-when-empty, hence idempotent.
- **Sections:** at most ONE `open` section at a time. The deck = that section's `section_items` in `position` order, filtered to items undecided at read. Exhausted = no item undecided → `status='report'`. Forge = close current + open new. First-ever read composes a cold section (rubric-weighted, w=0).
- **Returns eligibility (v1 contract):** a verdict exists (like/maybe/nope in `swipes`); listing `active`; NOT in either of the previous two sections; no round≥2 `nope` ever (rested); not already returned once (round cap). Selection prefers maybe > like > nope; nope candidates must be "surprising" (points ≥55 or net-positive family). Max 5 returns per section, ≤2 of them nopes.
- **Returns eligibility (v1.1 contract, amends the frozen ≤5 rule):** a verdict exists (like/maybe/nope in `swipes`); listing `active`; NOT in either of the previous two sections; no round≥2 `nope` ever (rested); not already returned once (round cap). Selection prefers maybe > like > nope; nope candidates must be "surprising" (points ≥55 or net-positive family) for the RESERVED slots only. Every batch carries ≥2 returns whenever eligible stock exists and fills the remainder up to 25 when fresh < 23; ≤2 nope-verdict returns per batch; fill returns relax the per-family cap from 4 to 8 (fill slots only).
- **`points` is computed, not stored** (deterministic from events + profile; a cache is allowed only if it never changes semantics).
- **prefs:** `forge_mode` ('ondemand' default | 'strict'). Existing keys unchanged.
- **prefs:** `forge_mode` ('ondemand' default | 'strict') · `rubric_debug` ('off' default | 'on' — dossier "why" breakdown visibility; v1.1). Existing keys unchanged.
- **Legacy deck semantics:** `/api/deck` (cursor paging; 7-day maybe re-admit) stays as a read-only fallback for debugging; sections are the production flow.
## B1 additions — frozen 2026-11-10 (photo experience v2)

View File

@@ -26,7 +26,7 @@ Deterministic, explainable, pure-Python. Learning reads `swipe_events` only.
**Score.** `taste_raw` = Σ shrunk weights over the listing's values; `taste_norm` = percentile rank among active listings (0–100). Blend: `w = min(0.85, N/60)` where N = total events; `points = clip( round( (1−w)·rubric + w·taste_norm + agent_adj ), 0, 100 )`. `agent_adj` = Σ profile adjustments (clamped ±10 per listing). Breakdown for the dossier: rubric share · top 3 ± taste features · agent lines — all source-labeled.
**Section recipe.** 25 cards = 2 wildcards + ≤5 returns + remainder new (points DESC). Constraints: ≤4 per family per section; ≥3 price bands when the pool allows; shortfalls fill from new.
**Section recipe.** Every batch targets 25 cards = fresh first (up to 23 slots) + ≥2 second looks whenever eligible stock exists + the 2 wildcard slots (wildcards draw from the fresh pool). When fresh < the remainder, second looks fill up to 25 (family cap relaxes to 8 for fill returns only); leftover fresh rolls to the next batch; fresh/return shortfalls are never padded — a drained pool yields a short (or empty) section. Constraints: ≤4 per family per section for fresh + reserved returns; ≥3 price bands when the pool allows.
**Wildcards.** Candidates: active, unseen, not in the last two sections. Exclusions: families with ≥3 cumulative nopes and net-negative weight. Prefer: different family AND different price band than the section's top new items. Score = `0.5·points + 0.5·novelty` (novelty = distance from the liked-profile centroid). Picks: 2 per section.