Files
Sauci/docs/question-selection.md
jjbruntonandClaude Fable 5 09255da7db feat(pairing): solo sealed answers that unlock on pairing
Unpaired users could only reach a waiting-room dead end and could not answer
questions until a partner joined. Responses.couple_id is now nullable so a
solo user answers immediately and banks "sealed answers" (couple_id IS NULL).
Pairing claims both members' sealed answers and computes matches for every
question both have now answered. Invite copy and the unpaired push nudge
escalate with the sealed answer count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:34:42 +01:00

160 lines
6.9 KiB
Markdown

# Question Selection Algorithm
This document explains how questions are selected and ordered for users in the swipe interface.
## Overview
Question selection happens in two stages:
1. **Database Layer** - `get_recommended_questions()` PostgreSQL function filters available questions
2. **Client Layer** - `swipe.tsx` applies additional filtering and sorting with bias
## Stage 1: Database Filtering
The `get_recommended_questions(target_pack_id)` function (`apps/supabase/migrations/20251227172500_role_specific_questions.sql`) returns questions the user hasn't answered yet.
### Pack Selection Logic
```
IF target_pack_id provided:
→ Use only that specific pack
ELSE IF couple has any couple_packs entries:
→ Use only packs where enabled = true
ELSE (new couple, no preferences set):
→ Use all public packs (is_public = true)
```
### Question Filtering
For each question in active packs:
- **Excluded**: Questions the current user has already answered
- **Included**: All unanswered questions from active packs
### Returned Fields
| Field | Description |
|-------|-------------|
| `id` | Question UUID |
| `text` | Display text (see two-part logic below) |
| `partner_text` | Original partner text (for reference) |
| `is_two_part` | `true` if question has different text for second responder |
| `pack_id` | Parent pack UUID |
| `intensity` | 1-5 rating |
| `partner_answered` | `true` if partner has already responded |
### Two-Part Questions
Some questions have role-specific text (`partner_text` column). The function handles this:
```
IF partner_text EXISTS AND partner has answered:
→ Return partner_text as display text
ELSE:
→ Return original text
```
This enables questions like:
- First person sees: "Would you like your partner to wake you up with oral?"
- Second person sees: "Would you wake your partner up with oral?"
## Stage 2: Client Sorting & Filtering
Located in `apps/mobile/app/(app)/swipe.tsx`, lines 57-95.
### Skip Filtering
Before sorting, questions are filtered against locally-skipped questions:
```typescript
const filtered = data.filter(q => !skippedIds.has(q.id));
```
**Skip behavior** (`src/lib/skippedQuestions.ts`):
- Swipe down = skip question
- Skipped questions hidden for **24 hours**
- Stored in SecureStore (native) or localStorage (web)
- No server-side record - purely client-side convenience
### Sorting Algorithm with Bias
Questions are sorted by a computed score:
```typescript
const sorted = filtered.sort((a, b) => {
let scoreA = Math.random(); // Base: 0.0 - 1.0
let scoreB = Math.random();
// Apply bias bonuses
if (a.partner_answered && a.is_two_part) scoreA += 1.5;
else if (a.partner_answered) scoreA += 0.7;
else if (a.is_two_part) scoreA += 0.4;
// Same for scoreB...
return scoreB - scoreA; // Descending order
});
```
### Priority Tiers
| Priority | Condition | Score Range | Rationale |
|----------|-----------|-------------|-----------|
| **1st** | `partner_answered` + `is_two_part` | 1.5 - 2.5 | Completes two-part question, immediate match potential |
| **2nd** | `partner_answered` only | 0.7 - 1.7 | High match potential, partner waiting |
| **3rd** | `is_two_part` only | 0.4 - 1.4 | Initiates two-part sequence |
| **4th** | Neither | 0.0 - 1.0 | Standard questions, fully randomized |
### Why This Bias?
1. **Engagement**: Questions partner already answered can immediately create matches, providing instant gratification
2. **Two-part completion**: When partner answered a two-part question first, completing it quickly maintains context and creates better matches
3. **Two-part initiation**: Slightly prioritizes two-part questions to create more engaging interactions
4. **Randomization within tiers**: The random base prevents predictable ordering while maintaining priority bands
## Visual Flow
```
┌─────────────────────────────────────────────────────────────┐
│ get_recommended_questions() │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Pack Filter │ → │ User Answered │ → │ Two-Part Text │ │
│ │ │ │ Exclusion │ │ Resolution │ │
│ └─────────────┘ └──────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Client (swipe.tsx) │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Skip Filter │ → │ Bias Scoring │ → │ Sort │ │
│ │ (24 hour) │ │ │ │ (Descending) │ │
│ └─────────────┘ └──────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓
Questions shown to user
```
## Edge Cases
| Scenario | Behavior |
|----------|----------|
| No packs enabled | Shows "No packs enabled" prompt (paired couples only; a couple with no configured packs has not chosen yet) |
| All questions answered | Shows "All caught up!" with refresh button |
| No partner paired | Answers straight into the swipe flow instead of a blocking prompt. Every answer is sealed (`couple_id IS NULL`, no match). A persistent banner reports the sealed count with an invite CTA. See [Solo answering and sealed answers](couple-pairing.md#solo-answering-and-sealed-answers). |
| Pack disabled mid-session | Client filters out questions from disabled packs |
An unpaired user has no `couple_packs` row to configure (it is keyed to
`couple_id`), so `recommended()`'s "unconfigured couple" fallback (every
public pack) is what they always see; the pack-selection screen only applies
once paired.
## Configuration
| Setting | Value | Location |
|---------|-------|----------|
| Skip duration | 24 hours | `src/lib/skippedQuestions.ts:6` |
| Two-part + answered bias | +1.5 | `swipe.tsx:78` |
| Answered-only bias | +0.7 | `swipe.tsx:79` |
| Two-part-only bias | +0.4 | `swipe.tsx:80` |