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:
@@ -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
|
||||
|
||||
|
||||
284
app/selfcheck.py
284
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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user