From 2160ba6ce8a6d2cfa8d701040ac878d291a84f75 Mon Sep 17 00:00:00 2001 From: Torres Date: Fri, 13 Nov 2026 13:53:33 -0400 Subject: [PATCH] =?UTF-8?q?feat(v1.1):=20item=209=20=E2=80=94=20batch=20fo?= =?UTF-8?q?rmula=20v2=20compose=20rewrite=20(AMENDS=20the=20frozen=20'<=3D?= =?UTF-8?q?5=20returns'=20rule)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/sections.py | 87 ++++++++++---- app/selfcheck.py | 284 +++++++++++++++++++++++++++++++++++++++++++++ docs/DATA-MODEL.md | 4 +- docs/RANKING.md | 2 +- 4 files changed, 352 insertions(+), 25 deletions(-) diff --git a/app/sections.py b/app/sections.py index 5f09520..02f5707 100644 --- a/app/sections.py +++ b/app/sections.py @@ -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 diff --git a/app/selfcheck.py b/app/selfcheck.py index 320393a..70a3b4b 100644 --- a/app/selfcheck.py +++ b/app/selfcheck.py @@ -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: diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index f0a16c2..5012859 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -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) diff --git a/docs/RANKING.md b/docs/RANKING.md index 95425de..f58470f 100644 --- a/docs/RANKING.md +++ b/docs/RANKING.md @@ -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.