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.
48 lines
5.2 KiB
Markdown
48 lines
5.2 KiB
Markdown
# RANKING — Bike-Tinder
|
||
|
||
The deck is ordered by predicted taste. Three rungs, in order: the hunt's existing rubric (v1), a cheap explainable boost from swipe clusters (v1.x), then a small learned model (Phase 3). Nothing ever becomes a black box.
|
||
|
||
## Signals
|
||
- Like = strong positive (+1). Maybe = weak positive (+0.4) on the *direction* — "right lane, wrong trim" (Glenn's read): the family/silhouette reads positive, the specific build is uncertain; maybes replenish via second looks + variant hunts. Nope = negative (−1). List-view edits count identically; the latest call wins (full history lives in `swipe_events`).
|
||
- Planned v2 signals: dwell time on card, "open listing" click-outs.
|
||
|
||
## Features (v1)
|
||
Per listing: family tier (from the hunt rubric), displacement (cc), price, year, km, km/year, dealer vs private, place/region, keyword flags from title + description (MVI/inspection, whitewalls, spokes, windshield, saddlebags, ABS, bobber/custom), color words (red is known lore), title tokens (make/model). Numeric features normalized; missing values neutral.
|
||
- Single-source v1 makes source/region features near-constant — kept in the schema for v2.
|
||
|
||
## Stage 1 — Cold start
|
||
Order by the hunt's existing rubric score (family tiers + MVI / whitewalls / windshield / spokes / ABS bonuses + price fit). This is already explainable (reason strings exist in the hunt system). Deck = rubric order until ≥ 20 swipes.
|
||
|
||
## Stage 1.5 — Cheap swipe boost (v1.x, post-launch)
|
||
Before any model exists, the importer can nudge `score` with plain swarm signals — e.g. +N when recent likes cluster in a family, small time-decay otherwise — recomputed each import, zero ML, fully explainable. Ships as v1.x if Glenn wants it; v1 itself stays pure rubric.
|
||
|
||
## Stage 2 — P3 taste engine (frozen 2026-11-10)
|
||
|
||
Deterministic, explainable, pure-Python. Learning reads `swipe_events` only.
|
||
|
||
**Event weights.** action: like +1.0 · maybe +0.4 · nope −1.0; round multiplier: round 1 ×1, round ≥2 **×2** (second strikes hit twice as hard — reaffirmations and flips both); recency: ×0.5^(age_days / 21). Event weight = product.
|
||
|
||
**Feature weights.** Feature set per listing: family · price band (`<3k, 3–5k, 5–8k, 8–12k, 12k+`) · displacement band (from title/desc) · year band · km band (when present) · each extras flag · seller type · silhouette keywords (bobber/custom/classic/cruiser) · color words · region. For each (feature, value): `raw = Σ event weights`; `shrunk = raw × n/(n+3)` — no single strike taboos a family.
|
||
|
||
**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.** 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.
|
||
|
||
**Returns ("second looks").** Pool + caps per DATA-MODEL. Order: maybe > like > surprising nope (points ≥55 or net-positive family); rank within role by points DESC. A re-nope (round≥2 nope) rests the item permanently in v1.
|
||
|
||
**Guardrails.** Never hide (List untouched; ranking only reorders). The ramp keeps cold start on rubric; the engine must run with zero profile (w=0). Agent offline → compose on the last profile (source-labeled). **No LLM inside any request path or computed score** — the pass only writes the profile.
|
||
|
||
## Explainability
|
||
Each card can show up to 2 chips: top positive contributions for that bike ("low km for the money", "Vulcan family", "red", "MVI"). Chips derive from the live feature contributions; during cold start they come from the rubric's existing reasons.
|
||
|
||
## Evaluation
|
||
- Track: swipes/session, like-rate trend, maybe-rate, undo rate, and "liked-item rank" (how deep liked items sat — should trend shallow).
|
||
- P3 adds: hit rate (likes/decided · likes+maybes), returns re-strike rate, wildcard hit rate, flip count — surfaced in the Trends tab.
|
||
- Notifications (decided 2026-11-10): in-app "new since last visit" badge only — no Conduit pushes. A weekly digest remains possible later if asked for.
|
||
|
||
## Open decisions
|
||
- Maybe-handling — **resolved 2026-11-10 (Glenn):** weak positive on the direction ("right lane, wrong trim"); maybes replenish via second looks + variant hunts.
|
||
- Distance feature (map-based proximity) — likely v2.
|