tidaldb/.sdlc/features/p1-quality-diversity-baseline/design.md

7.3 KiB

Design: Quality & Diversity Baseline

Overview

This is a backend-only change. No UI, no API surface change, no schema migration. The deliverable is a new built-in ranking profile (brief) and integration tests proving quality and diversity invariants.

Architecture

All changes are confined to three areas:

tidal/src/ranking/builtins.rs        ← new profile definition + registration
tidal/src/query/executor/mod.rs      ← format enrichment fix + metadata loading trigger
tidal/tests/p1_quality_diversity.rs  ← integration tests

No new modules, no new types, no new traits. The existing Gate, DiversitySpec, Boost, and Sort types are sufficient.

Profile Definition

ranking/builtins.rs
└── fn brief() -> RankingProfile
    ├── sort: Hot { gravity: 1.5 }
    ├── gates:
    │   ├── Gate { signal: "view", agg: Value, window: AllTime, min_threshold: 3.0 }
    │   └── Gate { signal: "completion", agg: Value, window: AllTime, min_threshold: 1.0 }
    ├── boosts:
    │   ├── Boost { signal: "view", agg: DecayScore, window: AllTime, weight: 1.0 }
    │   ├── Boost { signal: "like", agg: DecayScore, window: AllTime, weight: 2.0 }
    │   └── Boost { signal: "completion", agg: DecayScore, window: AllTime, weight: 1.5 }
    ├── diversity:
    │   ├── max_per_creator: 2
    │   └── format_mix_max_fraction: 0.6
    └── exploration: 0.0  (disabled — bypasses quality gates)

Gate Behavior

Gates are evaluated in passes_gates() (in ranking/executor/helpers.rs) which runs before scoring. This means:

  1. view / Value / AllTime >= 3.0 — reads the AllTime windowed count for "view". Items with fewer than 3 total views are excluded before any scoring happens.
  2. completion / Value / AllTime >= 1.0 — reads the AllTime windowed count for "completion". Items with zero completions are excluded.

Both gates use DegradationLevel::Full (never coarsened), ensuring gate evaluation precision is never sacrificed under load.

Schema dependency: Gates reference signals view and completion. If either signal is not in the schema, read_agg returns 0.0, which fails the gate threshold, meaning all items are excluded. This is the correct behavior — a brief without view/completion signals has no quality signal to rank on and should surface nothing rather than surface garbage.

Exploration Disabled

Exploration is set to 0.0 because inject_exploration() runs after gate filtering and injects random candidates from the unscored universe. These candidates bypass quality gates, defeating the curated-brief guarantee. This was discovered during integration testing.

Diversity Behavior

Diversity is enforced by the existing DiversitySelector in Stage 5 of the scoring pipeline:

  1. max_per_creator: 2 — Greedy selection skips items from any creator that already has 2 items in the result set. Multi-stage relaxation ensures the result count invariant (INV-RANK-5) holds.
  2. format_mix_max_fraction: 0.6 — No single format can exceed 60% of results. For a 10-item brief, max 6 items of one format.

These are profile-level defaults. Callers can override via RetrieveBuilder::diversity() for query-level control.

Pipeline Fixes

Two fixes in the scoring pipeline were required:

  1. Format enrichment (query/executor/mod.rs): The metadata enrichment loop only populated ScoredCandidate.creator_id. Extended to also populate ScoredCandidate.format from item metadata's "format" field.

  2. Metadata loading trigger (query/executor/mod.rs): needs_metadata_for_creator_grouping only checked max_per_creator. Extended to also check format_mix_max_fraction, ensuring metadata is loaded when format diversity is configured.

Scoring Formula

The sort mode is Hot { gravity: 1.5 }, which uses the Reddit/HN age-decay formula. This balances recency with engagement — appropriate for a daily brief cadence where yesterday's high-engagement content should still appear but not dominate.

Three boosts add quality signals:

  • view * 1.0 — baseline popularity
  • like * 2.0 — quality signal (2x weight reflects explicit positive engagement)
  • completion * 1.5 — deeper engagement signal

Registration

In register_builtins():

registry.register(brief())?;  // added after date_saved()

Total built-in profiles: 27 (includes profiles from parallel features).

Integration Test Design

tidal/tests/p1_quality_diversity.rs:

Test 1: quality_gate_excludes_low_view_items

  • Create schema with view, completion, like signals.
  • Write 10 items: 5 with >= 3 views + >= 1 completion, 5 with 1-2 views.
  • Retrieve using brief profile.
  • Assert: only the 5 high-quality items appear.

Test 2: quality_gate_excludes_zero_completion_items

  • Write 10 items: all have >= 3 views, but 5 have 0 completions.
  • Retrieve using brief profile.
  • Assert: only the 5 items with completions appear.

Test 3: creator_diversity_enforced

  • Write 30 items from 5 creators (10 from creator 1, 5 each from creators 2-5).
  • All pass quality gates.
  • Retrieve with limit 10.
  • Assert: no single creator has all 10 items; at least 2 creators represented.

Test 4: format_diversity_enforced

  • Write 12 items: 6 video, 6 podcast, all different creators, all pass quality gates.
  • Retrieve with limit 10.
  • Assert: no format exceeds 60%.

Test 5: brief_profile_registered

  • Open DB, retrieve with brief profile on empty DB.
  • Assert: no error (profile exists), empty results.

Test 6: combined_quality_and_diversity

  • 13 items: 10 high-quality from 5 creators, 3 low-quality.
  • Retrieve brief with limit 10.
  • Assert all constraints: quality gates exclude low-quality, creator diversity <= 2 per creator, format diversity <= 60%.

Data Flow

RETRIEVE items USING PROFILE brief LIMIT 10
  │
  ├─ Stage 1: Candidate Generation (Scan)
  │   └─ All items from universe
  │
  ├─ Stage 2: Filter Evaluation
  │   └─ User-level filters (unseen, unblocked, etc.)
  │
  ├─ Stage 3: Signal Scoring
  │   ├─ Gate: view/Value/AllTime >= 3 → exclude if below
  │   ├─ Gate: completion/Value/AllTime >= 1 → exclude if below
  │   ├─ Base: Hot(gravity=1.5) scoring
  │   ├─ Boost: view * 1.0 + like * 2.0 + completion * 1.5
  │   └─ Normalize [0, 1]
  │
  ├─ Stage 5: Diversity
  │   ├─ max_per_creator: 2
  │   └─ format_mix: 0.6
  │
  └─ Stage 6: Result Assembly
      └─ Top 10, cursor, stats

Risks & Mitigations

Risk Mitigation
Schema missing view or completion signal Gates return 0.0 for unknown signals, excluding all items. This is correct — no quality signal = no brief. Integration tests verify this is the expected behavior.
Small catalog where all items are from same creator DiversitySelector's multi-stage relaxation fills to target count even when constraints are unsatisfiable. The result set reports constraints_satisfied: false but still returns results.
Gate thresholds too aggressive for small alpha catalog Thresholds are deliberately low (3 views, 1 completion). Can be tuned by registering a custom profile with adjusted gates.
Exploration re-introduces gate-failing candidates Exploration disabled (0.0) for this profile. Discovery surfaces use separate profiles with exploration enabled.