Files
Sauci/docs/question-selection.md
T
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

6.9 KiB

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:

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:

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.
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